Skip to content

Commit 755490a

Browse files
authored
Merge pull request #8237 from BitGo/cecho-286
feat(sdk-coin-tempo): implement transaction deserialization, parsing, and verification
2 parents 867d5df + 9f2b66e commit 755490a

File tree

7 files changed

+717
-26
lines changed

7 files changed

+717
-26
lines changed

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { BaseTransaction, ParseTransactionError, TransactionType } from '@bitgo/
99
import { BaseCoin as CoinConfig } from '@bitgo/statics';
1010
import { ethers } from 'ethers';
1111
import { Address, Hex, Tip20Operation } from './types';
12+
import { amountToTip20Units } from './utils';
1213

1314
/**
1415
* TIP-20 Transaction Request Structure
@@ -35,6 +36,19 @@ export interface Tip20TransactionRequest {
3536
feeToken?: Address;
3637
}
3738

39+
export interface TxData {
40+
type: number | string;
41+
chainId: number;
42+
nonce: number;
43+
maxFeePerGas: string;
44+
maxPriorityFeePerGas: string;
45+
gas: string;
46+
callCount: number;
47+
feeToken?: string;
48+
operations: Tip20Operation[];
49+
signature?: { r: Hex; s: Hex; yParity: number };
50+
}
51+
3852
export class Tip20Transaction extends BaseTransaction {
3953
private txRequest: Tip20TransactionRequest;
4054
private _operations: Tip20Operation[];
@@ -44,6 +58,13 @@ export class Tip20Transaction extends BaseTransaction {
4458
super(_coinConfig);
4559
this.txRequest = request;
4660
this._operations = operations;
61+
this._outputs = operations.map((op) => ({
62+
address: op.to,
63+
value: amountToTip20Units(op.amount).toString(),
64+
coin: op.token,
65+
}));
66+
const totalUnits = operations.reduce((sum, op) => sum + amountToTip20Units(op.amount), 0n);
67+
this._inputs = [{ address: '', value: totalUnits.toString(), coin: _coinConfig.name }];
4768
}
4869

4970
get type(): TransactionType {
@@ -190,7 +211,7 @@ export class Tip20Transaction extends BaseTransaction {
190211
return this._signature;
191212
}
192213

193-
toJson(): Record<string, unknown> {
214+
toJson(): TxData {
194215
return {
195216
type: this.txRequest.type,
196217
chainId: this.txRequest.chainId,
@@ -209,8 +230,14 @@ export class Tip20Transaction extends BaseTransaction {
209230
return await this.serialize(this._signature);
210231
}
211232

233+
/** @inheritdoc */
212234
get id(): string {
213-
return 'pending';
235+
try {
236+
const serialized = this.serializeTransaction(this._signature);
237+
return ethers.utils.keccak256(ethers.utils.arrayify(serialized));
238+
} catch {
239+
return 'pending';
240+
}
214241
}
215242

