Skip to content

Commit 8ccd687

Browse files
committed
feat(sdk-coin-tempo): add smart contract call support
Ticket: CECHO-697
1 parent 090ffaf commit 8ccd687

6 files changed

Lines changed: 447 additions & 32 deletions

File tree

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { BaseTransaction, ParseTransactionError, TransactionType } from '@bitgo/sdk-core';
99
import { BaseCoin as CoinConfig } from '@bitgo/statics';
1010
import { ethers } from 'ethers';
11-
import { Address, Hex, Tip20Operation } from './types';
11+
import { Address, Hex, RawContractCall, Tip20Operation } from './types';
1212
import { amountToTip20Units } from './utils';
1313

1414
/**
@@ -46,23 +46,38 @@ export interface TxData {
4646
callCount: number;
4747
feeToken?: string;
4848
operations: Tip20Operation[];
49+
rawCalls: RawContractCall[];
4950
signature?: { r: Hex; s: Hex; yParity: number };
5051
}
5152

5253
export class Tip20Transaction extends BaseTransaction {
5354
private txRequest: Tip20TransactionRequest;
5455
private _operations: Tip20Operation[];
56+
private _rawCalls: RawContractCall[];
5557
private _signature?: { r: Hex; s: Hex; yParity: number };
5658

57-
constructor(_coinConfig: Readonly<CoinConfig>, request: Tip20TransactionRequest, operations: Tip20Operation[] = []) {
59+
constructor(
60+
_coinConfig: Readonly<CoinConfig>,
61+
request: Tip20TransactionRequest,
62+
operations: Tip20Operation[] = [],
63+
rawCalls: RawContractCall[] = []
64+
) {
5865
super(_coinConfig);
5966
this.txRequest = request;
6067
this._operations = operations;
61-
this._outputs = operations.map((op) => ({
62-
address: op.to,
63-
value: amountToTip20Units(op.amount).toString(),
64-
coin: op.token,
65-
}));
68+
this._rawCalls = rawCalls;
69+
this._outputs = [
70+
...operations.map((op) => ({
71+
address: op.to,
72+
value: amountToTip20Units(op.amount).toString(),
73+
coin: op.token,
74+
})),
75+
...rawCalls.map((call) => ({
76+
address: call.to,
77+
value: call.value || '0',
78+
coin: _coinConfig.name,
79+
})),
80+
];
6681
const totalUnits = operations.reduce((sum, op) => sum + amountToTip20Units(op.amount), 0n);
6782
this._inputs = [{ address: '', value: totalUnits.toString(), coin: _coinConfig.name }];
6883
}
@@ -191,12 +206,16 @@ export class Tip20Transaction extends BaseTransaction {
191206
return [...this._operations];
192207
}
193208

209+
getRawCalls(): RawContractCall[] {
210+
return [...this._rawCalls];
211+
}
212+
194213
getFeeToken(): Address | undefined {
195214
return this.txRequest.feeToken;
196215
}
197216

198217
getOperationCount(): number {
199-
return this.txRequest.calls.length;
218+
return this._operations.length + this._rawCalls.length;
200219
}
201220

202221
isBatch(): boolean {
@@ -240,6 +259,7 @@ export class Tip20Transaction extends BaseTransaction {
240259
callCount: this.txRequest.calls.length,
241260
feeToken: this.txRequest.feeToken,
242261
operations: this._operations,
262+
rawCalls: this._rawCalls,
243263
signature: this._signature,
244264
};
245265
}

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

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ import {
2121
} from '@bitgo/sdk-core';
2222
import { BaseCoin as CoinConfig } from '@bitgo/statics';
2323
import { ethers } from 'ethers';
24-
import { Address, Hex, Tip20Operation } from './types';
24+
import { Address, Hex, RawContractCall, Tip20Operation } from './types';
2525
import { Tip20Transaction, Tip20TransactionRequest } from './transaction';
2626
import {
2727
amountToTip20Units,
2828
encodeTip20TransferWithMemo,
2929
isTip20Transaction,
3030
isValidAddress,
31+
isValidHexData,
3132
isValidMemoId,
3233
isValidTip20Amount,
3334
tip20UnitsToAmount,
@@ -41,6 +42,7 @@ import { AA_TRANSACTION_TYPE } from './constants';
4142
*/
4243
export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
4344
private operations: Tip20Operation[] = [];
45+
private rawCalls: RawContractCall[] = [];
4446
private _feeToken?: Address;
4547
private _nonce?: number;
4648
private _gas?: bigint;
@@ -73,8 +75,8 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
7375
* @throws BuildTransactionError if validation fails
7476
*/
7577
validateTransaction(): void {
76-
if (this.operations.length === 0) {
77-
throw new BuildTransactionError('At least one operation is required to build a transaction');
78+
if (this.operations.length === 0 && this.rawCalls.length === 0) {
79+
throw new BuildTransactionError('At least one operation or raw call is required to build a transaction');
7880
}
7981

8082
if (this._nonce === undefined) {
@@ -148,7 +150,16 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
148150
data: tuple[2] as Hex,
149151
}));
150152

