Skip to content
Merged
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
36 changes: 35 additions & 1 deletion modules/sdk-coin-canton/src/canton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { auditEddsaPrivateKey } from '@bitgo/sdk-lib-mpc';
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
import { TransactionBuilderFactory } from './lib';
import { KeyPair as CantonKeyPair } from './lib/keyPair';
import { TxData } from './lib/iface';
import utils from './lib/utils';

export interface TransactionExplanation extends BaseTransactionExplanation {
Expand Down Expand Up @@ -112,10 +113,43 @@ export class Canton extends BaseCoin {
case TransactionType.TransferAccept:
case TransactionType.TransferReject:
case TransactionType.TransferAcknowledge:
case TransactionType.OneStepPreApproval:
case TransactionType.TransferOfferWithdrawn:
// There is no input for these type of transactions, so always return true.
return true;
case TransactionType.OneStepPreApproval:
// Canton is always a TSS wallet. The SDK's buildTokenEnablements passes enableTokens
// through unchanged for TSS wallets (no conversion to recipients), so txParams.enableTokens
// is the only source of user intent here.
//
// Receiver validation: the receiver of a OneStepPreApproval is always the wallet's root address.
// We use enableToken.address if explicitly provided, otherwise fall back to
// wallet.coinSpecific().rootAddress (the Canton party ID stored at wallet creation time).
// Token name validation: checked when the token is resolvable from statics.
if (
txParams.type === 'enabletoken' &&
txParams.enableTokens !== undefined &&
txParams.enableTokens.length > 0
) {
const txData = transaction.toJson() as TxData;
const enableToken = txParams.enableTokens[0];
const walletRootAddress = params.wallet?.coinSpecific?.()?.rootAddress;
const expectedReceiver = enableToken.address ?? walletRootAddress;
if (expectedReceiver) {
// Strip ?memoId suffix if present in the stored address
const [expectedReceiverBase] = expectedReceiver.split('?memoId=');
if (txData.receiver !== expectedReceiverBase) {
throw new Error(
`OneStepPreApproval receiver mismatch: expected '${expectedReceiverBase}', got '${txData.receiver}'`
);
}
}
if (txData.token !== undefined && txData.token !== enableToken.name) {
throw new Error(
`OneStepPreApproval token name mismatch: expected '${enableToken.name}', got '${txData.token}'`
);
}
}
return true;
case TransactionType.Send:
if (txParams.recipients !== undefined) {
const filteredRecipients = txParams.recipients?.map((recipient) => {
Expand Down
226 changes: 226 additions & 0 deletions modules/sdk-coin-canton/test/unit/canton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import 'should';
import { BitGoAPI } from '@bitgo/sdk-api';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { coins } from '@bitgo/statics';

import { Canton, Tcanton, TransactionBuilderFactory } from '../../src';
import {
CantonTokenPreApprovalPrepareResponse,
OneStepEnablement,
OneStepPreApprovalPrepareResponse,
} from '../resources';

/**
* Builds a base64-encoded raw transaction for a OneStepPreApproval (enable token flow).
* For TSS wallets (which Canton always is), verifyTransaction receives txParams.enableTokens,
* not txParams.recipients. The wallet SDK's buildTokenEnablements passes enableTokens through
* unchanged for TSS wallets rather than converting them to recipients.
*/
function buildOneStepPreApprovalRawTx(
prepareResponse: typeof OneStepPreApprovalPrepareResponse,
commandId: string
): string {
const data = {
prepareCommandResponse: prepareResponse,
txType: 'OneStepPreApproval',
preparedTransaction: '',
partySignatures: { signatures: [] },
deduplicationPeriod: { Empty: {} },
submissionId: commandId,
hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2',
minLedgerTime: { time: { Empty: {} } },
};
return Buffer.from(JSON.stringify(data)).toString('base64');
}

/** Returns a mock wallet whose coinSpecific().rootAddress matches the given party ID. */
function walletWithRootAddress(rootAddress: string): any {
return { coinSpecific: () => ({ rootAddress }) };
}

describe('Canton verifyTransaction:', function () {
let bitgo: TestBitGoAPI;
let basecoin: Canton;

before(function () {
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' });
bitgo.safeRegister('canton', Canton.createInstance);
bitgo.safeRegister('tcanton', Tcanton.createInstance);
bitgo.initializeTestVars();
basecoin = bitgo.coin('tcanton') as Canton;
});

describe('OneStepPreApproval (enable token flow):', function () {
it('should return true when txParams has no type (non-enabletoken flow)', async function () {
const txHex = buildOneStepPreApprovalRawTx(OneStepPreApprovalPrepareResponse, OneStepEnablement.commandId);
const result = await basecoin.verifyTransaction({
txPrebuild: { txHex },
txParams: {},
wallet: {} as any,
});
result.should.equal(true);
});

it('should return true when enableTokens is absent', async function () {
const txHex = buildOneStepPreApprovalRawTx(OneStepPreApprovalPrepareResponse, OneStepEnablement.commandId);
const result = await basecoin.verifyTransaction({
txPrebuild: { txHex },
txParams: { type: 'enabletoken' },
wallet: {} as any,
});
result.should.equal(true);
});

it('should return true when enableTokens is empty', async function () {
const txHex = buildOneStepPreApprovalRawTx(OneStepPreApprovalPrepareResponse, OneStepEnablement.commandId);
const result = await basecoin.verifyTransaction({
txPrebuild: { txHex },
txParams: { type: 'enabletoken', enableTokens: [] },
wallet: {} as any,
});
result.should.equal(true);
});

describe('coin pre-approval (TransferPreapprovalProposal):', function () {
let txHex: string;
let receiver: string;

before(function () {
txHex = buildOneStepPreApprovalRawTx(OneStepPreApprovalPrepareResponse, OneStepEnablement.commandId);
// Dynamically derive receiver from parsed transaction to avoid hardcoding protobuf-decoded addresses
const txBuilder = new TransactionBuilderFactory(coins.get('tcanton')).from(txHex);
receiver = (txBuilder.transaction as any).toJson().receiver as string;
});

it('should return true when wallet has no coinSpecific (receiver check skipped)', async function () {
// Typical case: wallet mock has no coinSpecific() method, and no address in enableTokens
const result = await basecoin.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'canton' }],
},
wallet: {} as any,
});
result.should.equal(true);
});

it('should return true when wallet rootAddress matches receiver', async function () {
// Typical UI flow: enableTokens has no address, receiver validated from wallet.coinSpecific().rootAddress
const result = await basecoin.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'canton' }],
},
wallet: walletWithRootAddress(receiver),
});
result.should.equal(true);
});

it('should return true when enableToken.address matches receiver (explicit address)', async function () {
const result = await basecoin.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'canton', address: receiver }],
},
wallet: {} as any,
});
result.should.equal(true);
});

