Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions modules/sdk-coin-icp/src/icp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ export class Icp extends BaseCoin {
return multisigTypes.tss;
}

/**
* Account consolidations (receive address -> root address) are supported;
* the platform enforces V2-only consolidations (CECHO-2443).
*/
allowsAccountConsolidations(): boolean {
return true;
}

/** @inheritDoc */
getMPCAlgorithm(): MPCAlgorithm {
return 'ecdsa';
Expand Down
106 changes: 105 additions & 1 deletion modules/sdk-coin-icp/test/unit/icp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { BitGoAPI } from '@bitgo/sdk-api';
import { Ecdsa } from '@bitgo/sdk-core';
import utils from '../../src/lib/utils';
import { getBuilderFactory } from './getBuilderFactory';

Expand Down Expand Up @@ -49,6 +50,7 @@ describe('Internet computer', function () {
icp.getFullName().should.equal('Internet Computer');
icp.getBaseFactor().should.equal(1e8);
icp.supportsTss().should.equal(true);
icp.allowsAccountConsolidations().should.equal(true);

ticp.getChain().should.equal('ticp');
ticp.getFamily().should.equal('icp');
Expand Down Expand Up @@ -259,6 +261,38 @@ describe('Internet computer', function () {
});
assert(response);
});

it('should successfully verify a derived-to-root consolidation prebuild', async () => {
// Consolidation prebuild shape (sendAccountConsolidation): single output
// from a derived receive address (sender) to the bare root recipient.
const consolidationTxBuilder = factory.getTransferBuilder();
consolidationTxBuilder.sender(testData.Accounts.account1.address, testData.Accounts.account1.publicKey);
consolidationTxBuilder.receiverId(testData.Accounts.account2.address);
consolidationTxBuilder.amount('10');
await consolidationTxBuilder.build();

const payloadsData = consolidationTxBuilder.transaction.payloadsData;
const serializedTxFormat = {
serializedTxHex: payloadsData,
publicKey: testData.Accounts.account1.publicKey,
};
const serializedTxHex = Buffer.from(JSON.stringify(serializedTxFormat), 'utf-8').toString('hex');
const txParams = {
recipients: [
{
address: testData.Accounts.account2.address,
amount: '10',
},
],
};
const response = await basecoin.verifyTransaction({
txPrebuild: {
txHex: serializedTxHex,
},
txParams: txParams,
});
assert(response);
});
});

describe('Address Verification', () => {
Expand All @@ -268,6 +302,19 @@ describe('Internet computer', function () {
rootAddress: 'fd3eaed3e2064bd30ab497e22e8ac5a0dcadd81fa5353879dbab64e259ec70c0',
};

// V2 (HD-derived) receive address at index 1, derived exactly the way
// verifyMPCWalletAddress/deriveMPCWalletAddress derives it: secp256k1 path
// 'm/1', first 33 bytes of the derived public key.
let derivedAddressAtIndex1: string;

before(function () {
derivedAddressAtIndex1 = utils.getAddressFromPublicKey(
Buffer.from(new Ecdsa().deriveUnhardened(addressVerificationData.commonKeychain, 'm/1'), 'hex')
.subarray(0, 33)
.toString('hex')
);
});

describe('Wallet VersionKey 1', () => {
let keychains;

Expand Down Expand Up @@ -430,7 +477,7 @@ describe('Internet computer', function () {
});
});

describe('Wallet VersionKey 2+', () => {
describe('V2 non-memoId wallets', () => {
let keychains;

before(function () {
Expand Down Expand Up @@ -492,6 +539,63 @@ describe('Internet computer', function () {
const result = await basecoin.isWalletAddress(params);
result.should.equal(true);
});

it('should verify the V2 root address at index 0', async function () {
const params = {
address: addressVerificationData.rootAddress,
rootAddress: addressVerificationData.rootAddress,
keychains: keychains,
index: 0,
walletVersion: 2,
};

const result = await basecoin.isWalletAddress(params);
result.should.equal(true);
});

it('should verify a derived address at its own index', async function () {
const params = {
address: derivedAddressAtIndex1,
rootAddress: addressVerificationData.rootAddress,
keychains: keychains,
index: 1,
walletVersion: 2,
};

const result = await basecoin.isWalletAddress(params);
result.should.equal(true);
});

it('should reject a derived address checked against a different index', async function () {
const params = {
address: derivedAddressAtIndex1,
rootAddress: addressVerificationData.rootAddress,
keychains: keychains,
index: 0,
walletVersion: 2,
};

await basecoin
.isWalletAddress(params)
.should.be.rejectedWith(
`address validation failure: address ${derivedAddressAtIndex1} is not a wallet address`
);
});

it('should reject memo-suffixed addresses for V2 non-memoId wallets', async function () {
const addressWithMemo = `${addressVerificationData.rootAddress}?memoId=5`;
const params = {
address: addressWithMemo,
rootAddress: addressVerificationData.rootAddress,
keychains: keychains,
index: 0,
walletVersion: 2,
};

await basecoin
.isWalletAddress(params)
.should.be.rejectedWith(`address validation failure: address ${addressWithMemo} is not a wallet address`);
});
});

describe('Address validation', () => {
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-coin-icp/test/unit/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ describe('ICP Transaction', () => {
payloadsData.payloads.length.should.equal(1);
});

it('should keep a memo-suffixed receiver address verbatim in the transfer operations', async () => {
const factory = getBuilderFactory('ticp');
const txBuilder = factory.getTransferBuilder();
txBuilder.sender(testData.Accounts.account1.address, testData.Accounts.account1.publicKey);
txBuilder.receiverId(testData.Accounts.account6.address);
txBuilder.amount('10');
await txBuilder.build();

const icpTransaction = txBuilder.transaction.icpTransaction;
should.equal(icpTransaction.operations[1].account.address, testData.Accounts.account6.address);
});

it('build a json transaction from raw hex', async () => {
await tx.fromRawTransaction(serializedTxHex);
const json = tx.toJson();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,36 @@ describe('ICP Transaction Builder with default memo as 0', async () => {
signedTxn.should.be.a.String();
});
});

describe('ICP Transaction Builder with bare (non-memo) addresses', async () => {
const factory = getBuilderFactory('ticp');

beforeEach(async function () {
// Echo back the memo the builder passes in so `metadata.memo` reflects the
// DEFAULT_MEMO fill-in in TransferBuilder.buildImplementation.
sinon.stub(utils, 'getMetaData').callsFake((memo) => ({
metaData: { ...testData.MetaDataWithDefaultMemo, memo: Number(memo) },
ingressEndTime: testData.MetaDataWithDefaultMemo.ingress_end ?? 0,
}));
sinon.stub(utils, 'validateExpireTime').returns(true);
});

afterEach(function () {
sinon.restore();
});

it('should keep bare sender and receiver addresses verbatim and default the memo to 0', async () => {
const txBuilder = factory.getTransferBuilder();
txBuilder.sender(testData.Accounts.account1.address, testData.Accounts.account1.publicKey);
txBuilder.receiverId(testData.Accounts.account2.address);
txBuilder.amount('10');

await txBuilder.build();

const icpTransaction = txBuilder.transaction.icpTransaction;
should.equal(icpTransaction.operations[0].account.address, testData.Accounts.account1.address);
should.equal(icpTransaction.operations[2].account.address, testData.Accounts.account1.address);
should.equal(icpTransaction.operations[1].account.address, testData.Accounts.account2.address);
should.equal(icpTransaction.metadata.memo, DEFAULT_MEMO);
});
});
Loading