Skip to content
Merged
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
36 changes: 28 additions & 8 deletions modules/sdk-coin-tempo/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { BaseTransaction, ParseTransactionError, TransactionType } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { ethers } from 'ethers';
import { Address, Hex, Tip20Operation } from './types';
import { Address, Hex, RawContractCall, Tip20Operation } from './types';
import { amountToTip20Units } from './utils';

/**
Expand Down Expand Up @@ -46,23 +46,38 @@ export interface TxData {
callCount: number;
feeToken?: string;
operations: Tip20Operation[];
rawCalls: RawContractCall[];
signature?: { r: Hex; s: Hex; yParity: number };
}

export class Tip20Transaction extends BaseTransaction {
private txRequest: Tip20TransactionRequest;
private _operations: Tip20Operation[];
private _rawCalls: RawContractCall[];
private _signature?: { r: Hex; s: Hex; yParity: number };

constructor(_coinConfig: Readonly<CoinConfig>, request: Tip20TransactionRequest, operations: Tip20Operation[] = []) {
constructor(
_coinConfig: Readonly<CoinConfig>,
request: Tip20TransactionRequest,
operations: Tip20Operation[] = [],
rawCalls: RawContractCall[] = []
) {
super(_coinConfig);
this.txRequest = request;
this._operations = operations;
this._outputs = operations.map((op) => ({
address: op.to,
value: amountToTip20Units(op.amount).toString(),
coin: op.token,
}));
this._rawCalls = rawCalls;
this._outputs = [
...operations.map((op) => ({
address: op.to,
value: amountToTip20Units(op.amount).toString(),
coin: op.token,
})),
...rawCalls.map((call) => ({
address: call.to,
value: call.value || '0',
coin: _coinConfig.name,
})),
];
const totalUnits = operations.reduce((sum, op) => sum + amountToTip20Units(op.amount), 0n);
this._inputs = [{ address: '', value: totalUnits.toString(), coin: _coinConfig.name }];
}
Expand Down Expand Up @@ -191,12 +206,16 @@ export class Tip20Transaction extends BaseTransaction {
return [...this._operations];
}

getRawCalls(): RawContractCall[] {
return [...this._rawCalls];
}

getFeeToken(): Address | undefined {
return this.txRequest.feeToken;
}

getOperationCount(): number {
return this.txRequest.calls.length;
return this._operations.length + this._rawCalls.length;
}

isBatch(): boolean {
Expand Down Expand Up @@ -240,6 +259,7 @@ export class Tip20Transaction extends BaseTransaction {
callCount: this.txRequest.calls.length,
feeToken: this.txRequest.feeToken,
operations: this._operations,
rawCalls: this._rawCalls,
signature: this._signature,
};
}
Expand Down
66 changes: 57 additions & 9 deletions modules/sdk-coin-tempo/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ import {
} from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { ethers } from 'ethers';
import { Address, Hex, Tip20Operation } from './types';
import { Address, Hex, RawContractCall, Tip20Operation } from './types';
import { Tip20Transaction, Tip20TransactionRequest } from './transaction';
import {
amountToTip20Units,
encodeTip20TransferWithMemo,
isTip20Transaction,
isValidAddress,
isValidHexData,
isValidMemoId,
isValidTip20Amount,
tip20UnitsToAmount,
Expand All @@ -41,6 +42,7 @@ import { AA_TRANSACTION_TYPE } from './constants';
*/
export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
private operations: Tip20Operation[] = [];
private rawCalls: RawContractCall[] = [];
private _feeToken?: Address;
private _nonce?: number;
private _gas?: bigint;
Expand Down Expand Up @@ -73,8 +75,8 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
* @throws BuildTransactionError if validation fails
*/
validateTransaction(): void {
if (this.operations.length === 0) {
throw new BuildTransactionError('At least one operation is required to build a transaction');
if (this.operations.length === 0 && this.rawCalls.length === 0) {
throw new BuildTransactionError('At least one operation or raw call is required to build a transaction');
Comment thread
nayandas190 marked this conversation as resolved.
}

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

const operations: Tip20Operation[] = calls.map((call) => this.decodeCallToOperation(call));
const operations: Tip20Operation[] = [];
const decodedRawCalls: RawContractCall[] = [];
for (const call of calls) {
const op = this.decodeCallToOperation(call);
if (op !== null) {
operations.push(op);
} else {
decodedRawCalls.push({ to: call.to, data: call.data, value: call.value.toString() });
}
}

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

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

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

return { token: call.to, to: toAddress, amount, memo };
} catch {
return { token: call.to, to: call.to, amount: tip20UnitsToAmount(call.value) };
// Not a transferWithMemo call — caller will store as RawContractCall
return null;
}
}

Expand Down Expand Up @@ -243,6 +257,14 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {

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

for (const rawCall of this.rawCalls) {
calls.push({
to: rawCall.to as Address,
data: rawCall.data as Hex,
value: rawCall.value ? BigInt(rawCall.value) : 0n,
});
}

const txRequest: Tip20TransactionRequest = {
type: AA_TRANSACTION_TYPE,
chainId: this._common.chainIdBN().toNumber(),
Expand All @@ -255,7 +277,7 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
feeToken: this._feeToken,
};

const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations);
const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations, this.rawCalls);