216243
toString(): string {

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

Lines changed: 168 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,31 @@
88
* - EIP-7702 Account Abstraction (type 0x76)
99
*/
1010

11-
import { TransactionBuilder as AbstractTransactionBuilder, TransferBuilder } from '@bitgo/abstract-eth';
12-
import { BaseTransaction, BuildTransactionError } from '@bitgo/sdk-core';
11+
import {
12+
Transaction as EthTransaction,
13+
TransactionBuilder as AbstractTransactionBuilder,
14+
TransferBuilder,
15+
} from '@bitgo/abstract-eth';
16+
import {
17+
BaseTransaction,
18+
BuildTransactionError,
19+
InvalidTransactionError,
20+
ParseTransactionError,
21+
} from '@bitgo/sdk-core';
1322
import { BaseCoin as CoinConfig } from '@bitgo/statics';
23+
import { ethers } from 'ethers';
1424
import { Address, Hex, Tip20Operation } from './types';
1525
import { Tip20Transaction, Tip20TransactionRequest } from './transaction';
16-
import { amountToTip20Units, encodeTip20TransferWithMemo, isValidAddress, isValidTip20Amount } from './utils';
26+
import {
27+
amountToTip20Units,
28+
encodeTip20TransferWithMemo,
29+
isTip20Transaction,
30+
isValidAddress,
31+
isValidMemoId,
32+
isValidTip20Amount,
33+
tip20UnitsToAmount,
34+
} from './utils';
35+
import { TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi';
1736
import { AA_TRANSACTION_TYPE } from './constants';
1837

1938
/**
@@ -27,6 +46,7 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
2746
private _gas?: bigint;
2847
private _maxFeePerGas?: bigint;
2948
private _maxPriorityFeePerGas?: bigint;
49+
private _restoredSignature?: { r: Hex; s: Hex; yParity: number };
3050

3151
constructor(_coinConfig: Readonly<CoinConfig>) {
3252
super(_coinConfig);
@@ -74,8 +94,133 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
7494
}
7595
}
7696

97+
/** @inheritdoc */
98+
validateRawTransaction(rawTransaction: any): void {
99+
if (typeof rawTransaction === 'string' && isTip20Transaction(rawTransaction)) {
100+
try {
101+
ethers.utils.RLP.decode('0x' + rawTransaction.slice(4));
102+
return;
103+
} catch (e) {
104+
throw new ParseTransactionError(`Failed to RLP decode TIP-20 transaction: ${e}`);
105+
}
106+
}
107+
super.validateRawTransaction(rawTransaction);
108+
}
109+
110+
/** @inheritdoc */
111+
protected fromImplementation(rawTransaction: string, isFirstSigner?: boolean): EthTransaction {
112+
if (!rawTransaction) {
113+
throw new InvalidTransactionError('Raw transaction is empty');
114+
}
115+
if (isTip20Transaction(rawTransaction)) {
116+
return this.fromTip20Transaction(rawTransaction) as unknown as EthTransaction;
117+
}
118+
return super.fromImplementation(rawTransaction, isFirstSigner);
119+
}
120+
121+
/**
122+
* Deserialize a type 0x76 transaction and restore builder state.
123+
* RLP field layout mirrors buildBaseRlpData() in transaction.ts.
124+
*/
125+
private fromTip20Transaction(rawTransaction: string): Tip20Transaction {
126+
try {
127+
const rlpHex = '0x' + rawTransaction.slice(4);
128+
const decoded = ethers.utils.RLP.decode(rlpHex) as any[];
129+
130+
if (!Array.isArray(decoded) || decoded.length < 13) {
131+
throw new ParseTransactionError('Invalid TIP-20 transaction: unexpected RLP structure');
132+
}
133+
134+
const parseBigInt = (hex: string): bigint => (!hex || hex === '0x' ? 0n : BigInt(hex));
135+
const parseHexInt = (hex: string): number => (!hex || hex === '0x' ? 0 : parseInt(hex, 16));
136+
137+
const chainId = parseHexInt(decoded[0] as string);
138+
const maxPriorityFeePerGas = parseBigInt(decoded[1] as string);
139+
const maxFeePerGas = parseBigInt(decoded[2] as string);
140+
const gas = parseBigInt(decoded[3] as string);
141+
const callsTuples = decoded[4] as string[][];
142+
const nonce = parseHexInt(decoded[7] as string);
143+
const feeTokenRaw = decoded[10] as string;
144+
145+
const calls: { to: Address; data: Hex; value: bigint }[] = callsTuples.map((tuple) => ({
146+
to: tuple[0] as Address,
147+
value: parseBigInt(tuple[1] as string),
148+
data: tuple[2] as Hex,
149+
}));
150+
151+
const operations: Tip20Operation[] = calls.map((call) => this.decodeCallToOperation(call));
152+
153+
let signature: { r: Hex; s: Hex; yParity: number } | undefined;
154+
if (decoded.length >= 14 && decoded[13] && (decoded[13] as string).length > 2) {
155+
const sigBytes = ethers.utils.arrayify(decoded[13] as string);
156+
if (sigBytes.length === 65) {
157+
const r = ethers.utils.hexlify(sigBytes.slice(0, 32)) as Hex;
158+
const s = ethers.utils.hexlify(sigBytes.slice(32, 64)) as Hex;
159+
const v = sigBytes[64];
160+
const yParity = v > 1 ? v - 27 : v;
161+
signature = { r, s, yParity };
162+
}
163+
}
164+
165+
const feeToken = feeTokenRaw && feeTokenRaw !== '0x' ? (feeTokenRaw as Address) : undefined;
166+
167+
const txRequest: Tip20TransactionRequest = {
168+
type: AA_TRANSACTION_TYPE,
169+
chainId,
170+
nonce,
171+
maxFeePerGas,
172+
maxPriorityFeePerGas,
173+
gas,
174+
calls,
175+
accessList: [],
176+
feeToken,
177+
};
178+
179+
this._nonce = nonce;
180+
this._gas = gas;
181+
this._maxFeePerGas = maxFeePerGas;
182+
this._maxPriorityFeePerGas = maxPriorityFeePerGas;
183+
this._feeToken = feeToken;
184+
this.operations = operations;
185+
this._restoredSignature = signature;
186+
187+
const tx = new Tip20Transaction(this._coinConfig, txRequest, operations);
188+
if (signature) {
189+
tx.setSignature(signature);
190+
}
191+
return tx;
192+
} catch (e) {
193+
if (e instanceof ParseTransactionError) throw e;
194+
throw new ParseTransactionError(`Failed to deserialize TIP-20 transaction: ${e}`);
195+
}
196+
}
197+
198+
/**
199+
* Decode a single AA call's data back into a Tip20Operation.
200+
* Expects the call data to encode transferWithMemo(address, uint256, bytes32).
201+
*/
202+
private decodeCallToOperation(call: { to: Address; data: Hex; value: bigint }): Tip20Operation {
203+
const iface = new ethers.utils.Interface(TIP20_TRANSFER_WITH_MEMO_ABI);
204+
try {
205+
const decoded = iface.decodeFunctionData('transferWithMemo', call.data);
206+
const toAddress = decoded[0] as string;
207+
const amountUnits = BigInt(decoded[1].toString());
208+
const memoBytes32 = decoded[2] as string;
209+
210+
const amount = tip20UnitsToAmount(amountUnits);
211+
212+
const stripped = ethers.utils.stripZeros(memoBytes32);
213+
const memo = stripped.length > 0 ? ethers.utils.toUtf8String(stripped) : undefined;
214+
215+
return { token: call.to, to: toAddress, amount, memo };
216+
} catch {
217+
return { token: call.to, to: call.to, amount: tip20UnitsToAmount(call.value) };
218+
}
219+
}
220+
77221
/**
78222
* Build the transaction from configured TIP-20 operations and transaction parameters.
223+
* Signs with _sourceKeyPair if it has been set via sign({ key }).
79224
*/
80225
protected async buildImplementation(): Promise<BaseTransaction> {
81226
if (
@@ -110,7 +255,24 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
110255
feeToken: this._feeToken,
111256
};
112257

113-
return new Tip20Transaction(this._coinConfig, txRequest, this.operations);
258+
const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations);
259+
260+
if (this._sourceKeyPair && this._sourceKeyPair.getKeys().prv) {
261+
const prv = this._sourceKeyPair.getKeys().prv!;
262+
const unsignedHex = await tx.serialize();
263+
const msgHash = ethers.utils.keccak256(ethers.utils.arrayify(unsignedHex));
264+
const signingKey = new ethers.utils.SigningKey('0x' + prv);
265+
const sig = signingKey.signDigest(ethers.utils.arrayify(msgHash));
266+
tx.setSignature({
267+
r: sig.r as Hex,
268+
s: sig.s as Hex,
269+
yParity: sig.recoveryParam ?? 0,
270+
});
271+
} else if (this._restoredSignature) {
272+
tx.setSignature(this._restoredSignature);
273+
}
274+
275+
return tx;
114276
}
115277

116278
/**
@@ -234,12 +396,8 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
234396
throw new BuildTransactionError(`Invalid amount: ${operation.amount}`);
235397
}
236398

237-
// Validate memo byte length (handles multi-byte UTF-8 characters)
238-
if (operation.memo) {
239-
const memoByteLength = new TextEncoder().encode(operation.memo).length;
240-
if (memoByteLength > 32) {
241-
throw new BuildTransactionError(`Memo too long: ${memoByteLength} bytes. Maximum 32 bytes.`);
242-
}
399+
if (operation.memo !== undefined && !isValidMemoId(operation.memo)) {
400+
throw new BuildTransactionError(`Invalid memo: must be a non-negative integer`);
243401
}
244402
}
245403

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
import { bip32 } from '@bitgo/secp256k1';
88
import { ethers } from 'ethers';
9-
import { TIP20_DECIMALS } from './constants';
9+
import { AA_TRANSACTION_TYPE, TIP20_DECIMALS } from './constants';
1010
import { TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi';
1111

12+
const AA_TX_HEX_REGEX = new RegExp(`^${AA_TRANSACTION_TYPE}[0-9a-f]*$`, 'i');
13+
1214
type Address = string;
1315
type Hex = string;
1416

@@ -133,6 +135,20 @@ export function isValidTip20Amount(amount: string): boolean {
133135
}
134136
}
135137

138+
/**
139+
* Check if a raw transaction string is a Tempo AA transaction (type 0x76)
140+
*/
141+
export function isTip20Transaction(raw: string): boolean {
142+
return AA_TX_HEX_REGEX.test(raw);
143+
}
144+
145+
/**
146+
* Validate that a memoId is a valid non-negative integer string
147+
*/
148+
export function isValidMemoId(memoId: string): boolean {
149+
return typeof memoId === 'string' && /^(0|[1-9]\d*)$/.test(memoId);
150+
}
151+
136152
const utils = {
137153
isValidAddress,
138154
isValidPublicKey,
@@ -142,6 +158,8 @@ const utils = {
142158
stringToBytes32,
143159
encodeTip20TransferWithMemo,
144160
isValidTip20Amount,
161+
isTip20Transaction,
162+
isValidMemoId,
145163
};
146164

147165
export default utils;

0 commit comments

Comments
 (0)