151-
const operations: Tip20Operation[] = calls.map((call) => this.decodeCallToOperation(call));
153+
const operations: Tip20Operation[] = [];
154+
const decodedRawCalls: RawContractCall[] = [];
155+
for (const call of calls) {
156+
const op = this.decodeCallToOperation(call);
157+
if (op !== null) {
158+
operations.push(op);
159+
} else {
160+
decodedRawCalls.push({ to: call.to, data: call.data, value: call.value.toString() });
161+
}
162+
}
152163

153164
let signature: { r: Hex; s: Hex; yParity: number } | undefined;
154165
if (decoded.length >= 14 && decoded[13] && (decoded[13] as string).length > 2) {
@@ -182,9 +193,10 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
182193
this._maxPriorityFeePerGas = maxPriorityFeePerGas;
183194
this._feeToken = feeToken;
184195
this.operations = operations;
196+
this.rawCalls = decodedRawCalls;
185197
this._restoredSignature = signature;
186198

187-
const tx = new Tip20Transaction(this._coinConfig, txRequest, operations);
199+
const tx = new Tip20Transaction(this._coinConfig, txRequest, operations, decodedRawCalls);
188200
if (signature) {
189201
tx.setSignature(signature);
190202
}
@@ -197,9 +209,10 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
197209

198210
/**
199211
* Decode a single AA call's data back into a Tip20Operation.
200-
* Expects the call data to encode transferWithMemo(address, uint256, bytes32).
212+
* Returns null if the call is not a transferWithMemo — it will be stored as a RawContractCall instead.
213+
* This preserves calldata fidelity for arbitrary smart contract interactions.
201214
*/
202-
private decodeCallToOperation(call: { to: Address; data: Hex; value: bigint }): Tip20Operation {
215+
private decodeCallToOperation(call: { to: Address; data: Hex; value: bigint }): Tip20Operation | null {
203216
const iface = new ethers.utils.Interface(TIP20_TRANSFER_WITH_MEMO_ABI);
204217
try {
205218
const decoded = iface.decodeFunctionData('transferWithMemo', call.data);
@@ -214,7 +227,8 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
214227

215228
return { token: call.to, to: toAddress, amount, memo };
216229
} catch {
217-
return { token: call.to, to: call.to, amount: tip20UnitsToAmount(call.value) };
230+
// Not a transferWithMemo call — caller will store as RawContractCall
231+
return null;
218232
}
219233
}
220234

@@ -243,6 +257,14 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
243257

244258
const calls = this.operations.map((op) => this.operationToCall(op));
245259

260+
for (const rawCall of this.rawCalls) {
261+
calls.push({
262+
to: rawCall.to as Address,
263+
data: rawCall.data as Hex,
264+
value: rawCall.value ? BigInt(rawCall.value) : 0n,
265+
});
266+
}
267+
246268
const txRequest: Tip20TransactionRequest = {
247269
type: AA_TRANSACTION_TYPE,
248270
chainId: this._common.chainIdBN().toNumber(),
@@ -255,7 +277,7 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
255277
feeToken: this._feeToken,
256278
};
257279

258-
const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations);
280+
const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations, this.rawCalls);
259281

260282
if (this._sourceKeyPair && this._sourceKeyPair.getKeys().prv) {
261283
const prv = this._sourceKeyPair.getKeys().prv!;
@@ -288,6 +310,32 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
288310
return this;
289311
}
290312

