From 8b5c8ef5fab2a014a00146cb3ce8d81fe88a5d4a Mon Sep 17 00:00:00 2001 From: Ravi Hegde Date: Thu, 23 Apr 2026 13:11:50 +0530 Subject: [PATCH] feat(sdk-coin-canton): added 1-step txn in verifyTransaction method Ticket: CHALO-332 --- modules/sdk-coin-canton/src/canton.ts | 36 +++- modules/sdk-coin-canton/test/unit/canton.ts | 226 ++++++++++++++++++++ modules/sdk-coin-canton/test/unit/index.ts | 2 + 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-canton/test/unit/canton.ts diff --git a/modules/sdk-coin-canton/src/canton.ts b/modules/sdk-coin-canton/src/canton.ts index 6f44b1f709..baa4297507 100644 --- a/modules/sdk-coin-canton/src/canton.ts +++ b/modules/sdk-coin-canton/src/canton.ts @@ -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 { @@ -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) => { diff --git a/modules/sdk-coin-canton/test/unit/canton.ts b/modules/sdk-coin-canton/test/unit/canton.ts new file mode 100644 index 0000000000..20fc3c453f --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/canton.ts @@ -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/); + }); + }); + }); +}); diff --git a/modules/sdk-coin-canton/test/unit/index.ts b/modules/sdk-coin-canton/test/unit/index.ts index b401d09823..c5221b95a5 100644 --- a/modules/sdk-coin-canton/test/unit/index.ts +++ b/modules/sdk-coin-canton/test/unit/index.ts @@ -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;