Skip to content

Commit 708b385

Browse files
authored
fix(sdk-coin-xtz): verify destination and amount in verifyTransaction
2 parents da2a13d + a3af4e4 commit 708b385

2 files changed

Lines changed: 170 additions & 2 deletions

File tree

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,61 @@ export class Xtz extends BaseCoin {
122122
}
123123

124124
async verifyTransaction(params: VerifyTransactionOptions): Promise<boolean> {
125-
const { txParams } = params;
125+
const { txParams, txPrebuild, wallet, verification } = params;
126126
if (Array.isArray(txParams.recipients) && txParams.recipients.length > 1) {
127127
throw new Error(
128128
`${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
129129
);
130130
}
131+
132+
const rawTx = txPrebuild?.txHex;
133+
if (!rawTx) {
134+
throw new Error('missing required tx prebuild property txHex');
135+
}
136+
137+
// Decode the prebuild and verify destination/amount. Fail closed on decode failure or mismatch.
138+
let txOutputs: { address: string; value: string }[];
139+
try {
140+
const txBuilder = new TransactionBuilder(coins.get(this.getChain()));
141+
txBuilder.from(rawTx);
142+
const tx = await txBuilder.build();
143+
txOutputs = tx.outputs;
144+
} catch (e) {
145+
throw new Error(`Failed to decode Tezos prebuild transaction: ${e.message}`);
146+
}
147+
148+
if (txOutputs.length !== 1) {
149+
throw new Error(`Tezos prebuild contains ${txOutputs.length} output(s) but expected exactly 1`);
150+
}
151+
152+
const { address: prebuildAddress, value: prebuildAmount } = txOutputs[0];
153+
154+
// Validate recipients if provided (normal send). Consolidation builds omit recipients.
155+
const recipient = txParams.recipients?.[0];
156+
if (recipient) {
157+
const requestedAddress = recipient.address;
158+
const requestedAmount = recipient.amount.toString();
159+
160+
if (prebuildAddress !== requestedAddress) {
161+
throw new Error(`Tezos prebuild destination mismatch: expected ${requestedAddress} but got ${prebuildAddress}`);
162+
}
163+
164+
if (new BigNumber(prebuildAmount).toFixed(0) !== new BigNumber(requestedAmount).toFixed(0)) {
165+
throw new Error(`Tezos prebuild amount mismatch: expected ${requestedAmount} but got ${prebuildAmount}`);
166+
}
167+
}
168+
169+
// Consolidation txs are built by the server with no client recipients — verify funds go to the base address.
170+
if (verification?.consolidationToBaseAddress) {
171+
const baseAddress = wallet?.coinSpecific()?.baseAddress || wallet?.coinSpecific()?.rootAddress;
172+
if (!baseAddress) {
173+
throw new Error('Unable to determine base address for consolidation');
174+
}
175+
if (prebuildAddress !== baseAddress) {
176+
throw new Error('Consolidation transaction recipient does not match wallet base address');
177+
}
178+
}
179+
131180
return true;
132181
}
133182

modules/sdk-coin-xtz/test/unit/xtz.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ describe('Tezos:', function () {
204204
describe('Verify Transaction', function () {
205205
const address1 = '5Ge59qRnZa8bxyhVFE6BDoY3kuhSrNVETRxXYLty1Hh6XTaf';
206206
const address2 = '5DiMLZugmcKEH3igPZP367FqummZkWeW5Z6zDCHLfxRjnPXe';
207+
208+
// unsignedHex encodes: destination=tz2PtJ9zgEgFVTRqy6GXsst54tH3ksEnYvvS, amount=11800000
209+
const prebuildDestination = 'tz2PtJ9zgEgFVTRqy6GXsst54tH3ksEnYvvS';
210+
const prebuildAmount = '11800000';
211+
207212
it('should reject a txPrebuild with more than one recipient', async function () {
208213
const wallet = new Wallet(bitgo, basecoin, {});
209214

@@ -217,10 +222,124 @@ describe('Tezos:', function () {
217222
};
218223

219224
await basecoin
220-
.verifyTransaction({ txParams })
225+
.verifyTransaction({ txParams, txPrebuild: { txHex: unsignedHex } })
221226
.should.be.rejectedWith(
222227
`txtz doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
223228
);
224229
});
230+
231+
it('should pass when no recipients are specified (consolidation builds omit them)', async function () {
232+
const txParams = { recipients: [] };
233+
const txPrebuild = { txHex: unsignedHex };
234+
235+
const result = await basecoin.verifyTransaction({ txParams, txPrebuild } as any);
236+
result.should.equal(true);
237+
});
238+
239+
it('should reject when txPrebuild is missing txHex', async function () {
240+
const txParams = {
241+
recipients: [{ address: prebuildDestination, amount: prebuildAmount }],
242+
};
243+
244+
await basecoin
245+
.verifyTransaction({ txParams, txPrebuild: {} } as any)
246+
.should.be.rejectedWith('missing required tx prebuild property txHex');
247+
});
248+
249+
it('should verify a valid transaction where destination and amount match', async function () {
250+
const txParams = {
251+
recipients: [{ address: prebuildDestination, amount: prebuildAmount }],
252+
};
253+
const txPrebuild = { txHex: unsignedHex };
254+
255+
const result = await basecoin.verifyTransaction({ txParams, txPrebuild } as any);
256+
result.should.equal(true);
257+
});
258+
259+
it('should reject when prebuild destination does not match requested recipient', async function () {
260+
const txParams = {
261+
recipients: [{ address: 'tz1VRjRpVKnv16AVprFH1tkDn4TDfVqA893A', amount: prebuildAmount }],
262+
};
263+
const txPrebuild = { txHex: unsignedHex };
264+
265+
await basecoin
266+
.verifyTransaction({ txParams, txPrebuild } as any)
267+
.should.be.rejectedWith(
268+
`Tezos prebuild destination mismatch: expected tz1VRjRpVKnv16AVprFH1tkDn4TDfVqA893A but got ${prebuildDestination}`
269+
);
270+
});
271+
272+
it('should reject when prebuild amount does not match requested recipient', async function () {
273+
const txParams = {
274+
recipients: [{ address: prebuildDestination, amount: '99999999' }],
275+
};
276+
const txPrebuild = { txHex: unsignedHex };
277+
278+
await basecoin
279+
.verifyTransaction({ txParams, txPrebuild } as any)
280+
.should.be.rejectedWith(`Tezos prebuild amount mismatch: expected 99999999 but got ${prebuildAmount}`);
281+
});
282+
283+
it('should reject when the prebuild contains an unexpected number of outputs', async function () {
284+
// unsignedTransactionWithTwoTransfersHex has 2 outputs, so it should fail the single-output check
285+
const txParams = {
286+
recipients: [{ address: prebuildDestination, amount: prebuildAmount }],
287+
};
288+
const txPrebuild = { txHex: unsignedTransactionWithTwoTransfersHex };
289+
290+
await basecoin
291+
.verifyTransaction({ txParams, txPrebuild } as any)
292+
.should.be.rejectedWith('Tezos prebuild contains 2 output(s) but expected exactly 1');
293+
});
294+
295+
it('should reject when the prebuild cannot be decoded', async function () {
296+
const txParams = {
297+
recipients: [{ address: prebuildDestination, amount: prebuildAmount }],
298+
};
299+
// A hex string that is not a valid Tezos transaction
300+
const txPrebuild = { txHex: 'ff'.repeat(200) };
301+
302+
let threw = false;
303+
try {
304+
await basecoin.verifyTransaction({ txParams, txPrebuild } as any);
305+
} catch (e) {
306+
threw = true;
307+
e.message.should.startWith('Failed to decode Tezos prebuild transaction');
308+
}
309+
threw.should.equal(true);
310+
});
311+
312+
it('should verify consolidation to wallet base address', async function () {
313+
const mockWallet = {
314+
coinSpecific: () => ({
315+
baseAddress: prebuildDestination,
316+
}),
317+
};
318+
319+
const result = await basecoin.verifyTransaction({
320+
txParams: {},
321+
txPrebuild: { txHex: unsignedHex },
322+
verification: { consolidationToBaseAddress: true },
323+
wallet: mockWallet as any,
324+
} as any);
325+
result.should.equal(true);
326+
});
327+
328+
it('should reject consolidation when destination does not match base address', async function () {
329+
const mockWallet = {
330+
coinSpecific: () => ({
331+
baseAddress: 'tz1VRjRpVKnv16AVprFH1tkDn4TDfVqA893A',
332+
}),
333+
};
334+
335+
await basecoin
336+
.verifyTransaction({
337+
txParams: {},
338+
txPrebuild: { txHex: unsignedHex },
339+
verification: { consolidationToBaseAddress: true },
340+
wallet: mockWallet as any,
341+
} as any)
342+
.should.be.rejectedWith('Consolidation transaction recipient does not match wallet base address');
343+
});
225344
});
226345
});

0 commit comments

Comments
 (0)