313+
/**
314+
* Add a raw smart contract call with pre-encoded calldata
315+
* Use this for arbitrary contract interactions where the UI provides ABI-encoded calldata
316+
*
317+
* @param call - Raw contract call with target address and pre-encoded calldata
318+
* @returns this builder instance for chaining
319+
*/
320+
addRawCall(call: RawContractCall): this {
321+
if (!isValidAddress(call.to)) {
322+
throw new BuildTransactionError(`Invalid contract address: ${call.to}`);
323+
}
324+
if (!isValidHexData(call.data)) {
325+
throw new BuildTransactionError(`Invalid calldata: must be a non-empty 0x-prefixed hex string`);
326+
}
327+
this.rawCalls.push(call);
328+
return this;
329+
}
330+
331+
/**
332+
* Get all raw contract calls in this transaction
333+
* @returns Array of raw contract calls
334+
*/
335+
getRawCalls(): RawContractCall[] {
336+
return [...this.rawCalls];
337+
}
338+
291339
/**
292340
* Set which TIP-20 token will be used to pay transaction fees
293341
* This is a global setting for the entire transaction

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,14 @@ export interface Tip20Operation {
1515
amount: string;
1616
memo?: string;
1717
}
18+
19+
/**
20+
* Raw smart contract call with pre-encoded calldata
21+
* Used for arbitrary contract interactions (e.g., mint(), approve())
22+
* where the caller provides the full ABI-encoded calldata
23+
*/
24+
export interface RawContractCall {
25+
to: Address;
26+
data: Hex;
27+
value?: string;
28+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ export function isValidMemoId(memoId: string): boolean {
149149
return typeof memoId === 'string' && /^(0|[1-9]\d*)$/.test(memoId);
150150
}
151151

152+
/**
153+
* Validate that a string is a non-empty 0x-prefixed hex string
154+
* Used to validate pre-encoded calldata for raw contract calls
155+
*/
156+
export function isValidHexData(data: string): boolean {
157+
return typeof data === 'string' && /^0x[0-9a-fA-F]+$/.test(data) && data.length > 2;
158+
}
159+
152160
const utils = {
153161
isValidAddress,
154162
isValidPublicKey,
@@ -160,6 +168,7 @@ const utils = {
160168
isValidTip20Amount,
161169
isTip20Transaction,
162170
isValidMemoId,
171+
isValidHexData,
163172
};
164173

165174
export default utils;

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

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -256,26 +256,49 @@ export class Tempo extends AbstractEthLikeNewCoins {
256256
txBuilder.from(txHex);
257257
const tx = (await txBuilder.build()) as Tip20Transaction;
258258
const operations = tx.getOperations();
259+
const rawCalls = tx.getRawCalls();
259260

260-
// If the caller specified explicit recipients, verify they match the operations 1-to-1
261+
// If the caller specified explicit recipients, verify they match operations and raw calls 1-to-1
261262
const recipients = txParams?.recipients;
262263
if (recipients && recipients.length > 0) {
263-
if (operations.length !== recipients.length) {
264+
const totalCallCount = operations.length + rawCalls.length;
265+
if (totalCallCount !== recipients.length) {
264266
throw new Error(
265-
`Transaction has ${operations.length} operation(s) but ${recipients.length} recipient(s) were requested`
267+
`Transaction has ${totalCallCount} call(s) but ${recipients.length} recipient(s) were requested`
266268
);
267269
}
268-
for (let i = 0; i < operations.length; i++) {
269-
const op = operations[i];
270+
271+
let opIndex = 0;
272+
let rawIndex = 0;
273+
for (let i = 0; i < recipients.length; i++) {
270274
const recipient = recipients[i];
271-
const recipientBaseAddress = recipient.address.split('?')[0];
272-
if (op.to.toLowerCase() !== recipientBaseAddress.toLowerCase()) {
273-
throw new Error(`Operation ${i} recipient mismatch: expected ${recipient.address}, got ${op.to}`);
274-
}
275-
// Compare amounts in base units (smallest denomination)
276-
const opAmountBaseUnits = amountToTip20Units(op.amount).toString();
277-
if (opAmountBaseUnits !== recipient.amount.toString()) {
278-
throw new Error(`Operation ${i} amount mismatch: expected ${recipient.amount}, got ${opAmountBaseUnits}`);
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+
}
281+
if (rawCall.to.toLowerCase() !== recipient.address.split('?')[0].toLowerCase()) {
282+
throw new Error(`Raw call ${i} address mismatch: expected ${recipient.address}, got ${rawCall.to}`);
283+
}
284+
if (rawCall.data !== recipient.data) {
285+
throw new Error(`Raw call ${i} calldata mismatch`);
286+
}
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+
}
293+
const recipientBaseAddress = recipient.address.split('?')[0];
294+
if (op.to.toLowerCase() !== recipientBaseAddress.toLowerCase()) {
295+
throw new Error(`Operation ${i} recipient mismatch: expected ${recipient.address}, got ${op.to}`);
296+
}
297+
// Compare amounts in base units (smallest denomination)
298+
const opAmountBaseUnits = amountToTip20Units(op.amount).toString();
299+
if (opAmountBaseUnits !== recipient.amount.toString()) {
300+
throw new Error(`Operation ${i} amount mismatch: expected ${recipient.amount}, got ${opAmountBaseUnits}`);
301+
}
279302
}
280303
}
281304
}

0 commit comments

Comments
 (0)