Skip to content

Commit 1e32098

Browse files
authored
Merge pull request #8619 from BitGo/CHALO-332
feat(sdk-coin-canton): added 1-step txn in verifyTransaction method
2 parents 56ad471 + 8b5c8ef commit 1e32098

3 files changed

Lines changed: 263 additions & 1 deletion

File tree

modules/sdk-coin-canton/src/canton.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { auditEddsaPrivateKey } from '@bitgo/sdk-lib-mpc';
2626
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
2727
import { TransactionBuilderFactory } from './lib';
2828
import { KeyPair as CantonKeyPair } from './lib/keyPair';
29+
import { TxData } from './lib/iface';
2930
import utils from './lib/utils';
3031

3132
export interface TransactionExplanation extends BaseTransactionExplanation {
@@ -112,10 +113,43 @@ export class Canton extends BaseCoin {
112113
case TransactionType.TransferAccept:
113114
case TransactionType.TransferReject:
114115
case TransactionType.TransferAcknowledge:
115-
case TransactionType.OneStepPreApproval:
116116
case TransactionType.TransferOfferWithdrawn:
117117
// There is no input for these type of transactions, so always return true.
118118
return true;
119+
case TransactionType.OneStepPreApproval:
120+
// Canton is always a TSS wallet. The SDK's buildTokenEnablements passes enableTokens
121+
// through unchanged for TSS wallets (no conversion to recipients), so txParams.enableTokens
122+
// is the only source of user intent here.
123+
//
124+
// Receiver validation: the receiver of a OneStepPreApproval is always the wallet's root address.
125+
// We use enableToken.address if explicitly provided, otherwise fall back to
126+
// wallet.coinSpecific().rootAddress (the Canton party ID stored at wallet creation time).
127+
// Token name validation: checked when the token is resolvable from statics.
128+
if (
129+
txParams.type === 'enabletoken' &&
130+
txParams.enableTokens !== undefined &&
131+
txParams.enableTokens.length > 0
132+
) {
133+
const txData = transaction.toJson() as TxData;
134+
const enableToken = txParams.enableTokens[0];
135+
const walletRootAddress = params.wallet?.coinSpecific?.()?.rootAddress;
136+
const expectedReceiver = enableToken.address ?? walletRootAddress;
137+
if (expectedReceiver) {
138+
// Strip ?memoId suffix if present in the stored address
139+
const [expectedReceiverBase] = expectedReceiver.split('?memoId=');
140+
if (txData.receiver !== expectedReceiverBase) {
141+
throw new Error(
142+
`OneStepPreApproval receiver mismatch: expected '${expectedReceiverBase}', got '${txData.receiver}'`
143+
);
144+
}
145+
}
146+
if (txData.token !== undefined && txData.token !== enableToken.name) {
147+
throw new Error(
148+
`OneStepPreApproval token name mismatch: expected '${enableToken.name}', got '${txData.token}'`
149+
);
150+
}
151+
}
152+
return true;
119153
case TransactionType.Send:
120154
if (txParams.recipients !== undefined) {
121155
const filteredRecipients = txParams.recipients?.map((recipient) => {
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import 'should';
2+
import { BitGoAPI } from '@bitgo/sdk-api';
3+
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
4+
import { coins } from '@bitgo/statics';
5+
6+
import { Canton, Tcanton, TransactionBuilderFactory } from '../../src';
7+
import {
8+
CantonTokenPreApprovalPrepareResponse,
9+
OneStepEnablement,
10+
OneStepPreApprovalPrepareResponse,
11+
} from '../resources';
12+
13+
/**
14+
* Builds a base64-encoded raw transaction for a OneStepPreApproval (enable token flow).
15+
* For TSS wallets (which Canton always is), verifyTransaction receives txParams.enableTokens,
16+
* not txParams.recipients. The wallet SDK's buildTokenEnablements passes enableTokens through
17+
* unchanged for TSS wallets rather than converting them to recipients.
18+
*/
19+
function buildOneStepPreApprovalRawTx(
20+
prepareResponse: typeof OneStepPreApprovalPrepareResponse,
21+
commandId: string
22+
): string {
23+
const data = {
24+
prepareCommandResponse: prepareResponse,
25+
txType: 'OneStepPreApproval',
26+
preparedTransaction: '',
27+
partySignatures: { signatures: [] },
28+
deduplicationPeriod: { Empty: {} },
29+
submissionId: commandId,
30+
hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2',
31+
minLedgerTime: { time: { Empty: {} } },
32+
};
33+
return Buffer.from(JSON.stringify(data)).toString('base64');
34+
}
35+
36+
/** Returns a mock wallet whose coinSpecific().rootAddress matches the given party ID. */
37+
function walletWithRootAddress(rootAddress: string): any {
38+
return { coinSpecific: () => ({ rootAddress }) };
39+
}
40+
41+
describe('Canton verifyTransaction:', function () {
42+
let bitgo: TestBitGoAPI;
43+
let basecoin: Canton;
44+
45+
before(function () {
46+
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' });
47+
bitgo.safeRegister('canton', Canton.createInstance);
48+
bitgo.safeRegister('tcanton', Tcanton.createInstance);
49+
bitgo.initializeTestVars();
50+
basecoin = bitgo.coin('tcanton') as Canton;
51+
});
52+
53+
describe('OneStepPreApproval (enable token flow):', function () {
54+
it('should return true when txParams has no type (non-enabletoken flow)', async function () {
55+
const txHex = buildOneStepPreApprovalRawTx(OneStepPreApprovalPrepareResponse, OneStepEnablement.commandId);
56+
const result = await basecoin.verifyTransaction({
57+
txPrebuild: { txHex },
58+
txParams: {},
59+
wallet: {} as any,
60+
});
61+
result.should.equal(true);
62+
});
63+
64+
it('should return true when enableTokens is absent', async function () {
65+
const txHex = buildOneStepPreApprovalRawTx(OneStepPreApprovalPrepareResponse, OneStepEnablement.commandId);
66+
const result = await basecoin.verifyTransaction({
67+
txPrebuild: { txHex },
68+
txParams: { type: 'enabletoken' },
69+
wallet: {} as any,
70+
});
71+
result.should.equal(true);
72+
});
73+
74+
it('should return true when enableTokens is empty', async function () {
75+
const txHex = buildOneStepPreApprovalRawTx(OneStepPreApprovalPrepareResponse, OneStepEnablement.commandId);
76+
const result = await basecoin.verifyTransaction({
77+
txPrebuild: { txHex },
78+
txParams: { type: 'enabletoken', enableTokens: [] },
79+
wallet: {} as any,
80+
});
81+
result.should.equal(true);
82+
});
83+
84+
describe('coin pre-approval (TransferPreapprovalProposal):', function () {
85+
let txHex: string;
86+
let receiver: string;
87+
88+
before(function () {
89+
txHex = buildOneStepPreApprovalRawTx(OneStepPreApprovalPrepareResponse, OneStepEnablement.commandId);
90+
// Dynamically derive receiver from parsed transaction to avoid hardcoding protobuf-decoded addresses
91+
const txBuilder = new TransactionBuilderFactory(coins.get('tcanton')).from(txHex);
92+
receiver = (txBuilder.transaction as any).toJson().receiver as string;
93+
});
94+
95+
it('should return true when wallet has no coinSpecific (receiver check skipped)', async function () {
96+
// Typical case: wallet mock has no coinSpecific() method, and no address in enableTokens
97+
const result = await basecoin.verifyTransaction({
98+
txPrebuild: { txHex },
99+
txParams: {
100+
type: 'enabletoken',
101+
enableTokens: [{ name: 'canton' }],
102+
},
103+
wallet: {} as any,
104+
});
105+
result.should.equal(true);
106+
});
107+
108+
it('should return true when wallet rootAddress matches receiver', async function () {
109+
// Typical UI flow: enableTokens has no address, receiver validated from wallet.coinSpecific().rootAddress
110+
const result = await basecoin.verifyTransaction({
111+
txPrebuild: { txHex },
112+
txParams: {
113+
type: 'enabletoken',
114+
enableTokens: [{ name: 'canton' }],
115+
},
116+
wallet: walletWithRootAddress(receiver),
117+
});
118+
result.should.equal(true);
119+
});
120+
121+
it('should return true when enableToken.address matches receiver (explicit address)', async function () {
122+
const result = await basecoin.verifyTransaction({
123+
txPrebuild: { txHex },
124+
txParams: {
125+
type: 'enabletoken',
126+
enableTokens: [{ name: 'canton', address: receiver }],
127+
},
128+
wallet: {} as any,
129+
});
130+
result.should.equal(true);
131+
});
132+
133+
it('should throw when wallet rootAddress does not match receiver', async function () {
134+
const wrongAddress = 'wrong-party::1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
135+
await basecoin
136+
.verifyTransaction({
137+
txPrebuild: { txHex },
138+
txParams: {
139+
type: 'enabletoken',
140+
enableTokens: [{ name: 'canton' }],
141+
},
142+
wallet: walletWithRootAddress(wrongAddress),
143+
})
144+
.should.be.rejectedWith(/OneStepPreApproval receiver mismatch/);
145+
});
146+
147+
it('should throw when explicit enableToken.address does not match receiver', async function () {
148+
const wrongAddress = 'wrong-party::1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
149+
await basecoin
150+
.verifyTransaction({
151+
txPrebuild: { txHex },
152+
txParams: {
153+
type: 'enabletoken',
154+
enableTokens: [{ name: 'canton', address: wrongAddress }],
155+
},
156+
wallet: {} as any,
157+
})
158+
.should.be.rejectedWith(/OneStepPreApproval receiver mismatch/);
159+
});
160+
});
161+
162+
describe('token pre-approval (TransferPreapproval):', function () {
163+
let txHex: string;
164+
let receiver: string;
165+
166+
before(function () {
167+
const commandId = '7d99789d-2f22-49e1-85cb-79d2ce5a69c1';
168+
txHex = buildOneStepPreApprovalRawTx(CantonTokenPreApprovalPrepareResponse, commandId);
169+
const txBuilder = new TransactionBuilderFactory(coins.get('tcanton')).from(txHex);
170+
receiver = (txBuilder.transaction as any).toJson().receiver as string;
171+
});
172+
173+
it('should return true when wallet rootAddress matches receiver', async function () {
174+
const result = await basecoin.verifyTransaction({
175+
txPrebuild: { txHex },
176+
txParams: {
177+
type: 'enabletoken',
178+
enableTokens: [{ name: 'tcanton:testcoin1' }],
179+
},
180+
wallet: walletWithRootAddress(receiver),
181+
});
182+
result.should.equal(true);
183+
});
184+
185+
it('should return true when enableToken.address matches receiver (explicit address)', async function () {
186+
const result = await basecoin.verifyTransaction({
187+
txPrebuild: { txHex },
188+
txParams: {
189+
type: 'enabletoken',
190+
enableTokens: [{ name: 'tcanton:testcoin1', address: receiver }],
191+
},
192+
wallet: {} as any,
193+
});
194+
result.should.equal(true);
195+
});
196+
197+
it('should throw when wallet rootAddress does not match receiver', async function () {
198+
const wrongAddress = 'wrong-party::1220bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
199+
await basecoin
200+
.verifyTransaction({
201+
txPrebuild: { txHex },
202+
txParams: {
203+
type: 'enabletoken',
204+
enableTokens: [{ name: 'tcanton:testcoin1' }],
205+
},
206+
wallet: walletWithRootAddress(wrongAddress),
207+
})
208+
.should.be.rejectedWith(/OneStepPreApproval receiver mismatch/);
209+
});
210+
211+
it('should throw when explicit enableToken.address does not match receiver', async function () {
212+
const wrongAddress = 'wrong-party::1220bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
213+
await basecoin
214+
.verifyTransaction({
215+
txPrebuild: { txHex },
216+
txParams: {
217+
type: 'enabletoken',
218+
enableTokens: [{ name: 'tcanton:testcoin1', address: wrongAddress }],
219+
},
220+
wallet: {} as any,
221+
})
222+
.should.be.rejectedWith(/OneStepPreApproval receiver mismatch/);
223+
});
224+
});
225+
});
226+
});

modules/sdk-coin-canton/test/unit/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { BitGoAPI } from '@bitgo/sdk-api';
33
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
44
import { Canton, Tcanton } from '../../src';
55

6+
import './canton';
7+
68
describe('Canton:', function () {
79
let bitgo: TestBitGoAPI;
810

0 commit comments

Comments
 (0)