Skip to content

Commit b99cd22

Browse files
authored
Merge pull request #8494 from BitGo/cecho-697-fix
feat(sdk-coin-tempo): enhance transaction validation and error handling
2 parents ab7c52a + b5414e6 commit b99cd22

File tree

5 files changed

+55
-76
lines changed

5 files changed

+55
-76
lines changed

modules/sdk-coin-tempo/src/lib/transactionBuilder.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,15 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
322322
throw new BuildTransactionError(`Invalid contract address: ${call.to}`);
323323
}
324324
if (!isValidHexData(call.data)) {
325-
throw new BuildTransactionError(`Invalid calldata: must be a non-empty 0x-prefixed hex string`);
325+
throw new BuildTransactionError(`Invalid calldata: must be a non-empty 0x-prefixed hex string with even length`);
326+
}
327+
if (call.value !== undefined) {
328+
try {
329+
const v = BigInt(call.value);
330+
if (v < 0n) throw new Error();
331+
} catch {
332+
throw new BuildTransactionError(`Invalid value: must be a non-negative integer string`);
333+
}
326334
}
327335
this.rawCalls.push(call);
328336
return this;

modules/sdk-coin-tempo/src/lib/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export function isValidMemoId(memoId: string): boolean {
154154
* Used to validate pre-encoded calldata for raw contract calls
155155
*/
156156
export function isValidHexData(data: string): boolean {
157-
return typeof data === 'string' && /^0x[0-9a-fA-F]+$/.test(data) && data.length > 2;
157+
return typeof data === 'string' && /^0x[0-9a-fA-F]+$/.test(data) && data.length > 2 && data.length % 2 === 0;
158158
}
159159

160160
const utils = {

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

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -258,43 +258,41 @@ export class Tempo extends AbstractEthLikeNewCoins {
258258
const operations = tx.getOperations();
259259
const rawCalls = tx.getRawCalls();
260260

261-
// If the caller specified explicit recipients, verify they match operations and raw calls 1-to-1
261+
// If the caller specified explicit recipients, verify they match the transaction.
262+
// A transaction is either all token transfer operations OR a single raw contract call — never mixed.
262263
const recipients = txParams?.recipients;
263264
if (recipients && recipients.length > 0) {
264-
const totalCallCount = operations.length + rawCalls.length;
265-
if (totalCallCount !== recipients.length) {
266-
throw new Error(
267-
`Transaction has ${totalCallCount} call(s) but ${recipients.length} recipient(s) were requested`
268-
);
269-
}
270-
271-
let opIndex = 0;
272-
let rawIndex = 0;
273-
for (let i = 0; i < recipients.length; i++) {
274-
const recipient = recipients[i];
275-
if (recipient.data) {
276-
// Contract call recipient — verify against rawCalls
277-
const rawCall = rawCalls[rawIndex++];
278-
if (!rawCall) {
279-
throw new Error(`Missing raw call for recipient ${i}`);
280-
}
265+
if (rawCalls.length > 0) {
266+
// Contract call transaction — single raw call, single recipient with data
267+
if (rawCalls.length !== recipients.length) {
268+
throw new Error(
269+
`Transaction has ${rawCalls.length} call(s) but ${recipients.length} recipient(s) were requested`
270+
);
271+
}
272+
for (let i = 0; i < rawCalls.length; i++) {
273+
const rawCall = rawCalls[i];
274+
const recipient = recipients[i];
281275
if (rawCall.to.toLowerCase() !== recipient.address.split('?')[0].toLowerCase()) {
282276
throw new Error(`Raw call ${i} address mismatch: expected ${recipient.address}, got ${rawCall.to}`);
283277
}
284-
if (rawCall.data !== recipient.data) {
278+
if (!recipient.data || rawCall.data.toLowerCase() !== recipient.data.toLowerCase()) {
285279
throw new Error(`Raw call ${i} calldata mismatch`);
286280
}
287-
} else {
288-
// Token transfer recipient — verify against operations
289-
const op = operations[opIndex++];
290-
if (!op) {
291-
throw new Error(`Missing operation for recipient ${i}`);
292-
}
281+
}
282+
} else {
283+
// Token transfer transaction — operations matched 1-to-1 against recipients
284+
if (operations.length !== recipients.length) {
285+
throw new Error(
286+
`Transaction has ${operations.length} operation(s) but ${recipients.length} recipient(s) were requested`
287+
);
288+
}
289+
for (let i = 0; i < operations.length; i++) {
290+
const op = operations[i];
291+
const recipient = recipients[i];
293292
const recipientBaseAddress = recipient.address.split('?')[0];
294293
if (op.to.toLowerCase() !== recipientBaseAddress.toLowerCase()) {
295294
throw new Error(`Operation ${i} recipient mismatch: expected ${recipient.address}, got ${op.to}`);
296295
}
297-
// Compare amounts in base units (smallest denomination)
298296
const opAmountBaseUnits = amountToTip20Units(op.amount).toString();
299297
if (opAmountBaseUnits !== recipient.amount.toString()) {
300298
throw new Error(`Operation ${i} amount mismatch: expected ${recipient.amount}, got ${opAmountBaseUnits}`);

modules/sdk-coin-tempo/test/resources/tempo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const MEMO_TEST_CASES = {
7777
// ============================================================================
7878

7979
export const ERROR_MESSAGES = {
80-
noOperations: /At least one operation is required/,
80+
noOperations: /At least one operation or raw call is required/,
8181
missingNonce: /Nonce is required/,
8282
missingGas: /Gas limit is required/,
8383
missingMaxFeePerGas: /maxFeePerGas is required/,

modules/sdk-coin-tempo/test/unit/transactionBuilder.ts

Lines changed: 20 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ describe('Tempo coin - parseTransaction / verifyTransaction', () => {
677677
],
678678
},
679679
}),
680-
/call\(s\)/
680+
/operation\(s\)/
681681
);
682682
});
683683

@@ -819,6 +819,21 @@ describe('Raw Contract Call Builder', () => {
819819
assert.throws(() => builder.addRawCall({ to: mockContract, data: 'a9059cbb' }), /Invalid calldata/);
820820
});
821821

822+
it('should throw for odd-length hex calldata', () => {
823+
const builder = new Tip20TransactionBuilder(mockCoinConfig);
824+
assert.throws(() => builder.addRawCall({ to: mockContract, data: '0xabc' }), /Invalid calldata/);
825+
});
826+
827+
it('should throw for invalid value string', () => {
828+
const builder = new Tip20TransactionBuilder(mockCoinConfig);
829+
assert.throws(() => builder.addRawCall({ to: mockContract, data: mockCalldata, value: '1e18' }), /Invalid value/);
830+
});
831+
832+
it('should throw for negative value', () => {
833+
const builder = new Tip20TransactionBuilder(mockCoinConfig);
834+
assert.throws(() => builder.addRawCall({ to: mockContract, data: mockCalldata, value: '-1' }), /Invalid value/);
835+
});
836+
822837
it('should allow raw-call-only transaction (no operations)', async () => {
823838
const builder = new Tip20TransactionBuilder(mockCoinConfig);
824839
builder
@@ -906,61 +921,19 @@ describe('Raw Contract Call Builder', () => {
906921
});
907922
});
908923

909-
describe('Mixed: operations + raw calls', () => {
910-
it('should build and round-trip a transaction with both operations and raw calls', async () => {
911-
const tokenAddress = ethers.utils.getAddress(TESTNET_TOKENS.alphaUSD.address);
912-
const recipientAddress = ethers.utils.getAddress(TEST_RECIPIENT_ADDRESS);
913-
924+
describe('Raw call toJson and outputs', () => {
925+
it('should expose raw call contract address as output', async () => {
914926
const builder = new Tip20TransactionBuilder(mockCoinConfig);
915927
builder
916-
.addOperation({ token: tokenAddress, to: recipientAddress, amount: '10.0', memo: '1' })
917-
.addRawCall({ to: mockContract, data: mockCalldata })
918-
.nonce(1)
919-
.gas(200000n)
920-
.maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas)
921-
.maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas);
922-
923-
const originalTx = (await builder.build()) as Tip20Transaction;
924-
assert.strictEqual(originalTx.getOperations().length, 1);
925-
assert.strictEqual(originalTx.getRawCalls().length, 1);
926-
assert.strictEqual(originalTx.getOperationCount(), 2);
927-
928-
const serialized = await originalTx.serialize();
929-
930-
const builder2 = new Tip20TransactionBuilder(mockCoinConfig);
931-
builder2.from(serialized);
932-
const restoredTx = (await builder2.build()) as Tip20Transaction;
933-
934-
assert.strictEqual(restoredTx.getOperations().length, 1);
935-
assert.strictEqual(restoredTx.getRawCalls().length, 1);
936-
assert.strictEqual(restoredTx.getOperationCount(), 2);
937-
938-
const ops = restoredTx.getOperations();
939-
assert.strictEqual(ops[0].to.toLowerCase(), recipientAddress.toLowerCase());
940-
assert.strictEqual(ops[0].amount, '10.0');
941-
942-
const rawCalls = restoredTx.getRawCalls();
943-
assert.strictEqual(rawCalls[0].to.toLowerCase(), mockContract.toLowerCase());
944-
assert.strictEqual(rawCalls[0].data.toLowerCase(), mockCalldata.toLowerCase());
945-
});
946-
947-
it('should expose outputs for both operations and raw calls', async () => {
948-
const tokenAddress = ethers.utils.getAddress(TESTNET_TOKENS.alphaUSD.address);
949-
const recipientAddress = ethers.utils.getAddress(TEST_RECIPIENT_ADDRESS);
950-
951-
const builder = new Tip20TransactionBuilder(mockCoinConfig);
952-
builder
953-
.addOperation({ token: tokenAddress, to: recipientAddress, amount: '5.0' })
954928
.addRawCall({ to: mockContract, data: mockCalldata, value: '0' })
955929
.nonce(2)
956930
.gas(200000n)
957931
.maxFeePerGas(TX_PARAMS.defaultMaxFeePerGas)
958932
.maxPriorityFeePerGas(TX_PARAMS.defaultMaxPriorityFeePerGas);
959933

960934
const tx = (await builder.build()) as Tip20Transaction;
961-
assert.strictEqual(tx.outputs.length, 2);
962-
assert.strictEqual(tx.outputs[0].address.toLowerCase(), recipientAddress.toLowerCase());
963-
assert.strictEqual(tx.outputs[1].address.toLowerCase(), mockContract.toLowerCase());
935+
assert.strictEqual(tx.outputs.length, 1);
936+
assert.strictEqual(tx.outputs[0].address.toLowerCase(), mockContract.toLowerCase());
964937
});
965938

966939
it('should include rawCalls in toJson() output', async () => {

0 commit comments

Comments
 (0)