if (this._sourceKeyPair && this._sourceKeyPair.getKeys().prv) {
const prv = this._sourceKeyPair.getKeys().prv!;
Expand Down Expand Up @@ -288,6 +310,32 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
return this;
}

/**
* Add a raw smart contract call with pre-encoded calldata
* Use this for arbitrary contract interactions where the UI provides ABI-encoded calldata
*
* @param call - Raw contract call with target address and pre-encoded calldata
* @returns this builder instance for chaining
*/
addRawCall(call: RawContractCall): this {
if (!isValidAddress(call.to)) {
throw new BuildTransactionError(`Invalid contract address: ${call.to}`);
}
if (!isValidHexData(call.data)) {
throw new BuildTransactionError(`Invalid calldata: must be a non-empty 0x-prefixed hex string`);
}
this.rawCalls.push(call);
return this;
}

/**
* Get all raw contract calls in this transaction
* @returns Array of raw contract calls
*/
getRawCalls(): RawContractCall[] {
return [...this.rawCalls];
}

/**
* Set which TIP-20 token will be used to pay transaction fees
* This is a global setting for the entire transaction
Expand Down
11 changes: 11 additions & 0 deletions modules/sdk-coin-tempo/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,14 @@ export interface Tip20Operation {
amount: string;
memo?: string;
}

/**
* Raw smart contract call with pre-encoded calldata
* Used for arbitrary contract interactions (e.g., mint(), approve())
* where the caller provides the full ABI-encoded calldata
*/
export interface RawContractCall {
to: Address;
data: Hex;
value?: string;
}
9 changes: 9 additions & 0 deletions modules/sdk-coin-tempo/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ export function isValidMemoId(memoId: string): boolean {
return typeof memoId === 'string' && /^(0|[1-9]\d*)$/.test(memoId);
}

/**
* Validate that a string is a non-empty 0x-prefixed hex string
* Used to validate pre-encoded calldata for raw contract calls
*/
export function isValidHexData(data: string): boolean {
return typeof data === 'string' && /^0x[0-9a-fA-F]+$/.test(data) && data.length > 2;
}

const utils = {
isValidAddress,
isValidPublicKey,
Expand All @@ -160,6 +168,7 @@ const utils = {
isValidTip20Amount,
isTip20Transaction,
isValidMemoId,
isValidHexData,
};

export default utils;
49 changes: 36 additions & 13 deletions modules/sdk-coin-tempo/src/tempo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,26 +256,49 @@ export class Tempo extends AbstractEthLikeNewCoins {
txBuilder.from(txHex);
const tx = (await txBuilder.build()) as Tip20Transaction;
const operations = tx.getOperations();
const rawCalls = tx.getRawCalls();

// If the caller specified explicit recipients, verify they match the operations 1-to-1
// If the caller specified explicit recipients, verify they match operations and raw calls 1-to-1
const recipients = txParams?.recipients;
if (recipients && recipients.length > 0) {
if (operations.length !== recipients.length) {
const totalCallCount = operations.length + rawCalls.length;
if (totalCallCount !== recipients.length) {
throw new Error(
`Transaction has ${operations.length} operation(s) but ${recipients.length} recipient(s) were requested`
`Transaction has ${totalCallCount} call(s) but ${recipients.length} recipient(s) were requested`
);
}
for (let i = 0; i < operations.length; i++) {
const op = operations[i];

let opIndex = 0;
let rawIndex = 0;
for (let i = 0; i < recipients.length; i++) {
const recipient = recipients[i];
const recipientBaseAddress = recipient.address.split('?')[0];
if (op.to.toLowerCase() !== recipientBaseAddress.toLowerCase()) {
throw new Error(`Operation ${i} recipient mismatch: expected ${recipient.address}, got ${op.to}`);
}
// Compare amounts in base units (smallest denomination)
const opAmountBaseUnits = amountToTip20Units(op.amount).toString();
if (opAmountBaseUnits !== recipient.amount.toString()) {
throw new Error(`Operation ${i} amount mismatch: expected ${recipient.amount}, got ${opAmountBaseUnits}`);
if (recipient.data) {
// Contract call recipient — verify against rawCalls
const rawCall = rawCalls[rawIndex++];
if (!rawCall) {
throw new Error(`Missing raw call for recipient ${i}`);
}
if (rawCall.to.toLowerCase() !== recipient.address.split('?')[0].toLowerCase()) {
throw new Error(`Raw call ${i} address mismatch: expected ${recipient.address}, got ${rawCall.to}`);
}
if (rawCall.data !== recipient.data) {
throw new Error(`Raw call ${i} calldata mismatch`);
}
} else {
// Token transfer recipient — verify against operations
const op = operations[opIndex++];
if (!op) {
throw new Error(`Missing operation for recipient ${i}`);
}
const recipientBaseAddress = recipient.address.split('?')[0];
if (op.to.toLowerCase() !== recipientBaseAddress.toLowerCase()) {
throw new Error(`Operation ${i} recipient mismatch: expected ${recipient.address}, got ${op.to}`);
}
// Compare amounts in base units (smallest denomination)
const opAmountBaseUnits = amountToTip20Units(op.amount).toString();
if (opAmountBaseUnits !== recipient.amount.toString()) {
throw new Error(`Operation ${i} amount mismatch: expected ${recipient.amount}, got ${opAmountBaseUnits}`);
}
}
}
}
Expand Down
Loading
Loading