Skip to content

Commit 17d04a7

Browse files
committed
feat(sdk-coin-tempo): implement tx deserialization, structured outputs, and field accessors
Ticket: CECHO-286
1 parent bb92243 commit 17d04a7

File tree

4 files changed

+679
-12
lines changed

4 files changed

+679
-12
lines changed

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

Lines changed: 52 additions & 4 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
@@ -44,6 +45,15 @@ export class Tip20Transaction extends BaseTransaction {
4445
super(_coinConfig);
4546
this.txRequest = request;
4647
this._operations = operations;
48+
// One output per operation: value in base units (6 decimals), coin is the token contract address
49+
this._outputs = operations.map((op) => ({
50+
address: op.to,
51+
value: amountToTip20Units(op.amount).toString(),
52+
coin: op.token,
53+
}));
54+
// Single input entry with total in base units; address is empty for unsigned transactions
55+
const totalUnits = operations.reduce((sum, op) => sum + amountToTip20Units(op.amount), 0n);
56+
this._inputs = [{ address: '', value: totalUnits.toString(), coin: _coinConfig.name }];
4757
}
4858

4959
get type(): TransactionType {
@@ -117,17 +127,17 @@ export class Tip20Transaction extends BaseTransaction {
117127
}
118128

119129
/**
120-
* Encode secp256k1 signature as 65-byte envelope
130+
* Encode secp256k1 signature as 65-byte envelope.
131+
* Follows EIP-2718 typed transaction convention: v = yParity (0 or 1).
121132
* @param signature ECDSA signature components
122133
* @returns Hex string of concatenated r (32) + s (32) + v (1) bytes
123134
* @private
124135
*/
125136
private encodeSignature(signature: { r: Hex; s: Hex; yParity: number }): string {
126-
const v = signature.yParity + 27;
127137
const signatureBytes = ethers.utils.concat([
128138
ethers.utils.zeroPad(signature.r, 32),
129139
ethers.utils.zeroPad(signature.s, 32),
130-
ethers.utils.hexlify(v),
140+
ethers.utils.hexlify(signature.yParity),
131141
]);
132142
return ethers.utils.hexlify(signatureBytes);
133143
}
@@ -170,6 +180,37 @@ export class Tip20Transaction extends BaseTransaction {
170180
return [...this._operations];
171181
}
172182

183+
/** Transaction nonce */
184+
get nonce(): number {
185+
return this.txRequest.nonce;
186+
}
187+
188+
/** Chain ID of the Tempo network */
189+
get chainId(): number {
190+
return this.txRequest.chainId;
191+
}
192+
193+
/** Gas limit as a decimal string */
194+
get gasLimit(): string {
195+
return this.txRequest.gas.toString();
196+
}
197+
198+
/** Maximum fee per gas as a decimal string (wei) */
199+
get maxFeePerGas(): string {
200+
return this.txRequest.maxFeePerGas.toString();
201+
}
202+
203+
/** Maximum priority fee per gas as a decimal string (wei) */
204+
get maxPriorityFeePerGas(): string {
205+
return this.txRequest.maxPriorityFeePerGas.toString();
206+
}
207+
208+
/** TIP-20 fee token contract address, or undefined if the native token is used */
209+
get feeToken(): string | undefined {
210+
return this.txRequest.feeToken;
211+
}
212+
213+
/** @deprecated Use the `feeToken` getter instead */
173214
getFeeToken(): Address | undefined {
174215
return this.txRequest.feeToken;
175216
}
@@ -209,8 +250,15 @@ export class Tip20Transaction extends BaseTransaction {
209250
return await this.serialize(this._signature);
210251
}
211252

253+
/** @inheritdoc */
212254
get id(): string {
213-
return 'pending';
255+
try {
256+
// Compute keccak256 of the serialized transaction (signed if available, otherwise unsigned)
257+
const serialized = this.serializeTransaction(this._signature);
258+
return ethers.utils.keccak256(ethers.utils.arrayify(serialized));
259+
} catch {
260+
return 'pending';
261+
}
214262
}
215263

216264
toString(): string {

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

Lines changed: 200 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,29 @@
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+
isValidAddress,
30+
isValidTip20Amount,
31+
tip20UnitsToAmount,
32+
} from './utils';
33+
import { TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi';
1734
import { AA_TRANSACTION_TYPE } from './constants';
1835

1936
/**
@@ -27,6 +44,7 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
2744
private _gas?: bigint;
2845
private _maxFeePerGas?: bigint;
2946
private _maxPriorityFeePerGas?: bigint;
47+
private _restoredSignature?: { r: Hex; s: Hex; yParity: number };
3048

3149
constructor(_coinConfig: Readonly<CoinConfig>) {
3250
super(_coinConfig);
@@ -74,8 +92,165 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
7492
}
7593
}
7694

95+
/** @inheritdoc */
96+
validateRawTransaction(rawTransaction: any): void {
97+
if (!rawTransaction) {
98+
throw new InvalidTransactionError('Raw transaction is empty');
99+
}
100+
if (typeof rawTransaction !== 'string') {
101+
throw new InvalidTransactionError('Transaction is not a hex string or stringified json');
102+
}
103+
// Accept Tempo AA transactions (type 0x76)
104+
if (/^0x76[0-9a-f]+$/i.test(rawTransaction)) {
105+
try {
106+
ethers.utils.RLP.decode('0x' + rawTransaction.slice(4));
107+
return;
108+
} catch (e) {
109+
throw new ParseTransactionError(`Failed to RLP decode TIP-20 transaction: ${e}`);
110+
}
111+
}
112+
// Fall back to parent validation for standard ETH hex or JSON formats
113+
super.validateRawTransaction(rawTransaction);
114+
}
115+
116+
/** @inheritdoc */
117+
protected fromImplementation(rawTransaction: string, isFirstSigner?: boolean): EthTransaction {
118+
if (!rawTransaction) {
119+
throw new InvalidTransactionError('Raw transaction is empty');
120+
}
121+
if (/^0x76[0-9a-f]+$/i.test(rawTransaction)) {
122+
// Cast is safe: Tip20Transaction shares the BaseTransaction contract and the builder
123+
// only uses the transaction through BaseTransaction's interface after from().
124+
return this.fromTip20Transaction(rawTransaction) as unknown as EthTransaction;
125+
}
126+
return super.fromImplementation(rawTransaction, isFirstSigner);
127+
}
128+
129+
/**
130+
* Deserialize a Tempo AA transaction (type 0x76) from its hex-encoded RLP form
131+
* and restore builder state so the transaction can be re-built or signed.
132+
*
133+
* RLP field layout (from buildBaseRlpData in transaction.ts):
134+
* [0] chainId
135+
* [1] maxPriorityFeePerGas
136+
* [2] maxFeePerGas
137+
* [3] gas
138+
* [4] calls[ [to, value, data], ... ]
139+
* [5] accessList
140+
* [6] nonceKey (2D-nonce reserved, '0x')
141+
* [7] nonce
142+
* [8] validBefore (time-bound reserved, '0x')
143+
* [9] validAfter (time-bound reserved, '0x')
144+
* [10] feeToken
145+
* [11] feePayerSignature (sponsorship reserved, '0x')
146+
* [12] authorizationList
147+
* [13] signature (65-byte envelope, if signed)
148+
*/
149+
private fromTip20Transaction(rawTransaction: string): Tip20Transaction {
150+
try {
151+
const rlpHex = '0x' + rawTransaction.slice(4); // strip '0x76'
152+
const decoded = ethers.utils.RLP.decode(rlpHex) as any[];
153+
154+
if (!Array.isArray(decoded) || decoded.length < 13) {
155+
throw new ParseTransactionError('Invalid TIP-20 transaction: unexpected RLP structure');
156+
}
157+
158+
const parseBigInt = (hex: string): bigint => (!hex || hex === '0x' ? 0n : BigInt(hex));
159+
const parseHexInt = (hex: string): number => (!hex || hex === '0x' ? 0 : parseInt(hex, 16));
160+
161+
const chainId = parseHexInt(decoded[0] as string);
162+
const maxPriorityFeePerGas = parseBigInt(decoded[1] as string);
163+
const maxFeePerGas = parseBigInt(decoded[2] as string);
164+
const gas = parseBigInt(decoded[3] as string);
165+
const callsTuples = decoded[4] as string[][];
166+
const nonce = parseHexInt(decoded[7] as string);
167+
const feeTokenRaw = decoded[10] as string;
168+
169+
const calls: { to: Address; data: Hex; value: bigint }[] = callsTuples.map((tuple) => ({
170+
to: tuple[0] as Address,
171+
value: parseBigInt(tuple[1] as string),
172+
data: tuple[2] as Hex,
173+
}));
174+
175+
const operations: Tip20Operation[] = calls.map((call) => this.decodeCallToOperation(call));
176+
177+
// Parse optional signature at index [13] (65-byte r+s+v envelope)
178+
let signature: { r: Hex; s: Hex; yParity: number } | undefined;
179+
if (decoded.length >= 14 && decoded[13] && (decoded[13] as string).length > 2) {
180+
const sigBytes = ethers.utils.arrayify(decoded[13] as string);
181+
if (sigBytes.length === 65) {
182+
const r = ethers.utils.hexlify(sigBytes.slice(0, 32)) as Hex;
183+
const s = ethers.utils.hexlify(sigBytes.slice(32, 64)) as Hex;
184+
const v = sigBytes[64];
185+
// Handle both EIP-2718 convention (v=0/1) and legacy offset (v=27/28)
186+
const yParity = v > 1 ? v - 27 : v;
187+
signature = { r, s, yParity };
188+
}
189+
}
190+
191+
const feeToken = feeTokenRaw && feeTokenRaw !== '0x' ? (feeTokenRaw as Address) : undefined;
192+
193+
const txRequest: Tip20TransactionRequest = {
194+
type: AA_TRANSACTION_TYPE,
195+
chainId,
196+
nonce,
197+
maxFeePerGas,
198+
maxPriorityFeePerGas,
199+
gas,
200+
calls,
201+
accessList: [],
202+
feeToken,
203+
};
204+
205+
// Restore builder state so validateTransaction() passes after from()
206+
this._nonce = nonce;
207+
this._gas = gas;
208+
this._maxFeePerGas = maxFeePerGas;
209+
this._maxPriorityFeePerGas = maxPriorityFeePerGas;
210+
this._feeToken = feeToken;
211+
this.operations = operations;
212+
// Preserve signature so buildImplementation() can re-apply it
213+
this._restoredSignature = signature;
214+
215+
const tx = new Tip20Transaction(this._coinConfig, txRequest, operations);
216+
if (signature) {
217+
tx.setSignature(signature);
218+
}
219+
return tx;
220+
} catch (e) {
221+
if (e instanceof ParseTransactionError) throw e;
222+
throw new ParseTransactionError(`Failed to deserialize TIP-20 transaction: ${e}`);
223+
}
224+
}
225+
226+
/**
227+
* Decode a single AA call's data back into a Tip20Operation.
228+
* Expects the call data to encode transferWithMemo(address, uint256, bytes32).
229+
*/
230+
private decodeCallToOperation(call: { to: Address; data: Hex; value: bigint }): Tip20Operation {
231+
const iface = new ethers.utils.Interface(TIP20_TRANSFER_WITH_MEMO_ABI);
232+
try {
233+
const decoded = iface.decodeFunctionData('transferWithMemo', call.data);
234+
const toAddress = decoded[0] as string;
235+
const amountUnits = BigInt(decoded[1].toString());
236+
const memoBytes32 = decoded[2] as string;
237+
238+
const amount = tip20UnitsToAmount(amountUnits);
239+
240+
// Decode memo: bytes32 is left-padded with zeros via hexZeroPad, so strip leading zeros
241+
const stripped = ethers.utils.stripZeros(memoBytes32);
242+
const memo = stripped.length > 0 ? ethers.utils.toUtf8String(stripped) : undefined;
243+
244+
return { token: call.to, to: toAddress, amount, memo };
245+
} catch {
246+
// Fallback for unrecognised call data (e.g., native coin value transfers)
247+
return { token: call.to, to: call.to, amount: tip20UnitsToAmount(call.value) };
248+
}
249+
}
250+
77251
/**
78252
* Build the transaction from configured TIP-20 operations and transaction parameters.
253+
* Signs with _sourceKeyPair if it has been set via sign({ key }).
79254
*/
80255
protected async buildImplementation(): Promise<BaseTransaction> {
81256
if (
@@ -110,7 +285,28 @@ export class Tip20TransactionBuilder extends AbstractTransactionBuilder {
110285
feeToken: this._feeToken,
111286
};
112287

113-
return new Tip20Transaction(this._coinConfig, txRequest, this.operations);
288+
const tx = new Tip20Transaction(this._coinConfig, txRequest, this.operations);
289+
290+
// Re-apply a signature that was parsed from a serialized transaction
291+
if (this._restoredSignature) {
292+
tx.setSignature(this._restoredSignature);
293+
}
294+
295+
// Sign if a key pair was provided via sign({ key })
296+
if (this._sourceKeyPair && this._sourceKeyPair.getKeys().prv) {
297+
const prv = this._sourceKeyPair.getKeys().prv!;
298+
const unsignedHex = await tx.serialize();
299+
const msgHash = ethers.utils.keccak256(ethers.utils.arrayify(unsignedHex));
300+
const signingKey = new ethers.utils.SigningKey('0x' + prv);
301+
const sig = signingKey.signDigest(ethers.utils.arrayify(msgHash));
302+
tx.setSignature({
303+
r: sig.r as Hex,
304+
s: sig.s as Hex,
305+
yParity: sig.recoveryParam ?? 0,
306+
});
307+
}
308+
309+
return tx;
114310
}
115311

116312
/**

0 commit comments

Comments
 (0)