Skip to content
Draft
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
37 changes: 36 additions & 1 deletion modules/sdk-coin-xtz/src/xtz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,47 @@ export class Xtz extends BaseCoin {
}

async verifyTransaction(params: VerifyTransactionOptions): Promise<boolean> {
const { txParams } = params;
const { txParams, txPrebuild } = params;
if (Array.isArray(txParams.recipients) && txParams.recipients.length > 1) {
throw new Error(
`${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
);
}

const recipient =
Array.isArray(txParams.recipients) && txParams.recipients.length === 1 ? txParams.recipients[0] : undefined;

// When a recipient is specified alongside a prebuild, decode the transaction
// and verify the encoded destination and amount match the requested recipient.
// Fail closed: any decode failure or mismatch is treated as a tampered prebuild.
if (recipient && txPrebuild?.txHex) {
let txOutputs: { address: string; value: string }[];
try {
const txBuilder = new TransactionBuilder(coins.get(this.getChain()));
txBuilder.from(txPrebuild.txHex);
const tx = await txBuilder.build();
txOutputs = tx.outputs;
} catch (e) {
throw new Error(`Failed to decode Tezos prebuild transaction: ${e.message}`);
}

if (txOutputs.length !== 1) {
throw new Error(`Tezos prebuild contains ${txOutputs.length} output(s) but expected exactly 1`);
}

const { address: prebuildAddress, value: prebuildAmount } = txOutputs[0];
const requestedAddress = recipient.address;
const requestedAmount = recipient.amount.toString();

if (prebuildAddress !== requestedAddress) {
throw new Error(`Tezos prebuild destination mismatch: expected ${requestedAddress} but got ${prebuildAddress}`);
}

if (new BigNumber(prebuildAmount).toFixed(0) !== new BigNumber(requestedAmount).toFixed(0)) {
throw new Error(`Tezos prebuild amount mismatch: expected ${requestedAmount} but got ${prebuildAmount}`);
}
}

return true;
}

Expand Down
86 changes: 86 additions & 0 deletions modules/sdk-coin-xtz/test/unit/xtz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ describe('Tezos:', function () {
describe('Verify Transaction', function () {
const address1 = '5Ge59qRnZa8bxyhVFE6BDoY3kuhSrNVETRxXYLty1Hh6XTaf';
const address2 = '5DiMLZugmcKEH3igPZP367FqummZkWeW5Z6zDCHLfxRjnPXe';

// unsignedHex encodes: destination=tz2PtJ9zgEgFVTRqy6GXsst54tH3ksEnYvvS, amount=11800000
const prebuildDestination = 'tz2PtJ9zgEgFVTRqy6GXsst54tH3ksEnYvvS';
const prebuildAmount = '11800000';

it('should reject a txPrebuild with more than one recipient', async function () {
const wallet = new Wallet(bitgo, basecoin, {});

Expand All @@ -222,5 +227,86 @@ describe('Tezos:', function () {
`txtz doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
);
});

it('should verify a valid transaction where destination and amount match', async function () {
const txParams = {
recipients: [{ address: prebuildDestination, amount: prebuildAmount }],
};
const txPrebuild = { txHex: unsignedHex };

const result = await basecoin.verifyTransaction({ txParams, txPrebuild });
result.should.equal(true);
});

it('should reject when prebuild destination does not match requested recipient', async function () {
const txParams = {
recipients: [{ address: 'tz1VRjRpVKnv16AVprFH1tkDn4TDfVqA893A', amount: prebuildAmount }],
};
const txPrebuild = { txHex: unsignedHex };

await basecoin
.verifyTransaction({ txParams, txPrebuild })
.should.be.rejectedWith(
`Tezos prebuild destination mismatch: expected tz1VRjRpVKnv16AVprFH1tkDn4TDfVqA893A but got ${prebuildDestination}`
);
});

it('should reject when prebuild amount does not match requested recipient', async function () {
const txParams = {
recipients: [{ address: prebuildDestination, amount: '99999999' }],
};
const txPrebuild = { txHex: unsignedHex };

await basecoin
.verifyTransaction({ txParams, txPrebuild })
.should.be.rejectedWith(`Tezos prebuild amount mismatch: expected 99999999 but got ${prebuildAmount}`);
});

it('should reject when the prebuild contains an unexpected number of outputs', async function () {
// unsignedTransactionWithTwoTransfersHex has 2 outputs, so it should fail the single-output check
const txParams = {
recipients: [{ address: prebuildDestination, amount: prebuildAmount }],
};
const txPrebuild = { txHex: unsignedTransactionWithTwoTransfersHex };

await basecoin
.verifyTransaction({ txParams, txPrebuild })
.should.be.rejectedWith('Tezos prebuild contains 2 output(s) but expected exactly 1');
});

it('should reject when the prebuild cannot be decoded', async function () {
const txParams = {
recipients: [{ address: prebuildDestination, amount: prebuildAmount }],
};
// A hex string that is not a valid Tezos transaction
const txPrebuild = { txHex: 'ff'.repeat(200) };

let threw = false;
try {
await basecoin.verifyTransaction({ txParams, txPrebuild });
} catch (e) {
threw = true;
e.message.should.startWith('Failed to decode Tezos prebuild transaction');
}
threw.should.equal(true);
});

it('should pass when no recipients are specified (no destination check)', async function () {
const txParams = { recipients: [] };
const txPrebuild = { txHex: unsignedHex };

const result = await basecoin.verifyTransaction({ txParams, txPrebuild });
result.should.equal(true);
});

it('should pass when no txHex is present in the prebuild (wallet init)', async function () {
const txParams = {
recipients: [{ address: prebuildDestination, amount: prebuildAmount }],
};
const txPrebuild = {};

const result = await basecoin.verifyTransaction({ txParams, txPrebuild });
result.should.equal(true);
});
});
});
Loading