it('should throw when wallet rootAddress does not match receiver', async function () {
const wrongAddress = 'wrong-party::1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
await basecoin
.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'canton' }],
},
wallet: walletWithRootAddress(wrongAddress),
})
.should.be.rejectedWith(/OneStepPreApproval receiver mismatch/);
});

it('should throw when explicit enableToken.address does not match receiver', async function () {
const wrongAddress = 'wrong-party::1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
await basecoin
.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'canton', address: wrongAddress }],
},
wallet: {} as any,
})
.should.be.rejectedWith(/OneStepPreApproval receiver mismatch/);
});
});

describe('token pre-approval (TransferPreapproval):', function () {
let txHex: string;
let receiver: string;

before(function () {
const commandId = '7d99789d-2f22-49e1-85cb-79d2ce5a69c1';
txHex = buildOneStepPreApprovalRawTx(CantonTokenPreApprovalPrepareResponse, commandId);
const txBuilder = new TransactionBuilderFactory(coins.get('tcanton')).from(txHex);
receiver = (txBuilder.transaction as any).toJson().receiver as string;
});

it('should return true when wallet rootAddress matches receiver', async function () {
const result = await basecoin.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'tcanton:testcoin1' }],
},
wallet: walletWithRootAddress(receiver),
});
result.should.equal(true);
});

it('should return true when enableToken.address matches receiver (explicit address)', async function () {
const result = await basecoin.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'tcanton:testcoin1', address: receiver }],
},
wallet: {} as any,
});
result.should.equal(true);
});

it('should throw when wallet rootAddress does not match receiver', async function () {
const wrongAddress = 'wrong-party::1220bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
await basecoin
.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'tcanton:testcoin1' }],
},
wallet: walletWithRootAddress(wrongAddress),
})
.should.be.rejectedWith(/OneStepPreApproval receiver mismatch/);
});

it('should throw when explicit enableToken.address does not match receiver', async function () {
const wrongAddress = 'wrong-party::1220bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
await basecoin
.verifyTransaction({
txPrebuild: { txHex },
txParams: {
type: 'enabletoken',
enableTokens: [{ name: 'tcanton:testcoin1', address: wrongAddress }],
},
wallet: {} as any,
})
.should.be.rejectedWith(/OneStepPreApproval receiver mismatch/);
});
});
});
});
2 changes: 2 additions & 0 deletions modules/sdk-coin-canton/test/unit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { BitGoAPI } from '@bitgo/sdk-api';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { Canton, Tcanton } from '../../src';

import './canton';

describe('Canton:', function () {
let bitgo: TestBitGoAPI;

Expand Down
Loading