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+ isTip20Transaction ,
30+ isValidAddress ,
31+ isValidMemoId ,
32+ isValidTip20Amount ,
33+ tip20UnitsToAmount ,
34+ } from './utils' ;
35+ import { TIP20_TRANSFER_WITH_MEMO_ABI } from './tip20Abi' ;
1736import { 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
0 commit comments