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' ;
1322import { BaseCoin as CoinConfig } from '@bitgo/statics' ;
23+ import { ethers } from 'ethers' ;
1424import { Address , Hex , Tip20Operation } from './types' ;
1525import { 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' ;
1734import { 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 ( / ^ 0 x 7 6 [ 0 - 9 a - 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 ( / ^ 0 x 7 6 [ 0 - 9 a - 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