From 878248f3870a35190e0083ae84afc207d0f17512 Mon Sep 17 00:00:00 2001 From: prithvishet2503 Date: Thu, 17 Sep 2026 00:51:08 +0530 Subject: [PATCH 1/6] feat(abstract-eth): add EIP-7702 set code (0x04) transaction support --- modules/abstract-eth/src/lib/eip7702.ts | 260 ++++++++++++++++++ modules/abstract-eth/src/lib/iface.ts | 17 +- modules/abstract-eth/src/lib/index.ts | 1 + .../src/lib/transactionBuilder.ts | 53 +++- modules/abstract-eth/src/lib/types.ts | 247 ++++++++++++++--- modules/abstract-eth/test/unit/eip7702.ts | 148 ++++++++++ .../abstract-eth/test/unit/eip7702Builder.ts | 76 +++++ .../abstract-eth/test/unit/eip7702TxData.ts | 202 ++++++++++++++ 8 files changed, 967 insertions(+), 37 deletions(-) create mode 100644 modules/abstract-eth/src/lib/eip7702.ts create mode 100644 modules/abstract-eth/test/unit/eip7702.ts create mode 100644 modules/abstract-eth/test/unit/eip7702Builder.ts create mode 100644 modules/abstract-eth/test/unit/eip7702TxData.ts diff --git a/modules/abstract-eth/src/lib/eip7702.ts b/modules/abstract-eth/src/lib/eip7702.ts new file mode 100644 index 0000000000..2c84bf5b33 --- /dev/null +++ b/modules/abstract-eth/src/lib/eip7702.ts @@ -0,0 +1,260 @@ +/** + * EIP-7702: Set Code for EOAs. + * + * Encoding/decoding helpers for the "set code" transaction type (0x04) and its + * per-authorization digest. These produce byte-for-byte compatible output with + * the reference implementations (@ethereumjs/tx `EOACodeEIP7702Transaction`, + * geth). + * + * Wire format (EIP-2718): + * + * ``` + * rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, + * destination, value, data, access_list, authorization_list, + * signature_y_parity, signature_r, signature_s]) + * + * authorization_list = [[chain_id, address, nonce, y_parity, r, s], ...] + * ``` + * + * Each authorization is signed by the delegating EOA over + * `keccak256(0x05 || rlp([chain_id, address, nonce]))`. + * + * BitGo wallets are MPC EOAs, so the per-authorization digest (a 32-byte + * value) can be signed with the existing MPC ECDSA message-signing path and the + * resulting `(r, s, yParity)` embedded via {@link buildSetCodeTransaction}. + */ + +import { RLP } from '@ethereumjs/rlp'; +import BN from 'bn.js'; +import assert from 'assert'; +import { addHexPrefix, bufferToHex, keccak256, setLengthLeft, toBuffer } from 'ethereumjs-util'; + +/** EIP-7702 set code transaction type (`0x04`). */ +export const SET_CODE_TX_TYPE = 0x04; +/** Magic byte prepended to the RLP-encoded authorization for its signing digest. */ +export const SET_CODE_MAGIC = 0x05; +/** Delegation indicator written into an EOA's code: `0xef0100 || address`. */ +export const DELEGATION_PREFIX = Buffer.from('ef0100', 'hex'); +/** Length of a delegation indicator (3-byte prefix + 20-byte address). */ +export const DELEGATION_INDICATOR_LENGTH = 23; + +/** Access-list entry, matching EIP-2930 semantics. */ +export interface SetCodeAccessListEntry { + address: string; + storageKeys: string[]; +} + +/** An unsigned authorization: the fields covered by the delegation signature. */ +export interface SetCodeAuthorizationUnsigned { + chainId: number | bigint | string; + /** 20-byte address that will hold the delegation (0x-prefixed hex). */ + address: string; + nonce: number | bigint | string; +} + +/** A fully signed authorization, ready to be embedded in a set code tx. */ +export interface SetCodeAuthorization extends SetCodeAuthorizationUnsigned { + yParity: 0 | 1; + /** secp256k1 signature `r` (0x-prefixed hex). */ + r: string; + /** secp256k1 signature `s` (0x-prefixed hex). */ + s: string; +} + +/** Outer set code transaction parameters (EIP-4844/EIP-1559 semantics). */ +export interface SetCodeTransactionParams { + chainId: number | bigint | string; + nonce: number | bigint | string; + maxPriorityFeePerGas: number | bigint | string; + maxFeePerGas: number | bigint | string; + gasLimit: number | bigint | string; + /** Destination of the transaction; EIP-7702 requires a non-null destination. */ + destination: string; + value: number | bigint | string; + /** Hex-encoded calldata (0x-prefixed or not). */ + data: string; + accessList?: SetCodeAccessListEntry[]; + authorizationList: SetCodeAuthorization[]; +} + +/** A signed set code transaction. */ +export interface SignedSetCodeTransaction extends SetCodeTransactionParams { + yParity: 0 | 1; + r: string; + s: string; +} + +/** Encode a scalar (number | bigint | string | BN) as minimal-length bytes. */ +function toMinimalBuffer(value: number | bigint | string | BN): Buffer { + const bn = BN.isBN(value) ? value : new BN(String(value), 10); + // bn.js toArrayLike emits a single 0x00 for zero; RLP expects the empty byte + // string (0x80) for a zero-valued scalar. + if (bn.isZero()) { + return Buffer.alloc(0); + } + return bn.toArrayLike(Buffer); +} + +/** Normalize an address/data hex string to a Buffer, preserving length for addresses. */ +function toBytes(value: string, fixedLength?: number): Buffer { + const buf = toBuffer(addHexPrefix(value)); + return fixedLength === undefined ? buf : setLengthLeft(buf, fixedLength); +} + +function encodeAccessList(accessList: SetCodeAccessListEntry[]): Array<[Buffer, Buffer[]]> { + return accessList.map((entry) => [ + toBytes(entry.address, 20), + entry.storageKeys.map((key) => toBytes(key, 32)), + ]); +} + +/** + * Compute the signing digest for a set code authorization: + * `keccak256(0x05 || rlp([chain_id, address, nonce]))`. + * + * The digest is a 32-byte value that can be signed by the wallet's MPC ECDSA + * key (via the existing message-signing path) to produce `(r, s, yParity)`. + */ +export function computeSetCodeAuthorizationDigest(auth: SetCodeAuthorizationUnsigned): Buffer { + const encoded = RLP.encode([ + toMinimalBuffer(auth.chainId), + toBytes(auth.address, 20), + toMinimalBuffer(auth.nonce), + ]); + return keccak256(Buffer.concat([Buffer.from([SET_CODE_MAGIC]), Buffer.from(encoded)])); +} + +/** + * Build and serialize a signed EIP-7702 set code transaction. + * + * `params` must include the envelope signature (`yParity`, `r`, `s`) and fully + * signed `authorizationList` entries. Returns the EIP-2718 serialized bytes + * (type byte `0x04` followed by the RLP payload). + */ +export function buildSetCodeTransaction(params: SignedSetCodeTransaction): Buffer { + const authorizationList = params.authorizationList.map((auth) => [ + toMinimalBuffer(auth.chainId), + toBytes(auth.address, 20), + // Per the reference implementations, the authorization `nonce` is RLP + // encoded as a nested list. + [toMinimalBuffer(auth.nonce)], + toMinimalBuffer(auth.yParity), + toBytes(auth.r, 32), + toBytes(auth.s, 32), + ]); + + const payload = RLP.encode([ + toMinimalBuffer(params.chainId), + toMinimalBuffer(params.nonce), + toMinimalBuffer(params.maxPriorityFeePerGas), + toMinimalBuffer(params.maxFeePerGas), + toMinimalBuffer(params.gasLimit), + toBytes(params.destination, 20), + toMinimalBuffer(params.value), + toBytes(params.data), + encodeAccessList(params.accessList ?? []), + authorizationList, + toMinimalBuffer(params.yParity), + toBytes(params.r, 32), + toBytes(params.s, 32), + ]); + + return Buffer.concat([Buffer.from([SET_CODE_TX_TYPE]), Buffer.from(payload)]); +} + +/** + * Compute the transaction-envelope signing hash for a set code transaction: + * `keccak256(0x04 || rlp())`. This is the digest the + * sending EOA signs over (in addition to each authorization digest). + */ +export function getSetCodeTransactionSigningHash(params: SetCodeTransactionParams): Buffer { + const authorizationList = params.authorizationList.map((auth) => [ + toMinimalBuffer(auth.chainId), + toBytes(auth.address, 20), + [toMinimalBuffer(auth.nonce)], + toMinimalBuffer(auth.yParity), + toBytes(auth.r, 32), + toBytes(auth.s, 32), + ]); + + const payload = RLP.encode([ + toMinimalBuffer(params.chainId), + toMinimalBuffer(params.nonce), + toMinimalBuffer(params.maxPriorityFeePerGas), + toMinimalBuffer(params.maxFeePerGas), + toMinimalBuffer(params.gasLimit), + toBytes(params.destination, 20), + toMinimalBuffer(params.value), + toBytes(params.data), + encodeAccessList(params.accessList ?? []), + authorizationList, + ]); + + return keccak256(Buffer.concat([Buffer.from([SET_CODE_TX_TYPE]), Buffer.from(payload)])); +} + +interface DecodedSetCodeTransaction extends Array { + 0: Buffer; // chainId + 1: Buffer; // nonce + 2: Buffer; // maxPriorityFeePerGas + 3: Buffer; // maxFeePerGas + 4: Buffer; // gasLimit + 5: Buffer; // destination + 6: Buffer; // value + 7: Buffer; // data + 8: Buffer[]; // accessList + 9: Buffer[]; // authorizationList + 10: Buffer; // yParity + 11: Buffer; // r + 12: Buffer; // s +} + +function assertDecodedList(value: unknown, index: number): Buffer[] { + assert(Array.isArray(value), `Invalid set code tx: field ${index} is not a list`); + return value as Buffer[]; +} + +/** + * Parse a serialized EIP-7702 set code transaction into its component fields. + * Accepts a 0x-prefixed or raw hex string. + */ +export function parseSetCodeTransaction(serialized: string): SignedSetCodeTransaction { + const bytes = toBuffer(addHexPrefix(serialized)); + assert(bytes.length > 0, 'Empty set code transaction'); + assert(bytes[0] === SET_CODE_TX_TYPE, `Expected set code tx type 0x04, got 0x${bytes[0].toString(16)}`); + + const decoded = RLP.decode(bytes.subarray(1)) as unknown as DecodedSetCodeTransaction; + assert(decoded.length === 13, `Expected 13 fields, got ${decoded.length}`); + + const rawAuthList = assertDecodedList(decoded[9], 9); + const rawAccessList = assertDecodedList(decoded[8], 8); + + return { + chainId: bufferToHex(decoded[0]), + nonce: bufferToHex(decoded[1]), + maxPriorityFeePerGas: bufferToHex(decoded[2]), + maxFeePerGas: bufferToHex(decoded[3]), + gasLimit: bufferToHex(decoded[4]), + destination: bufferToHex(decoded[5]), + value: bufferToHex(decoded[6]), + data: bufferToHex(decoded[7]), + accessList: rawAccessList.map((entry) => { + const [addr, storageKeys] = entry as unknown as [Buffer, Buffer[]]; + return { address: bufferToHex(addr), storageKeys: storageKeys.map(bufferToHex) }; + }), + authorizationList: rawAuthList.map((auth) => { + const [c, a, n, yp, rr, ss] = auth as unknown as [Buffer, Buffer, Buffer[], Buffer, Buffer, Buffer]; + return { + chainId: bufferToHex(c), + address: bufferToHex(a), + nonce: bufferToHex(Buffer.concat(n)), + yParity: yp.length === 0 ? 0 : (yp[0] as 0 | 1), + r: bufferToHex(rr), + s: bufferToHex(ss), + }; + }), + yParity: decoded[10].length === 0 ? 0 : (decoded[10][0] as 0 | 1), + r: bufferToHex(decoded[11]), + s: bufferToHex(decoded[12]), + }; +} diff --git a/modules/abstract-eth/src/lib/iface.ts b/modules/abstract-eth/src/lib/iface.ts index fd5c6cdc00..886a29751d 100644 --- a/modules/abstract-eth/src/lib/iface.ts +++ b/modules/abstract-eth/src/lib/iface.ts @@ -1,5 +1,6 @@ import { BaseFee } from '@bitgo/sdk-core'; import { KeyPair } from './keyPair'; +import { SetCodeAuthorization } from './eip7702'; export interface EthFee extends BaseFee { gasLimit: string; @@ -50,6 +51,7 @@ export interface BaseTxData { export const ETHTransactionType = { LEGACY: 'Legacy', EIP1559: 'EIP1559', + EIP7702: 'EIP7702', } as const; // eslint-disable-next-line no-redeclare @@ -69,7 +71,20 @@ export interface EIP1559TxData extends BaseTxData { maxPriorityFeePerGas: string; } -export type TxData = EIP1559TxData | LegacyTxData; +/** + * EIP-7702 "set code" transaction data (transaction type `0x04`). Uses EIP-1559 + * fee semantics plus an ordered list of signed authorizations that delegate the + * sender EOA's code to a target address. + */ +export interface EIP7702TxData extends BaseTxData { + _type: typeof ETHTransactionType.EIP7702; + gasPrice?: never; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + authorizationList: SetCodeAuthorization[]; +} + +export type TxData = EIP1559TxData | LegacyTxData | EIP7702TxData; /** * An Ethereum transaction with helpers for serialization and deserialization. diff --git a/modules/abstract-eth/src/lib/index.ts b/modules/abstract-eth/src/lib/index.ts index 0eca42a295..ed52b1c870 100644 --- a/modules/abstract-eth/src/lib/index.ts +++ b/modules/abstract-eth/src/lib/index.ts @@ -1,4 +1,5 @@ export * from './constants'; +export * from './eip7702'; export * from './zamaUtils'; export * from './decryptionDelegationBuilder'; export * from './contractCall'; diff --git a/modules/abstract-eth/src/lib/transactionBuilder.ts b/modules/abstract-eth/src/lib/transactionBuilder.ts index 3b37d1d8d3..e77f510502 100644 --- a/modules/abstract-eth/src/lib/transactionBuilder.ts +++ b/modules/abstract-eth/src/lib/transactionBuilder.ts @@ -30,6 +30,7 @@ import { UnwrapERC7984Data, FinalizeUnwrapERC7984Data, } from './iface'; +import { SET_CODE_TX_TYPE, SetCodeAuthorization } from './eip7702'; import { calculateForwarderAddress, calculateForwarderV1Address, @@ -84,6 +85,10 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { private _fee: Fee; protected _value: string; + // EIP-7702 set code transaction parameters + private _eip7702: boolean; + private _authorizationList: SetCodeAuthorization[]; + // the signature on the external ETH transaction private _txSignature: SignatureParts; @@ -147,6 +152,8 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this._type = TransactionType.Send; this._counter = 0; this._value = '0'; + this._eip7702 = false; + this._authorizationList = []; this._walletOwnerAddresses = []; this._forwarderVersion = 0; this._walletVersion = 0; @@ -256,6 +263,13 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { }); } + // EIP-7702 (type 0x04) reuses EIP-1559 fee fields and carries a set code + // authorization list. + if (transactionJson._type === ETHTransactionType.EIP7702) { + this._eip7702 = true; + this._authorizationList = transactionJson.authorizationList; + } + if (hasSignature(transactionJson)) { this._txSignature = { v: transactionJson.v!, r: transactionJson.r!, s: transactionJson.s! }; } @@ -439,7 +453,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { if (typeof rawTransaction === 'string') { if (RAW_TX_HEX_REGEX.test(rawTransaction.toLowerCase())) { const txBytes = ethUtil.toBuffer(ethUtil.addHexPrefix(rawTransaction.toLowerCase())); - if (!this.isEip1559Txn(txBytes) && !this.isRLPDecodable(txBytes)) { + if (!this.isEip1559Txn(txBytes) && !this.isRLPDecodable(txBytes) && !this.isSetCodeTxn(txBytes)) { throw new ParseTransactionError('There was error in decoding the hex string'); } } else { @@ -472,6 +486,10 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } } + private isSetCodeTxn(txn: Buffer): boolean { + return txn.length > 0 && txn[0] === SET_CODE_TX_TYPE; + } + protected validateBaseTransactionFields(): void { if (this._fee === undefined || (!this._fee.fee && !this._fee.gasPrice && !this._fee.eip1559)) { throw new BuildTransactionError('Invalid transaction: missing fee'); @@ -787,7 +805,17 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { to: this._contractAddress, }; - if (this._fee.eip1559) { + if (this._eip7702) { + // EIP-7702 uses EIP-1559 fee semantics plus a set code authorization list. + const eip1559 = this._fee.eip1559; + return { + ...baseParams, + _type: ETHTransactionType.EIP7702, + maxFeePerGas: eip1559 ? eip1559.maxFeePerGas : this._fee.fee, + maxPriorityFeePerGas: eip1559 ? eip1559.maxPriorityFeePerGas : this._fee.fee, + authorizationList: this._authorizationList, + }; + } else if (this._fee.eip1559) { return { ...baseParams, _type: ETHTransactionType.EIP1559, @@ -804,6 +832,27 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { } } + /** + * Mark the transaction as an EIP-7702 set code transaction (type `0x04`). + * The fee must be set with EIP-1559 fields (`maxFeePerGas` / + * `maxPriorityFeePerGas`) and at least one authorization must be supplied via + * {@link setAuthorizationList}. + */ + eip7702(): this { + this._eip7702 = true; + return this; + } + + /** + * Set the signed EIP-7702 authorization list to embed in the transaction. + * + * @param authorizationList the fully signed authorizations + */ + setAuthorizationList(authorizationList: SetCodeAuthorization[]): this { + this._authorizationList = authorizationList; + return this; + } + // endregion // region WalletInitialization builder methods diff --git a/modules/abstract-eth/src/lib/types.ts b/modules/abstract-eth/src/lib/types.ts index 707a7ca0e3..a407f1da7f 100644 --- a/modules/abstract-eth/src/lib/types.ts +++ b/modules/abstract-eth/src/lib/types.ts @@ -8,9 +8,24 @@ import { AccessListEIP2930Transaction, } from '@ethereumjs/tx'; import EthereumCommon from '@ethereumjs/common'; -import { bufferToHex, bufferToInt, toBuffer, toUnsigned, addHexPrefix } from 'ethereumjs-util'; -import { BaseTxData, EIP1559TxData, EthLikeTransactionData, LegacyTxData, ETHTransactionType, TxData } from './iface'; +import { bufferToHex, bufferToInt, ecsign, keccak256, toBuffer, toUnsigned, addHexPrefix } from 'ethereumjs-util'; +import { + BaseTxData, + EIP1559TxData, + EIP7702TxData, + EthLikeTransactionData, + LegacyTxData, + ETHTransactionType, + TxData, +} from './iface'; import { KeyPair } from './keyPair'; +import { + SET_CODE_TX_TYPE, + SignedSetCodeTransaction, + buildSetCodeTransaction, + getSetCodeTransactionSigningHash, + parseSetCodeTransaction, +} from './eip7702'; // https://github.com/ethereumjs/ethereumjs-monorepo/blob/master/packages/tx/src/transactionFactory.ts#L31 const LEGACY_TX_TYPE = 0; @@ -20,12 +35,32 @@ const EIP1559_TX_TYPE = 2; * An Ethereum transaction with helpers for serialization and deserialization. */ export class EthTransactionData implements EthLikeTransactionData { - private tx: TypedTransaction; + /** The @ethereumjs/tx transaction, set for Legacy and EIP-1559 types. */ + private tx?: TypedTransaction; + /** + * The parsed EIP-7702 set code transaction fields. Set (instead of `tx`) for + * transaction type `0x04`, which @ethereumjs/tx v3 cannot represent natively. + */ + private eip7702?: SignedSetCodeTransaction; protected args?: { deployedAddress?: string; chainId?: string }; - constructor(tx: TypedTransaction, args?: { deployedAddress?: string; chainId?: string }) { - this.tx = tx; + constructor(tx: TypedTransaction, args?: { deployedAddress?: string; chainId?: string }); + constructor( + eip7702: SignedSetCodeTransaction, + args: { deployedAddress?: string; chainId?: string } | undefined, + isEip7702: true + ); + constructor( + txOrEip7702: TypedTransaction | SignedSetCodeTransaction, + args?: { deployedAddress?: string; chainId?: string }, + isEip7702?: boolean + ) { this.args = args; + if (isEip7702) { + this.eip7702 = txOrEip7702 as SignedSetCodeTransaction; + } else { + this.tx = txOrEip7702 as TypedTransaction; + } } /** @@ -36,6 +71,35 @@ export class EthTransactionData implements EthLikeTransactionData { * @returns {EthTransactionData} a new ethereum transaction object */ public static fromJson(tx: TxData, common: EthereumCommon): EthTransactionData { + if (isEIP7702Tx(tx)) { + // buildSetCodeTransaction / getSetCodeTransactionSigningHash treat string + // scalars as decimal, so the stored representation uses decimal strings + // for all scalar fields. + const eip7702: SignedSetCodeTransaction = { + chainId: tx.chainId ? scalarToDecimalString(tx.chainId) : common.chainIdBN().toString(10), + nonce: scalarToDecimalString(tx.nonce), + maxPriorityFeePerGas: scalarToDecimalString(tx.maxPriorityFeePerGas), + maxFeePerGas: scalarToDecimalString(tx.maxFeePerGas), + gasLimit: scalarToDecimalString(tx.gasLimit), + destination: tx.to ?? '', + value: scalarToDecimalString(tx.value), + data: tx.data, + authorizationList: tx.authorizationList, + yParity: tx.v !== undefined ? (Number(tx.v) as 0 | 1) : 0, + r: tx.r ?? '0x', + s: tx.s ?? '0x', + }; + + return new EthTransactionData( + eip7702, + { + deployedAddress: tx.deployedAddress, + chainId: addHexPrefix(new BigNumber(Number(tx.chainId)).toString(16)), + }, + true + ); + } + const nonce = addHexPrefix(new BigNumber(tx.nonce).toString(16)); const value = addHexPrefix(new BigNumber(tx.value).toString(16)); const gasLimit = addHexPrefix(new BigNumber(tx.gasLimit).toString(16)); @@ -81,68 +145,95 @@ export class EthTransactionData implements EthLikeTransactionData { * @param common */ public static fromSerialized(tx: string, common: EthereumCommon): EthTransactionData { + const bytes = toBuffer(addHexPrefix(tx)); + // @ethereumjs/tx v3 cannot parse the EIP-7702 type byte (0x04), so route + // those through the dedicated set code util. + if (bytes.length > 0 && bytes[0] === SET_CODE_TX_TYPE) { + // parseSetCodeTransaction returns hex strings; convert scalars to the + // decimal representation expected by buildSetCodeTransaction. + return new EthTransactionData(normalizeEip7702Scalars(parseSetCodeTransaction(tx)), undefined, true); + } return new EthTransactionData( - TransactionFactory.fromSerializedData(toBuffer(addHexPrefix(tx)), { common: common }) + TransactionFactory.fromSerializedData(bytes, { common: common }) ); } sign(keyPair: KeyPair) { const privateKey = Buffer.from(keyPair.getKeys().prv as string, 'hex'); - this.tx = this.tx.sign(privateKey); + if (this.eip7702) { + // Produce the envelope signature over the set code transaction signing hash. + const hash = getSetCodeTransactionSigningHash(this.eip7702); + const sig = ecsign(hash, privateKey); + this.eip7702 = { + ...this.eip7702, + yParity: (sig.v - 27) as 0 | 1, + r: bufferToHex(sig.r), + s: bufferToHex(sig.s), + }; + return; + } + this.tx = this.tx!.sign(privateKey); } getSignablePayload(): Buffer { - return Buffer.from(this.tx.getMessageToSign(true)); + if (this.eip7702) { + // The 32-byte envelope digest an external (MPC) signer signs over. + return getSetCodeTransactionSigningHash(this.eip7702); + } + return Buffer.from(this.tx!.getMessageToSign(true)); } /** @inheritdoc */ toJson(): TxData { + if (this.eip7702) { + return this.toEip7702Json(); + } + + const tx = this.tx!; const result: BaseTxData = { - nonce: bufferToInt(toUnsigned(this.tx.nonce)), - gasLimit: new BigNumber(bufferToHex(toUnsigned(this.tx.gasLimit)), 16).toString(10), - value: this.tx.value.toString(10), - data: bufferToHex(this.tx.data), + nonce: bufferToInt(toUnsigned(tx.nonce)), + gasLimit: new BigNumber(bufferToHex(toUnsigned(tx.gasLimit)), 16).toString(10), + value: tx.value.toString(10), + data: bufferToHex(tx.data), }; - if (this.tx.isSigned()) { - result.id = addHexPrefix(bufferToHex(this.tx.hash())); + if (tx.isSigned()) { + result.id = addHexPrefix(bufferToHex(tx.hash())); } else { - result.id = addHexPrefix(bufferToHex(this.tx.getMessageToSign())); + result.id = addHexPrefix(bufferToHex(tx.getMessageToSign())); } - if (this.tx.to) { - result.to = bufferToHex(this.tx.to.toBuffer()); + if (tx.to) { + result.to = bufferToHex(tx.to.toBuffer()); } - if (this.tx.verifySignature()) { - result.from = bufferToHex(this.tx.getSenderAddress().toBuffer()); - assert(this.tx.r != undefined); - result.r = bufferToHex(toUnsigned(this.tx.r)); - assert(this.tx.s != undefined); - result.s = bufferToHex(toUnsigned(this.tx.s)); + if (tx.verifySignature()) { + result.from = bufferToHex(tx.getSenderAddress().toBuffer()); + assert(tx.r != undefined); + result.r = bufferToHex(toUnsigned(tx.r)); + assert(tx.s != undefined); + result.s = bufferToHex(toUnsigned(tx.s)); } - if (this.tx.v) { - result.v = bufferToHex(toUnsigned(this.tx.v)); + if (tx.v) { + result.v = bufferToHex(toUnsigned(tx.v)); } - result.chainId = addHexPrefix(this.tx.common.chainIdBN().toString(16)); + result.chainId = addHexPrefix(tx.common.chainIdBN().toString(16)); if (this.args && this.args.deployedAddress) { result.deployedAddress = this.args.deployedAddress; } - if (this.tx instanceof LegacyTransaction) { - const gasPrice = new BigNumber(bufferToHex(toUnsigned(this.tx.gasPrice)), 16).toString(10); + if (tx instanceof LegacyTransaction) { + const gasPrice = new BigNumber(bufferToHex(toUnsigned(tx.gasPrice)), 16).toString(10); return { ...result, _type: ETHTransactionType.LEGACY, gasPrice, }; - } else if (this.tx instanceof FeeMarketEIP1559Transaction) { - const maxFeePerGas = new BigNumber(bufferToHex(toUnsigned(this.tx.maxFeePerGas)), 16).toString(10); - const maxPriorityFeePerGas = new BigNumber(bufferToHex(toUnsigned(this.tx.maxPriorityFeePerGas)), 16).toString( - 10 - ); + } else if (tx instanceof FeeMarketEIP1559Transaction) { + const maxFeePerGas = new BigNumber(bufferToHex(toUnsigned(tx.maxFeePerGas)), 16).toString(10); + const maxPriorityFeePerGas = new BigNumber(bufferToHex(toUnsigned(tx.maxPriorityFeePerGas)), 16).toString(10); return { ...result, @@ -155,9 +246,55 @@ export class EthTransactionData implements EthLikeTransactionData { } } + /** + * Produce the JSON representation of an EIP-7702 set code transaction. The + * stored scalar fields are decimal strings; they are emitted in the same + * representation used for the other transaction types (decimal strings and a + * numeric nonce), with the chain id as a 0x-prefixed hex string. + */ + private toEip7702Json(): EIP7702TxData { + const e = this.eip7702!; + const result: BaseTxData = { + nonce: Number(e.nonce), + gasLimit: String(e.gasLimit), + value: String(e.value), + data: e.data, + }; + + result.chainId = addHexPrefix(new BigNumber(String(e.chainId)).toString(16)); + const isSigned = e.r !== '0x' && e.s !== '0x'; + result.id = addHexPrefix( + (isSigned ? keccak256(buildSetCodeTransaction(e)) : getSetCodeTransactionSigningHash(e)).toString('hex') + ); + result.to = e.destination; + + if (isSigned) { + // Match the 0x-prefixed hex convention used by the other transaction types + // (and expected by toStringSig / hasSignature). + result.v = addHexPrefix(e.yParity.toString(16).padStart(2, '0')); + result.r = e.r; + result.s = e.s; + } + + if (this.args && this.args.deployedAddress) { + result.deployedAddress = this.args.deployedAddress; + } + + return { + ...result, + _type: ETHTransactionType.EIP7702, + maxFeePerGas: String(e.maxFeePerGas), + maxPriorityFeePerGas: String(e.maxPriorityFeePerGas), + authorizationList: e.authorizationList, + }; + } + /** @inheritdoc */ toSerialized(): string { - return addHexPrefix(this.tx.serialize().toString('hex')); + if (this.eip7702) { + return addHexPrefix(buildSetCodeTransaction(this.eip7702).toString('hex')); + } + return addHexPrefix(this.tx!.serialize().toString('hex')); } } @@ -168,3 +305,45 @@ function isLegacyTx(tx: TxData): tx is LegacyTxData { function isEIP1559Txn(tx: TxData): tx is EIP1559TxData { return tx._type === ETHTransactionType.EIP1559; } + +function isEIP7702Tx(tx: TxData): tx is EIP7702TxData { + return tx._type === ETHTransactionType.EIP7702; +} + +/** + * Convert an EIP-7702 scalar to a decimal string. Hex strings (0x-prefixed, + * as returned by {@link parseSetCodeTransaction}) are converted to decimal; + * everything else is passed through, since the set code encoding treats string + * scalars as decimal. + */ +function scalarToDecimalString(value: string | number | bigint): string { + if (typeof value === 'string' && value.startsWith('0x')) { + // BigNumber yields "NaN" for the bare "0x" prefix; map it to zero. + if (value === '0x') { + return '0'; + } + return new BigNumber(value, 16).toString(10); + } + return String(value); +} + +/** + * Normalize a parsed EIP-7702 transaction (hex-string scalars) into the decimal + * scalar representation expected by {@link buildSetCodeTransaction}. + */ +function normalizeEip7702Scalars(tx: SignedSetCodeTransaction): SignedSetCodeTransaction { + return { + ...tx, + chainId: scalarToDecimalString(tx.chainId), + nonce: scalarToDecimalString(tx.nonce), + maxPriorityFeePerGas: scalarToDecimalString(tx.maxPriorityFeePerGas), + maxFeePerGas: scalarToDecimalString(tx.maxFeePerGas), + gasLimit: scalarToDecimalString(tx.gasLimit), + value: scalarToDecimalString(tx.value), + authorizationList: tx.authorizationList.map((auth) => ({ + ...auth, + chainId: scalarToDecimalString(auth.chainId), + nonce: scalarToDecimalString(auth.nonce), + })), + }; +} diff --git a/modules/abstract-eth/test/unit/eip7702.ts b/modules/abstract-eth/test/unit/eip7702.ts new file mode 100644 index 0000000000..7e0da4820c --- /dev/null +++ b/modules/abstract-eth/test/unit/eip7702.ts @@ -0,0 +1,148 @@ +import should from 'should'; +import { bufferToHex } from 'ethereumjs-util'; +import { + computeSetCodeAuthorizationDigest, + buildSetCodeTransaction, + parseSetCodeTransaction, + getSetCodeTransactionSigningHash, + SET_CODE_TX_TYPE, + DELEGATION_PREFIX, +} from '../../src/lib/eip7702'; + +/** + * Reference vector generated with the official @ethereumjs/tx v5 + * `EOACodeEIP7702Transaction` (hardfork prague, eips [7702]) for: + * chainId=1, nonce=0, maxPriorityFeePerGas=1, maxFeePerGas=30, gasLimit=21000, + * destination=address, value=0, data=0x, single authorization + * (chainId=1, address=address, nonce=0) signed by the same key. + * Serialized bytes are expressed as decimal arrays to keep them legible in + * source; each value is one byte. + */ +const ADDRESS = '0xbe78addef3bf432e660f0944e372954d1d287fe2'; + +// keccak256(0x05 || rlp([1, address, 0])) +const EXPECTED_AUTH_DIGEST_BYTES = [ + 136, 136, 244, 229, 102, 255, 237, 181, 15, 157, 139, 231, 95, 203, 240, 217, 175, 144, 224, 213, 242, 214, 151, 202, + 217, 167, 243, 97, 139, 161, 83, 234, +]; + +// Authorization signature (r, s) from the same key over EXPECTED_AUTH_DIGEST. +const AUTH_R_BYTES = [ + 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, 155, 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, + 212, 148, 74, 48, 85, 15, 18, +]; +const AUTH_S_BYTES = [ + 113, 131, 176, 111, 21, 92, 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, 229, + 16, 60, 235, 190, 188, 221, 235, +]; + +// Transaction envelope signature (yParity, r, s). +const ENVELOPE_R_BYTES = [ + 47, 223, 167, 78, 21, 168, 232, 3, 184, 81, 179, 23, 135, 208, 15, 246, 230, 97, 31, 50, 233, 68, 50, 252, 85, 107, + 206, 81, 237, 215, 86, 247, +]; +const ENVELOPE_S_BYTES = [ + 122, 136, 155, 55, 93, 184, 202, 64, 135, 102, 78, 167, 138, 136, 145, 21, 253, 192, 94, 138, 158, 241, 135, 149, + 161, 49, 99, 16, 51, 244, 161, 252, +]; + +// Fully signed set code transaction serialized by @ethereumjs/tx v5. +const EXPECTED_SERIALIZED_BYTES = [ + 4, 248, 193, 1, 128, 1, 30, 130, 82, 8, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, 227, 114, 149, + 77, 29, 40, 127, 226, 128, 128, 192, 248, 93, 248, 91, 1, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, + 227, 114, 149, 77, 29, 40, 127, 226, 193, 128, 128, 160, 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, + 155, 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, 212, 148, 74, 48, 85, 15, 18, 160, 113, 131, 176, 111, 21, + 92, 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, 229, 16, 60, 235, 190, 188, + 221, 235, 1, 160, 47, 223, 167, 78, 21, 168, 232, 3, 184, 81, 179, 23, 135, 208, 15, 246, 230, 97, 31, 50, 233, 68, + 50, 252, 85, 107, 206, 81, 237, 215, 86, 247, 160, 122, 136, 155, 55, 93, 184, 202, 64, 135, 102, 78, 167, 138, 136, + 145, 21, 253, 192, 94, 138, 158, 241, 135, 149, 161, 49, 99, 16, 51, 244, 161, 252, +]; + +const toHex = (bytes: number[]): string => bufferToHex(Buffer.from(bytes)); + +const TX_PARAMS = { + chainId: 1, + nonce: 0, + maxPriorityFeePerGas: 1, + maxFeePerGas: 30, + gasLimit: 21000, + destination: ADDRESS, + value: 0, + data: '0x', + authorizationList: [ + { + chainId: 1, + address: ADDRESS, + nonce: 0, + yParity: 0 as const, + r: toHex(AUTH_R_BYTES), + s: toHex(AUTH_S_BYTES), + }, + ], +}; + +describe('EIP-7702', () => { + describe('computeSetCodeAuthorizationDigest', () => { + it('matches the @ethereumjs reference digest byte-for-byte', () => { + const digest = computeSetCodeAuthorizationDigest({ chainId: 1, address: ADDRESS, nonce: 0 }); + digest.should.deepEqual(Buffer.from(EXPECTED_AUTH_DIGEST_BYTES)); + }); + + it('returns a 32-byte digest', () => { + const digest = computeSetCodeAuthorizationDigest({ chainId: 1, address: ADDRESS, nonce: 0 }); + digest.length.should.equal(32); + }); + }); + + describe('buildSetCodeTransaction', () => { + it('produces byte-for-byte identical output to @ethereumjs/tx v5', () => { + const signed = buildSetCodeTransaction({ + ...TX_PARAMS, + yParity: 1, + r: toHex(ENVELOPE_R_BYTES), + s: toHex(ENVELOPE_S_BYTES), + }); + Buffer.from(signed).should.deepEqual(Buffer.from(EXPECTED_SERIALIZED_BYTES)); + signed[0].should.equal(SET_CODE_TX_TYPE); + }); + + it('uses the delegation indicator prefix constant', () => { + DELEGATION_PREFIX.toString('hex').should.equal('ef0100'); + }); + }); + + describe('parseSetCodeTransaction', () => { + it('round-trips a serialized set code transaction', () => { + const signed = buildSetCodeTransaction({ + ...TX_PARAMS, + yParity: 1, + r: toHex(ENVELOPE_R_BYTES), + s: toHex(ENVELOPE_S_BYTES), + }); + const parsed = parseSetCodeTransaction(signed.toString('hex')); + parsed.chainId.should.equal('0x01'); + parsed.nonce.should.equal('0x'); + parsed.maxPriorityFeePerGas.should.equal('0x01'); + parsed.maxFeePerGas.should.equal('0x1e'); + parsed.gasLimit.should.equal('0x5208'); + parsed.destination.toLowerCase().should.equal(ADDRESS.toLowerCase()); + parsed.value.should.equal('0x'); + parsed.data.should.equal('0x'); + parsed.yParity.should.equal(1); + parsed.authorizationList.should.have.length(1); + parsed.authorizationList[0].address.toLowerCase().should.equal(ADDRESS.toLowerCase()); + parsed.authorizationList[0].yParity.should.equal(0); + }); + + it('rejects a non-set-code transaction type', () => { + should.throws(() => parseSetCodeTransaction('0x02'), /Expected set code tx type 0x04/); + }); + }); + + describe('getSetCodeTransactionSigningHash', () => { + it('returns a 32-byte keccak hash', () => { + const hash = getSetCodeTransactionSigningHash(TX_PARAMS as never); + hash.length.should.equal(32); + }); + }); +}); diff --git a/modules/abstract-eth/test/unit/eip7702Builder.ts b/modules/abstract-eth/test/unit/eip7702Builder.ts new file mode 100644 index 0000000000..2461c4e1b7 --- /dev/null +++ b/modules/abstract-eth/test/unit/eip7702Builder.ts @@ -0,0 +1,76 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { bufferToHex } from 'ethereumjs-util'; +import { TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { + TransferBuilder, + ERC721TransferBuilder, + ERC1155TransferBuilder, + TransferBuilderERC7984, +} from '../../src/lib'; +import { ETHTransactionType } from '../../src/lib/iface'; +import { SetCodeAuthorization } from '../../src/lib/eip7702'; + +const ADDRESS = '0xbe78addef3bf432e660f0944e372954d1d287fe2'; +const AUTH_R = bufferToHex( + Buffer.from([ + 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, 155, 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, + 212, 148, 74, 48, 85, 15, 18, + ]) +); +const AUTH_S = bufferToHex( + Buffer.from([ + 113, 131, 176, 111, 21, 92, 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, + 229, 16, 60, 235, 190, 188, 221, 235, + ]) +); + +class TestBuilder extends TransactionBuilder { + transfer(): TransferBuilder | ERC721TransferBuilder | ERC1155TransferBuilder | TransferBuilderERC7984 { + throw new Error('transfer not used in this test'); + } + + public get tx() { + return this.transaction; + } +} + +const coinConfig = coins.get('eth'); + +const authorizationList: SetCodeAuthorization[] = [ + { chainId: 1, address: ADDRESS, nonce: 0, yParity: 0, r: AUTH_R, s: AUTH_S }, +]; + +describe('TransactionBuilder EIP-7702', () => { + it('builds a 0x04 tx via eip7702() and setAuthorizationList()', async () => { + const builder = new TestBuilder(coinConfig); + builder.type(TransactionType.SingleSigSend); + builder.contract(ADDRESS); + builder.counter(0); + builder.value('0'); + builder.fee({ + fee: '30', + gasLimit: '21000', + eip1559: { maxFeePerGas: '30', maxPriorityFeePerGas: '1' }, + }); + builder.eip7702().setAuthorizationList(authorizationList); + + const tx = await builder.build(); + const json = tx.toJson(); + + should.equal(json._type, ETHTransactionType.EIP7702); + should(tx.toBroadcastFormat().toLowerCase()).startWith('0x04'); + }); + + it('loads a serialized 0x04 tx through from()', () => { + const builder = new TestBuilder(coinConfig); + // Serialized via the util; validateRawTransaction must accept the 0x04 type byte. + const serialized = + '0x04f8c10180011e82520894be78addef3bf432e660f0944e372954d1d287fe28080c0f85df85b0194be78addef3bf432e660f0944e372954d1d287fe2c18080a019e5761489a956602bf945276c9bde9b22436490b6a85c1217d4944a30550f12a007183b06f155c0ec064c4c95a98655a6761844219cc7922be5103cebbe8cddeb01a002fdfa74e15a8e803b851b31787d00ff6e6611f32e94432fc556bce51edd56f7a07a889b375db8ca4087664ea78a889115fdc05e8a9ef1879595a1316330f4a1fc'; + builder.from(serialized); + const json = builder.tx.toJson(); + should.equal(json._type, ETHTransactionType.EIP7702); + should(json.r).not.equal(undefined); + }); +}); diff --git a/modules/abstract-eth/test/unit/eip7702TxData.ts b/modules/abstract-eth/test/unit/eip7702TxData.ts new file mode 100644 index 0000000000..fed09ca5fc --- /dev/null +++ b/modules/abstract-eth/test/unit/eip7702TxData.ts @@ -0,0 +1,202 @@ +import should from 'should'; +import EthereumCommon from '@ethereumjs/common'; +import { bufferToHex } from 'ethereumjs-util'; +import { EthTransactionData } from '../../src/lib/types'; +import { EIP7702TxData, ETHTransactionType } from '../../src/lib/iface'; +import { KeyPair } from '../../src/lib/keyPair'; +import { + SET_CODE_TX_TYPE, + buildSetCodeTransaction, + getSetCodeTransactionSigningHash, +} from '../../src/lib/eip7702'; + +/** + * Reference vector generated with the official @ethereumjs/tx v5 + * `EOACodeEIP7702Transaction` (hardfork prague, eips [7702]) for: + * chainId=1, nonce=0, maxPriorityFeePerGas=1, maxFeePerGas=30, gasLimit=21000, + * destination=address, value=0, data=0x, single authorization + * (chainId=1, address=address, nonce=0) signed by the same key. + */ +const ADDRESS = '0xbe78addef3bf432e660f0944e372954d1d287fe2'; + +const AUTH_R_BYTES = [ + 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, 155, 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, + 212, 148, 74, 48, 85, 15, 18, +]; +const AUTH_S_BYTES = [ + 113, 131, 176, 111, 21, 92, 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, 229, + 16, 60, 235, 190, 188, 221, 235, +]; + +const ENVELOPE_R_BYTES = [ + 47, 223, 167, 78, 21, 168, 232, 3, 184, 81, 179, 23, 135, 208, 15, 246, 230, 97, 31, 50, 233, 68, 50, 252, 85, 107, + 206, 81, 237, 215, 86, 247, +]; +const ENVELOPE_S_BYTES = [ + 122, 136, 155, 55, 93, 184, 202, 64, 135, 102, 78, 167, 138, 136, 145, 21, 253, 192, 94, 138, 158, 241, 135, 149, + 161, 49, 99, 16, 51, 244, 161, 252, +]; + +const EXPECTED_SERIALIZED_BYTES = [ + 4, 248, 193, 1, 128, 1, 30, 130, 82, 8, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, 227, 114, 149, + 77, 29, 40, 127, 226, 128, 128, 192, 248, 93, 248, 91, 1, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, + 227, 114, 149, 77, 29, 40, 127, 226, 193, 128, 128, 160, 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, + 155, 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, 212, 148, 74, 48, 85, 15, 18, 160, 113, 131, 176, 111, 21, + 92, 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, 229, 16, 60, 235, 190, 188, + 221, 235, 1, 160, 47, 223, 167, 78, 21, 168, 232, 3, 184, 81, 179, 23, 135, 208, 15, 246, 230, 97, 31, 50, 233, 68, + 50, 252, 85, 107, 206, 81, 237, 215, 86, 247, 160, 122, 136, 155, 55, 93, 184, 202, 64, 135, 102, 78, 167, 138, 136, + 145, 21, 253, 192, 94, 138, 158, 241, 135, 149, 161, 49, 99, 16, 51, 244, 161, 252, +]; + +const toHex = (bytes: number[]): string => bufferToHex(Buffer.from(bytes)); + +const SERIALIZED = bufferToHex(Buffer.from(EXPECTED_SERIALIZED_BYTES)); + +const common = EthereumCommon.forCustomChain('mainnet', { name: 'mainnet', networkId: 1, chainId: 1 }, 'london'); + +/** An unsigned EIP-7702 tx matching the reference vector (minus envelope signature). */ +function buildEip7702TxData(): EIP7702TxData { + return { + _type: ETHTransactionType.EIP7702, + nonce: 0, + gasLimit: '21000', + value: '0', + data: '0x', + to: ADDRESS, + chainId: '0x1', + maxFeePerGas: '30', + maxPriorityFeePerGas: '1', + authorizationList: [ + { + chainId: 1, + address: ADDRESS, + nonce: 0, + yParity: 0, + r: toHex(AUTH_R_BYTES), + s: toHex(AUTH_S_BYTES), + }, + ], + }; +} + +describe('EthTransactionData EIP-7702', () => { + describe('fromJson / toJson', () => { + it('round-trips an EIP7702TxData', () => { + const tx = EthTransactionData.fromJson(buildEip7702TxData(), common); + const json = tx.toJson(); + + should.equal(json._type, ETHTransactionType.EIP7702); + should.equal(json.nonce, 0); + should.equal(json.gasLimit, '21000'); + should.equal(json.value, '0'); + should.equal(json.chainId, '0x1'); + should.equal((json as EIP7702TxData).maxFeePerGas, '30'); + should.equal((json as EIP7702TxData).maxPriorityFeePerGas, '1'); + should.equal(json.to!.toLowerCase(), ADDRESS.toLowerCase()); + should((json as EIP7702TxData).authorizationList).have.length(1); + should.equal((json as EIP7702TxData).authorizationList[0].address.toLowerCase(), ADDRESS.toLowerCase()); + should.equal((json as EIP7702TxData).authorizationList[0].chainId, 1); + should.equal((json as EIP7702TxData).authorizationList[0].nonce, 0); + }); + + it('exposes the envelope signature after parsing a signed tx', () => { + const tx = EthTransactionData.fromSerialized(SERIALIZED, common); + const json = tx.toJson(); + + should.equal(json._type, ETHTransactionType.EIP7702); + should.equal(json.v, '0x01'); + should.equal(json.r!.toLowerCase(), toHex(ENVELOPE_R_BYTES).toLowerCase()); + should.equal(json.s!.toLowerCase(), toHex(ENVELOPE_S_BYTES).toLowerCase()); + }); + }); + + describe('fromSerialized', () => { + it('parses the known-good 0x04 hex', () => { + const tx = EthTransactionData.fromSerialized(SERIALIZED, common); + const json = tx.toJson(); + + should.equal(json.nonce, 0); + should.equal(json.gasLimit, '21000'); + should.equal(json.to!.toLowerCase(), ADDRESS.toLowerCase()); + should((json as EIP7702TxData).authorizationList).have.length(1); + should.equal((json as EIP7702TxData).authorizationList[0].address.toLowerCase(), ADDRESS.toLowerCase()); + }); + }); + + describe('getSignablePayload', () => { + it('returns the 32-byte envelope digest', () => { + const tx = EthTransactionData.fromSerialized(SERIALIZED, common); + const payload = tx.getSignablePayload(); + should.equal(payload.length, 32); + + const expected = getSetCodeTransactionSigningHash({ + chainId: 1, + nonce: 0, + maxPriorityFeePerGas: 1, + maxFeePerGas: 30, + gasLimit: 21000, + destination: ADDRESS, + value: 0, + data: '0x', + authorizationList: [ + { + chainId: 1, + address: ADDRESS, + nonce: 0, + yParity: 0, + r: toHex(AUTH_R_BYTES), + s: toHex(AUTH_S_BYTES), + }, + ], + }); + should.deepEqual(Buffer.from(payload), Buffer.from(expected)); + }); + + it('is available for an unsigned tx built from JSON', () => { + const tx = EthTransactionData.fromJson(buildEip7702TxData(), common); + should.equal(tx.getSignablePayload().length, 32); + }); + }); + + describe('toSerialized', () => { + it('produces byte-exact output matching the @ethereumjs reference vector', () => { + const tx = EthTransactionData.fromSerialized(SERIALIZED, common); + should.equal(tx.toSerialized().toLowerCase(), SERIALIZED.toLowerCase()); + }); + + it('serializes a fromJson tx byte-for-byte with the reference vector', () => { + const txData: EIP7702TxData = { + ...buildEip7702TxData(), + v: '1', + r: toHex(ENVELOPE_R_BYTES), + s: toHex(ENVELOPE_S_BYTES), + }; + const tx = EthTransactionData.fromJson(txData, common); + should.equal(tx.toSerialized().toLowerCase(), SERIALIZED.toLowerCase()); + }); + + it('emits the 0x04 type byte', () => { + const tx = EthTransactionData.fromJson(buildEip7702TxData(), common); + should(tx.toSerialized().toLowerCase()).startWith(`0x${SET_CODE_TX_TYPE.toString(16).padStart(2, '0')}`); + }); + }); + + describe('sign', () => { + it('produces an envelope signature over the signing hash', () => { + const keyPair = new KeyPair({ + prv: 'c87509a1c067bbde78beb793e6fa76530b6382a4c0241e5e4a9ec0a0f44dc0d3', + }); + const tx = EthTransactionData.fromJson(buildEip7702TxData(), common); + const payload = tx.getSignablePayload(); + tx.sign(keyPair); + + const json = tx.toJson(); + should.notEqual(json.r, '0x'); + should.notEqual(json.s, '0x'); + should(tx.toSerialized().toLowerCase()).startWith('0x04'); + + // re-deriving the same digest after signing must match the pre-sign payload + should.deepEqual(tx.getSignablePayload(), payload); + }); + }); +}); \ No newline at end of file From 5e5f1bb1e8bc4ec75f81e9260ba740c3b2680693 Mon Sep 17 00:00:00 2001 From: prithvishet2503 Date: Thu, 17 Sep 2026 01:11:53 +0530 Subject: [PATCH 2/6] feat(sdk-core): wire eip7702 wallet intent for set code txs --- modules/sdk-core/src/bitgo/utils/mpcUtils.ts | 20 ++++ .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 49 +++++++++ modules/sdk-core/src/bitgo/wallet/iWallet.ts | 27 +++++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 15 +++ .../test/unit/bitgo/utils/mpcUtils.eip7702.ts | 104 ++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 modules/sdk-core/test/unit/bitgo/utils/mpcUtils.eip7702.ts diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index 98bad4da2e..945924575d 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -226,6 +226,7 @@ export abstract class MpcUtils { 'unwrap-native', 'wrapApprove', 'wrap', + 'eip7702', ].includes(params.intentType) ) { assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`); @@ -369,6 +370,25 @@ export abstract class MpcUtils { feeToken: params.feeToken, }; } + case 'eip7702': { + assert(params.eip7702Params, `'eip7702Params' is required for ${params.intentType} intent`); + const { eip7702Params } = params; + return { + ...baseIntent, + implementationAddress: eip7702Params.implementationAddress, + chainId: eip7702Params.chainId, + nonce: `${eip7702Params.nonce}`, + authorizationList: eip7702Params.authorizationList, + maxPriorityFeePerGas: eip7702Params.envelope.maxPriorityFeePerGas, + maxFeePerGas: eip7702Params.envelope.maxFeePerGas, + gasLimit: eip7702Params.envelope.gasLimit, + destination: eip7702Params.envelope.destination, + value: eip7702Params.envelope.value, + data: eip7702Params.envelope.data, + feeOptions: params.feeOptions, + feeToken: params.feeToken, + }; + } default: throw new Error(`Unsupported intent type ${params.intentType}`); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index f2bc902732..6a3395e37a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -305,6 +305,43 @@ export interface WrapIntentParams { amount: string; } +/** A pre-signed authorization entry for an EIP-7702 set code transaction. */ +export interface Eip7702Authorization { + chainId: number | bigint | string; + /** 20-byte address that will hold the delegation (0x-prefixed hex). */ + address: string; + nonce: number | bigint | string; + yParity: 0 | 1; + /** secp256k1 signature `r` (0x-prefixed hex). */ + r: string; + /** secp256k1 signature `s` (0x-prefixed hex). */ + s: string; +} + +/** + * EIP-7702 set code intent parameters (input container for `eip7702Params`). + * The per-authorization signatures are pre-computed on the MPC message-signing + * path; the txRequest carries the resulting authorizationList. + */ +export interface Eip7702IntentParams { + /** The implementation contract address the EOA delegates to (0x-prefixed hex). */ + implementationAddress: string; + chainId: number | bigint | string; + nonce: number | bigint | string; + authorizationList: Eip7702Authorization[]; + /** Outer transaction envelope (EIP-1559 semantics). */ + envelope: { + maxPriorityFeePerGas: number | bigint | string; + maxFeePerGas: number | bigint | string; + gasLimit: number | bigint | string; + /** Destination of the transaction; EIP-7702 requires a non-null destination. */ + destination: string; + value: number | bigint | string; + /** Hex-encoded calldata (0x-prefixed or not). */ + data: string; + }; +} + export interface IntentOptionsForMessage extends IntentOptionsBase { messageRaw: string; messageEncoded?: string; @@ -385,6 +422,8 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase defiParams?: DefiIntentParams; /** ERC-7984 wrap / wrapApprove fields flattened onto the WP intent. */ wrapParams?: WrapIntentParams; + /** EIP-7702 set code intent parameters (eip7702 intent). */ + eip7702Params?: Eip7702IntentParams; /** Canton party ID of the end investor to onboard (cantonEndInvestorOnboardingOffer intent). */ endInvestorPartyId?: string; /** Reason for rejecting the onboarding offer (cantonEndInvestorOnboardingReject intent). */ @@ -503,6 +542,16 @@ export interface PopulatedIntent extends PopulatedIntentBase, DefiIntentFields { clientOnboarder?: string; /** Optional ISO 8601 expiration timestamp (cantonParticipantOnboardingRequest intent). */ expirationIso?: string; + // EIP-7702 set code intent fields + implementationAddress?: string; + chainId?: number | bigint | string; + authorizationList?: Eip7702Authorization[]; + maxPriorityFeePerGas?: number | bigint | string; + maxFeePerGas?: number | bigint | string; + gasLimit?: number | bigint | string; + destination?: string; + value?: number | bigint | string; + data?: string; } export type TxRequestState = diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 435599da12..f5c1bc9707 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -309,6 +309,33 @@ export interface PrebuildTransactionOptions { tokenName: string; amount: string; }; + /** + * EIP-7702 set code parameters (`type: 'eip7702'`). The per-authorization + * signatures are pre-computed on the MPC message-signing path; the txRequest + * carries the resulting authorizationList. + */ + eip7702Params?: { + /** The implementation contract address the EOA delegates to (0x-prefixed hex). */ + implementationAddress: string; + chainId: number | bigint | string; + nonce: number | bigint | string; + authorizationList: { + chainId: number | bigint | string; + address: string; + nonce: number | bigint | string; + yParity: 0 | 1; + r: string; + s: string; + }[]; + envelope: { + maxPriorityFeePerGas: number | bigint | string; + maxFeePerGas: number | bigint | string; + gasLimit: number | bigint | string; + destination: string; + value: number | bigint | string; + data: string; + }; + }; /** * Parameters for executing DAML commands on Canton. */ diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 02f0ab5e31..27f5705815 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -4571,6 +4571,21 @@ export class Wallet implements IWallet { params.preview ); break; + case 'eip7702': + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'eip7702', + sequenceId: params.sequenceId, + comment: params.comment, + eip7702Params: params.eip7702Params, + feeOptions, + feeToken: params.feeToken, + }, + apiVersion, + params.preview + ); + break; case 'wrapApprove': case 'wrap': txRequest = await this.tssUtils!.prebuildTxWithIntent( diff --git a/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.eip7702.ts b/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.eip7702.ts new file mode 100644 index 0000000000..f5d7985cca --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.eip7702.ts @@ -0,0 +1,104 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { IBaseCoin, KeychainsTriplet } from '../../../../src/bitgo/baseCoin'; +import { BitGoBase } from '../../../../src/bitgo/bitgoBase'; +import { MpcUtils } from '../../../../src/bitgo/utils/mpcUtils'; +import { Eip7702IntentParams } from '../../../../src/bitgo/utils/tss/baseTypes'; +import { RequestTracer } from '../../../../src/bitgo/utils/util'; + +class TestMpcUtils extends MpcUtils { + createKeychains(): Promise { + return Promise.reject(new Error('unused')); + } +} + +describe('populateIntent eip7702', function () { + const reqId = new RequestTracer(); + let mpcUtils: TestMpcUtils; + let coin: IBaseCoin; + + beforeEach(function () { + const mockBitgo = { getEnv: sinon.stub().returns('test') } as unknown as BitGoBase; + coin = { + getChain: () => 'hteth', + getFamily: () => 'eth', + isEVM: () => true, + supportsTss: () => true, + } as unknown as IBaseCoin; + mpcUtils = new TestMpcUtils(mockBitgo, coin); + }); + + afterEach(function () { + sinon.restore(); + }); + + const eip7702Params: Eip7702IntentParams = { + implementationAddress: '0x0000000000000000000000000000000000000001', + chainId: 1, + nonce: 0, + authorizationList: [ + { + chainId: 1, + address: '0x0000000000000000000000000000000000000001', + nonce: 0, + yParity: 0, + r: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + s: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + ], + envelope: { + maxPriorityFeePerGas: 2000000000, + maxFeePerGas: 3000000000, + gasLimit: 21000, + destination: '0x0000000000000000000000000000000000000002', + value: 0, + data: '0x', + }, + }; + + it('flattens eip7702Params onto eip7702 intent', function () { + const intent = mpcUtils.populateIntent(coin, { + reqId, + intentType: 'eip7702', + eip7702Params, + }); + + assert.strictEqual(intent.intentType, 'eip7702'); + assert.strictEqual(intent.implementationAddress, eip7702Params.implementationAddress); + assert.strictEqual(intent.chainId, eip7702Params.chainId); + assert.strictEqual(intent.nonce, '0'); + assert.deepStrictEqual(intent.authorizationList, eip7702Params.authorizationList); + assert.strictEqual(intent.maxPriorityFeePerGas, eip7702Params.envelope.maxPriorityFeePerGas); + assert.strictEqual(intent.maxFeePerGas, eip7702Params.envelope.maxFeePerGas); + assert.strictEqual(intent.gasLimit, eip7702Params.envelope.gasLimit); + assert.strictEqual(intent.destination, eip7702Params.envelope.destination); + assert.strictEqual(intent.value, eip7702Params.envelope.value); + assert.strictEqual(intent.data, eip7702Params.envelope.data); + assert.strictEqual(intent.recipients, undefined); + }); + + it('carries feeOptions and feeToken through', function () { + const feeOptions = { maxFeePerGas: 3000000000, maxPriorityFeePerGas: 2000000000 }; + const intent = mpcUtils.populateIntent(coin, { + reqId, + intentType: 'eip7702', + eip7702Params, + feeOptions, + feeToken: 'hteth:cusdt', + }); + + assert.deepStrictEqual(intent.feeOptions, feeOptions); + assert.strictEqual(intent.feeToken, 'hteth:cusdt'); + }); + + it('requires eip7702Params', function () { + assert.throws( + () => + mpcUtils.populateIntent(coin, { + reqId, + intentType: 'eip7702', + }), + /eip7702Params/ + ); + }); +}); From 4e765c68fee020012b3a17b029b45be4a302d6f5 Mon Sep 17 00:00:00 2001 From: prithvishet2503 Date: Thu, 17 Sep 2026 01:28:07 +0530 Subject: [PATCH 3/6] feat(abstract-eth): add EIP-7702 batch send and gas-tank sponsorship calldata --- modules/abstract-eth/src/lib/eip7702.ts | 86 +++++++++++++++++++++++ modules/abstract-eth/test/unit/eip7702.ts | 60 ++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/modules/abstract-eth/src/lib/eip7702.ts b/modules/abstract-eth/src/lib/eip7702.ts index 2c84bf5b33..a7b6836daf 100644 --- a/modules/abstract-eth/src/lib/eip7702.ts +++ b/modules/abstract-eth/src/lib/eip7702.ts @@ -27,6 +27,7 @@ import { RLP } from '@ethereumjs/rlp'; import BN from 'bn.js'; import assert from 'assert'; +import { ethers } from 'ethers'; import { addHexPrefix, bufferToHex, keccak256, setLengthLeft, toBuffer } from 'ethereumjs-util'; /** EIP-7702 set code transaction type (`0x04`). */ @@ -258,3 +259,88 @@ export function parseSetCodeTransaction(serialized: string): SignedSetCodeTransa s: bufferToHex(decoded[12]), }; } + +// --------------------------------------------------------------------------- +// Batch sends and gas-tank sponsorship (delegated execution) +// +// After an EOA delegates to the EIP7702Delegate implementation, a transaction +// with `destination = ` executes the delegated code in the EOA's context. +// A batch send to N recipients is one such transaction whose calldata calls +// `executeBatch`/`batchSend` (gas paid by the EOA) or `sponsoredExecuteBatch` +// (gas paid by an allowlisted gas tank). These helpers produce that calldata. +// --------------------------------------------------------------------------- + +/** A single recipient of a batched native-value send. */ +export interface SetCodeRecipient { + /** Recipient address (0x-prefixed hex). */ + to: string; + /** Native value to send, as a decimal or hex string. */ + amount: string; +} + +/** A single call in a delegated batch execution. */ +export interface SetCodeCall { + /** Call target (0x-prefixed hex). */ + to: string; + /** Native value to send with the call, as a decimal or hex string. */ + value: string; + /** Hex-encoded calldata (0x-prefixed or not). */ + data: string; +} + +const abiCoder = new ethers.utils.AbiCoder(); + +const executeBatchSelector = ethers.utils.id('executeBatch((address,uint256,bytes)[])').slice(0, 10); +const sponsoredExecuteBatchSelector = ethers.utils.id('sponsoredExecuteBatch((address,uint256,bytes)[])').slice(0, 10); +const batchSendSelector = ethers.utils.id('batchSend((address,uint256)[])').slice(0, 10); + +/** + * Encode calldata for `executeBatch((address,uint256,bytes)[])` on the + * delegated implementation. Gas is paid by the delegating EOA. + */ +export function encodeSetCodeExecuteBatch(calls: SetCodeCall[]): string { + return executeBatchSelector + abiCoder.encode(['tuple(address,uint256,bytes)[]'], [calls.map((c) => [c.to, c.value, c.data])]).slice(2); +} + +/** + * Encode calldata for `sponsoredExecuteBatch((address,uint256,bytes)[])` on + * the delegated implementation. Gas is paid by the allowlisted gas tank that + * sends the transaction. + */ +export function encodeSetCodeSponsoredExecuteBatch(calls: SetCodeCall[]): string { + return sponsoredExecuteBatchSelector + abiCoder.encode(['tuple(address,uint256,bytes)[]'], [calls.map((c) => [c.to, c.value, c.data])]).slice(2); +} + +/** + * Encode calldata for `batchSend((address,uint256)[])` on the delegated + * implementation — a native-value transfer to many recipients in one + * transaction, gas paid by the delegating EOA. + */ +export function encodeSetCodeBatchSend(recipients: SetCodeRecipient[]): string { + return batchSendSelector + abiCoder.encode(['tuple(address,uint256)[]'], [recipients.map((r) => [r.to, r.amount])]).slice(2); +} + +/** + * Build the outer transaction fields for a batched send executed through the + * delegated EOA. + * + * @param delegatedEoa - the delegating EOA (the transaction `destination`). + * @param recipients - the batch recipients. + * @param sponsored - if true, encode `sponsoredExecuteBatch` (a gas tank pays + * gas); otherwise encode `executeBatch` (the EOA pays). + * @returns `{ destination, value, data }` to place in the set-code transaction + * params. `value` is always `0` — each recipient's amount is carried inside + * the encoded batch and funded from the EOA's balance. + */ +export function buildSetCodeBatchTxParams( + delegatedEoa: string, + recipients: SetCodeRecipient[], + sponsored: boolean +): { destination: string; value: string; data: string } { + const calls: SetCodeCall[] = recipients.map((r) => ({ to: r.to, value: r.amount, data: '0x' })); + return { + destination: delegatedEoa, + value: '0', + data: sponsored ? encodeSetCodeSponsoredExecuteBatch(calls) : encodeSetCodeExecuteBatch(calls), + }; +} diff --git a/modules/abstract-eth/test/unit/eip7702.ts b/modules/abstract-eth/test/unit/eip7702.ts index 7e0da4820c..861deb44b9 100644 --- a/modules/abstract-eth/test/unit/eip7702.ts +++ b/modules/abstract-eth/test/unit/eip7702.ts @@ -1,10 +1,15 @@ import should from 'should'; +import { ethers } from 'ethers'; import { bufferToHex } from 'ethereumjs-util'; import { computeSetCodeAuthorizationDigest, buildSetCodeTransaction, parseSetCodeTransaction, getSetCodeTransactionSigningHash, + encodeSetCodeExecuteBatch, + encodeSetCodeSponsoredExecuteBatch, + encodeSetCodeBatchSend, + buildSetCodeBatchTxParams, SET_CODE_TX_TYPE, DELEGATION_PREFIX, } from '../../src/lib/eip7702'; @@ -145,4 +150,59 @@ describe('EIP-7702', () => { hash.length.should.equal(32); }); }); + + describe('batch sends and gas-tank sponsorship', () => { + const abiCoder = new ethers.utils.AbiCoder(); + const recipientA = { to: ADDRESS, amount: '1000000000000000' }; + const recipientB = { to: '0x1111111111111111111111111111111111111111', amount: '2000000000000000' }; + + it('encodeSetCodeExecuteBatch round-trips calls', () => { + const data = encodeSetCodeExecuteBatch([ + { to: recipientA.to, value: recipientA.amount, data: '0x' }, + { to: recipientB.to, value: recipientB.amount, data: '0x' }, + ]); + const decoded = abiCoder.decode(['tuple(address,uint256,bytes)[]'], '0x' + data.slice(10))[0]; + decoded.should.have.length(2); + decoded[0][0].toLowerCase().should.equal(recipientA.to.toLowerCase()); + decoded[0][1].toString().should.equal(recipientA.amount); + decoded[1][0].toLowerCase().should.equal(recipientB.to.toLowerCase()); + decoded[1][1].toString().should.equal(recipientB.amount); + }); + + it('encodeSetCodeSponsoredExecuteBatch round-trips calls', () => { + const data = encodeSetCodeSponsoredExecuteBatch([ + { to: recipientA.to, value: recipientA.amount, data: '0x' }, + ]); + const decoded = abiCoder.decode(['tuple(address,uint256,bytes)[]'], '0x' + data.slice(10))[0]; + decoded.should.have.length(1); + decoded[0][0].toLowerCase().should.equal(recipientA.to.toLowerCase()); + decoded[0][1].toString().should.equal(recipientA.amount); + }); + + it('encodeSetCodeBatchSend round-trips recipients', () => { + const data = encodeSetCodeBatchSend([recipientA, recipientB]); + const decoded = abiCoder.decode(['tuple(address,uint256)[]'], '0x' + data.slice(10))[0]; + decoded.should.have.length(2); + decoded[0][0].toLowerCase().should.equal(recipientA.to.toLowerCase()); + decoded[0][1].toString().should.equal(recipientA.amount); + decoded[1][1].toString().should.equal(recipientB.amount); + }); + + it('buildSetCodeBatchTxParams targets the EOA with zero value', () => { + const params = buildSetCodeBatchTxParams(ADDRESS, [recipientA, recipientB], false); + params.destination.toLowerCase().should.equal(ADDRESS.toLowerCase()); + params.value.should.equal('0'); + const decoded = abiCoder.decode(['tuple(address,uint256,bytes)[]'], '0x' + params.data.slice(10))[0]; + decoded.should.have.length(2); + }); + + it('buildSetCodeBatchTxParams sponsored flag targets sponsoredExecuteBatch', () => { + const self = buildSetCodeBatchTxParams(ADDRESS, [recipientA], false); + const sponsored = buildSetCodeBatchTxParams(ADDRESS, [recipientA], true); + sponsored.destination.toLowerCase().should.equal(ADDRESS.toLowerCase()); + sponsored.value.should.equal('0'); + // selectors differ: executeBatch vs sponsoredExecuteBatch + self.data.slice(0, 10).should.not.equal(sponsored.data.slice(0, 10)); + }); + }); }); From 22af61a465784112fb872f143f04c62af9e7229e Mon Sep 17 00:00:00 2001 From: prithvishet2503 Date: Thu, 17 Sep 2026 02:33:15 +0530 Subject: [PATCH 4/6] feat(sdk-core): carry recipients + sponsorAddress in eip7702 intent --- modules/sdk-core/src/bitgo/utils/mpcUtils.ts | 2 ++ modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts | 6 ++++++ modules/sdk-core/src/bitgo/wallet/iWallet.ts | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index 945924575d..bd25e75297 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -385,6 +385,8 @@ export abstract class MpcUtils { destination: eip7702Params.envelope.destination, value: eip7702Params.envelope.value, data: eip7702Params.envelope.data, + recipients: eip7702Params.recipients, + sponsorAddress: eip7702Params.sponsorAddress, feeOptions: params.feeOptions, feeToken: params.feeToken, }; diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 6a3395e37a..b16682d369 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -340,6 +340,12 @@ export interface Eip7702IntentParams { /** Hex-encoded calldata (0x-prefixed or not). */ data: string; }; + /** Batch recipients for a delegated batch send (multiple recipients, one tx). */ + recipients?: Array<{ to: string; amount: string }>; + /** Gas-tank address that pays gas for the batch (the enterprise fee address + * by default). When set, the envelope is signed by the gas tank, not the + * wallet MPC key. */ + sponsorAddress?: string; } export interface IntentOptionsForMessage extends IntentOptionsBase { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index f5c1bc9707..89e830651b 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -335,6 +335,12 @@ export interface PrebuildTransactionOptions { value: number | bigint | string; data: string; }; + /** Batch recipients for a delegated batch send (multiple recipients, one tx). */ + recipients?: Array<{ to: string; amount: string }>; + /** Gas-tank address that pays gas for the batch (the enterprise fee address + * by default). When set, the envelope is signed by the gas tank, not the + * wallet MPC key. */ + sponsorAddress?: string; }; /** * Parameters for executing DAML commands on Canton. From 5ed92337938caf7875504403e74bf03c3047a0a8 Mon Sep 17 00:00:00 2001 From: prithvishet2503 Date: Thu, 17 Sep 2026 08:31:38 +0530 Subject: [PATCH 5/6] feat(abstract-eth): standalone EIP-7702 on-chain harness (delegate/batch/sponsored/verify) --- .../abstract-eth/scripts/eip7702/README.md | 50 ++++++++ modules/abstract-eth/scripts/eip7702/batch.ts | 78 ++++++++++++ .../abstract-eth/scripts/eip7702/common.ts | 103 ++++++++++++++++ .../abstract-eth/scripts/eip7702/delegate.ts | 111 ++++++++++++++++++ .../abstract-eth/scripts/eip7702/sponsored.ts | 94 +++++++++++++++ .../abstract-eth/scripts/eip7702/verify.ts | 49 ++++++++ 6 files changed, 485 insertions(+) create mode 100644 modules/abstract-eth/scripts/eip7702/README.md create mode 100644 modules/abstract-eth/scripts/eip7702/batch.ts create mode 100644 modules/abstract-eth/scripts/eip7702/common.ts create mode 100644 modules/abstract-eth/scripts/eip7702/delegate.ts create mode 100644 modules/abstract-eth/scripts/eip7702/sponsored.ts create mode 100644 modules/abstract-eth/scripts/eip7702/verify.ts diff --git a/modules/abstract-eth/scripts/eip7702/README.md b/modules/abstract-eth/scripts/eip7702/README.md new file mode 100644 index 0000000000..2ed84c5dd2 --- /dev/null +++ b/modules/abstract-eth/scripts/eip7702/README.md @@ -0,0 +1,50 @@ +# EIP-7702 On-Chain Harness (Hoodi) + +Standalone scripts to sign and broadcast EIP-7702 transactions on Hoodi and +verify them on-chain. Uses the SDK's `lib/eip7702.ts` encoding core. + +## Target +- **Network:** Hoodi (Pectra testnet), chain **560048** +- **Delegate implementation (deployed):** `0xd9b435f8aaa0d2c6d789eceeba8a1c3a5cf8089a` +- **Public RPC:** `https://rpc.hoodi.ethpandaops.io` (override with `HOODI_RPC_URL`) + +## Prereqs — funded accounts +The harness needs Hoodi ETH on two accounts (testnet faucets: https://hoodi-faucet.pk910.de): +- **EOA** (delegating wallet): pays gas for delegation, `addSponsor`, and self-paid batch. Key in `EIP7702_EOA_PRIVATE_KEY` or `~/eip7702-eoa.env`. +- **Gas tank** (enterprise fee-address stand-in): pays gas for the sponsored batch. Key in `EIP7702_GAS_TANK_PRIVATE_KEY` or `~/eip7702-gastank.env`. + +## Run (from `modules/abstract-eth`) +```bash +# 0) optional: analyze an existing tx +node -r ts-node/register/transpile-only scripts/eip7702/verify.ts +TX_HASH=0x... node -r ts-node/register/transpile-only scripts/eip7702/verify.ts + +# 1) delegation: 0x04 set-code tx (EOA delegates to the implementation) +node -r ts-node/register/transpile-only scripts/eip7702/delegate.ts + +# 2) self-paid batch: EOA sends to N recipients in one tx (EOA pays gas) +node -r ts-node/register/transpile-only scripts/eip7702/batch.ts + +# 3) gas-tank sponsored batch: gas tank pays gas, values from EOA balance +node -r ts-node/register/transpile-only scripts/eip7702/sponsored.ts +``` + +Optional env: `RECIPIENT_A`, `RECIPIENT_B`, `AMOUNT_A`, `AMOUNT_B`. + +## What each script does + analysis output +- **delegate.ts** — computes the authorization digest + `keccak256(0x05 ‖ rlp([chainId, address, nonce]))`, signs it with the EOA key, + computes the envelope hash `keccak256(0x04 ‖ rlp(fields))`, signs it, builds the + serialized 0x04 tx, broadcasts, prints the receipt (gas, cost, block), then + verifies on-chain that the EOA code == `0xef0100 ‖ implementation`. +- **batch.ts** — encodes `executeBatch((address,uint256,bytes)[])` for N recipients, + sends a type-2 tx from the EOA to itself (destination = EOA ⇒ runs delegate + code), prints receipt + balance deltas. +- **sponsored.ts** — EOA calls `addSponsor(gasTank)` on itself, then the **gas + tank** sends `sponsoredExecuteBatch(...)` (gas tank pays gas; values from EOA + balance). Prints receipts + balances. +- **verify.ts** — read-only: delegation indicator, implementation code length, + balances, and optional per-tx analysis via `TX_HASH`. + +## Explorer +All tx hashes link to https://hoodi.beaconcha.in/tx/. diff --git a/modules/abstract-eth/scripts/eip7702/batch.ts b/modules/abstract-eth/scripts/eip7702/batch.ts new file mode 100644 index 0000000000..d7850f0a01 --- /dev/null +++ b/modules/abstract-eth/scripts/eip7702/batch.ts @@ -0,0 +1,78 @@ +/** + * Phase 2 — self-paid batch send through the delegated EOA. + * + * After delegation (run delegate.ts first), the EOA sends ONE EIP-1559 + * transaction to itself whose calldata calls `executeBatch` on the delegated + * implementation. Multiple recipients are paid in a single tx; the EOA pays + * the gas. + * + * Recipients: RECIPIENT_A / RECIPIENT_B env vars (defaults to two fresh + * random addresses, printed for on-chain lookup). + * + * Run: node -r ts-node/register/transpile-only scripts/eip7702/batch.ts + */ + +import { ethers } from 'ethers'; +import { encodeSetCodeExecuteBatch } from '../../src/lib/eip7702'; +import { + CHAIN_ID, + loadKey, + getFeeData, + getNonce, + waitAndAnalyze, + verifyDelegation, + printBalances, +} from './common'; + +async function main(): Promise { + const eoa = loadKey('EIP7702_EOA_PRIVATE_KEY', 'eip7702-eoa.env'); + const recipientA = process.env.RECIPIENT_A || ethers.Wallet.createRandom().address; + const recipientB = process.env.RECIPIENT_B || ethers.Wallet.createRandom().address; + + console.log('=== self-paid batch send (executeBatch) ==='); + console.log('EOA:', eoa.address); + console.log('recipientA:', recipientA); + console.log('recipientB:', recipientB); + + const delegated = await verifyDelegation(eoa.address); + if (!delegated) { + throw new Error('EOA is not delegated — run delegate.ts first'); + } + + const amountA = ethers.utils.parseEther(process.env.AMOUNT_A || '0.001'); + const amountB = ethers.utils.parseEther(process.env.AMOUNT_B || '0.002'); + const calls = [ + { to: recipientA, value: amountA.toString(), data: '0x' }, + { to: recipientB, value: amountB.toString(), data: '0x' }, + ]; + const data = encodeSetCodeExecuteBatch(calls); + console.log(`\nbatch calldata (${data.length / 2 - 1} bytes): ${data.slice(0, 100)}...`); + console.log(`amountA: ${ethers.utils.formatEther(amountA)} ETH`); + console.log(`amountB: ${ethers.utils.formatEther(amountB)} ETH`); + + const nonce = await getNonce(eoa.address); + const { maxPriorityFeePerGas, maxFeePerGas } = await getFeeData(); + const gasLimit = ethers.BigNumber.from('200000'); + console.log(`\nnonce: ${nonce}`); + console.log(`gasLimit: ${gasLimit.toString()}`); + + const tx = await eoa.sendTransaction({ + to: eoa.address, // destination = the delegated EOA -> runs delegate code + data, + value: 0, + nonce, + gasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + chainId: CHAIN_ID, + type: 2, + }); + await waitAndAnalyze(tx.hash, 'self-paid batch tx'); + + await printBalances({ EOA: eoa.address, recipientA, recipientB }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/modules/abstract-eth/scripts/eip7702/common.ts b/modules/abstract-eth/scripts/eip7702/common.ts new file mode 100644 index 0000000000..d50841512f --- /dev/null +++ b/modules/abstract-eth/scripts/eip7702/common.ts @@ -0,0 +1,103 @@ +/** + * Shared config + helpers for the standalone EIP-7702 on-chain harness. + * + * Target: Hoodi (Pectra testnet, chain 560048). The delegate implementation + * was deployed at IMPLEMENTATION (see DEPLOYMENT.md in the BGMS repo). + * + * Keys are loaded from env vars or files in the home dir: + * EIP7702_EOA_PRIVATE_KEY -> ~/eip7702-eoa.env + * EIP7702_GAS_TANK_PRIVATE_KEY -> ~/eip7702-gastank.env + */ + +import { ethers } from 'ethers'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export const HOODI_RPC = process.env.HOODI_RPC_URL || 'https://rpc.hoodi.ethpandaops.io'; +export const CHAIN_ID = 560048; +/** Deployed EIP7702Delegate on Hoodi (see BGMS DEPLOYMENT.md). */ +export const IMPLEMENTATION = '0xd9b435f8aaa0d2c6d789eceeba8a1c3a5cf8089a'; +export const EXPLORER = 'https://hoodi.beaconcha.in'; + +export const provider = new ethers.providers.JsonRpcProvider(HOODI_RPC); + +/** Load a wallet from env var or a `KEY=0x...` file in the home dir. */ +export function loadKey(envName: string, fileName: string): ethers.Wallet { + const fromEnv = process.env[envName]; + if (fromEnv) { + return new ethers.Wallet(fromEnv, provider); + } + const p = path.join(os.homedir(), fileName); + if (fs.existsSync(p)) { + const line = fs.readFileSync(p, 'utf8').trim(); + const key = line.split('=')[1]; + if (key) { + return new ethers.Wallet(key, provider); + } + } + throw new Error(`No private key: set env ${envName} or create ~/${fileName} with KEY=0x...`); +} + +/** Current EIP-1559 fee data from the RPC (with sane fallbacks). */ +export async function getFeeData(): Promise<{ maxPriorityFeePerGas: ethers.BigNumber; maxFeePerGas: ethers.BigNumber }> { + const feeData = await provider.getFeeData(); + const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.utils.parseUnits('1', 'gwei'); + const maxFeePerGas = feeData.maxFeePerGas ?? ethers.utils.parseUnits('30', 'gwei'); + return { maxPriorityFeePerGas, maxFeePerGas }; +} + +export async function getNonce(address: string): Promise { + return provider.getTransactionCount(address, 'pending'); +} + +/** Sign a 32-byte digest with a wallet key; returns {r, s, yParity}. */ +export function signDigest( + wallet: ethers.Wallet, + digest: Buffer +): { r: string; s: string; yParity: number } { + const sig = new ethers.utils.SigningKey(wallet.privateKey).signDigest('0x' + digest.toString('hex')); + return { r: sig.r, s: sig.s, yParity: sig.recoveryParam }; +} + +/** Wait for a tx, print exhaustive receipt analysis, return the receipt. */ +export async function waitAndAnalyze(txHash: string, label: string): Promise { + console.log(`\n=== ${label} ===`); + console.log(`tx hash: ${txHash}`); + console.log(`explorer: ${EXPLORER}/tx/${txHash}`); + const receipt = await provider.waitForTransaction(txHash, 1, 180000); + console.log(`status: ${receipt.status === 1 ? 'SUCCESS' : 'FAILED'}`); + console.log(`block: ${receipt.blockNumber}`); + console.log(`gas used: ${receipt.gasUsed.toString()}`); + console.log(`effective gas price: ${ethers.utils.formatUnits(receipt.effectiveGasPrice, 'gwei')} gwei`); + console.log(`total gas cost: ${ethers.utils.formatEther(receipt.gasUsed.mul(receipt.effectiveGasPrice))} ETH`); + return receipt; +} + +/** + * Verify an EOA is delegated: its code must be exactly the EIP-7702 + * delegation indicator `0xef0100 || implementation_address`. + */ +export async function verifyDelegation(eoa: string): Promise { + const code = await provider.getCode(eoa); + const expected = '0xef0100' + IMPLEMENTATION.slice(2).toLowerCase(); + const delegated = code.toLowerCase() === expected; + console.log(`\n=== delegation check: ${eoa} ===`); + console.log(`code: ${code}`); + console.log(`expected indicator: ${expected}`); + console.log(`delegated: ${delegated}`); + return delegated; +} + +export async function printBalances(labels: Record): Promise { + console.log('\n=== balances ==='); + for (const [label, addr] of Object.entries(labels)) { + const bal = await provider.getBalance(addr); + console.log(`${label}: ${ethers.utils.formatEther(bal)} ETH (${bal.toString()} wei)`); + } +} + +/** ABI-encode `addSponsor(address)` for the delegate contract. */ +export function encodeAddSponsor(sponsor: string): string { + return new ethers.utils.Interface(['function addSponsor(address)']).encodeFunctionData('addSponsor', [sponsor]); +} diff --git a/modules/abstract-eth/scripts/eip7702/delegate.ts b/modules/abstract-eth/scripts/eip7702/delegate.ts new file mode 100644 index 0000000000..20748c0f2d --- /dev/null +++ b/modules/abstract-eth/scripts/eip7702/delegate.ts @@ -0,0 +1,111 @@ +/** + * Phase 1 — EIP-7702 delegation (0x04 set-code tx). + * + * Signs the authorization digest with the EOA key, builds the 0x04 + * transaction (authorization -> IMPLEMENTATION), signs the envelope, and + * broadcasts on Hoodi. Then verifies on-chain that the EOA's code is the + * delegation indicator `0xef0100 || implementation`. + * + * Run: node -r ts-node/register/transpile-only scripts/eip7702/delegate.ts + */ + +import { ethers } from 'ethers'; +import { + computeSetCodeAuthorizationDigest, + buildSetCodeTransaction, + getSetCodeTransactionSigningHash, +} from '../../src/lib/eip7702'; +import { + CHAIN_ID, + IMPLEMENTATION, + provider, + loadKey, + getFeeData, + getNonce, + signDigest, + waitAndAnalyze, + verifyDelegation, + printBalances, +} from './common'; + +async function main(): Promise { + const eoa = loadKey('EIP7702_EOA_PRIVATE_KEY', 'eip7702-eoa.env'); + + console.log('=== EIP-7702 delegation (0x04 set-code) ==='); + console.log('EOA (delegating):', eoa.address); + console.log('implementation:', IMPLEMENTATION); + console.log('chainId:', CHAIN_ID); + await printBalances({ EOA: eoa.address }); + + const nonce = await getNonce(eoa.address); + const { maxPriorityFeePerGas, maxFeePerGas } = await getFeeData(); + const gasLimit = ethers.BigNumber.from('100000'); + console.log(`\nnonce: ${nonce}`); + console.log(`maxFeePerGas: ${ethers.utils.formatUnits(maxFeePerGas, 'gwei')} gwei`); + console.log(`maxPriorityFeePerGas: ${ethers.utils.formatUnits(maxPriorityFeePerGas, 'gwei')} gwei`); + console.log(`gasLimit: ${gasLimit.toString()}`); + + // 1) Authorization digest: keccak256(0x05 || rlp([chainId, address, nonce])) + const digest = computeSetCodeAuthorizationDigest({ chainId: CHAIN_ID, address: eoa.address, nonce }); + console.log(`\nauthorization digest: 0x${digest.toString('hex')}`); + const authSig = signDigest(eoa, digest); + console.log(`authorization sig: r=${authSig.r}`); + console.log(` s=${authSig.s}`); + console.log(` yParity=${authSig.yParity}`); + + const auth = { + chainId: CHAIN_ID, + address: IMPLEMENTATION, + nonce, + yParity: authSig.yParity as 0 | 1, + r: authSig.r, + s: authSig.s, + }; + + // 2) Envelope hash: keccak256(0x04 || rlp()) + const envelopeHash = getSetCodeTransactionSigningHash({ + chainId: CHAIN_ID, + nonce, + maxPriorityFeePerGas: maxPriorityFeePerGas.toString(), + maxFeePerGas: maxFeePerGas.toString(), + gasLimit: gasLimit.toString(), + destination: eoa.address, + value: '0', + data: '0x', + authorizationList: [auth], + }); + console.log(`\nenvelope hash: 0x${envelopeHash.toString('hex')}`); + const envSig = signDigest(eoa, envelopeHash); + console.log(`envelope sig: r=${envSig.r}`); + console.log(` s=${envSig.s}`); + console.log(` yParity=${envSig.yParity}`); + + // 3) Build the serialized 0x04 tx and broadcast + const serialized = buildSetCodeTransaction({ + chainId: CHAIN_ID, + nonce, + maxPriorityFeePerGas: maxPriorityFeePerGas.toString(), + maxFeePerGas: maxFeePerGas.toString(), + gasLimit: gasLimit.toString(), + destination: eoa.address, + value: '0', + data: '0x', + authorizationList: [auth], + yParity: envSig.yParity as 0 | 1, + r: envSig.r, + s: envSig.s, + }); + const hex = '0x' + serialized.toString('hex'); + console.log(`\nserialized 0x04 tx (${serialized.length} bytes): ${hex.slice(0, 100)}...`); + + const tx = await provider.sendTransaction(hex); + await waitAndAnalyze(tx.hash, 'delegation tx'); + + await verifyDelegation(eoa.address); + await printBalances({ EOA: eoa.address }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/modules/abstract-eth/scripts/eip7702/sponsored.ts b/modules/abstract-eth/scripts/eip7702/sponsored.ts new file mode 100644 index 0000000000..9f6f870ed7 --- /dev/null +++ b/modules/abstract-eth/scripts/eip7702/sponsored.ts @@ -0,0 +1,94 @@ +/** + * Phase 3 — gas-tank sponsored batch send. + * + * 1. The EOA authorizes the gas tank as a sponsor on the delegated contract + * (owner-only `addSponsor`, called on the EOA itself). + * 2. The gas tank sends ONE EIP-1559 transaction to the EOA whose calldata + * calls `sponsoredExecuteBatch` — the GAS TANK pays the gas, while the + * values are funded from the EOA's balance. + * + * Gas tank key: EIP7702_GAS_TANK_PRIVATE_KEY or ~/eip7702-gastank.env. + * + * Run: node -r ts-node/register/transpile-only scripts/eip7702/sponsored.ts + */ + +import { ethers } from 'ethers'; +import { encodeSetCodeSponsoredExecuteBatch } from '../../src/lib/eip7702'; +import { + CHAIN_ID, + loadKey, + getFeeData, + getNonce, + waitAndAnalyze, + verifyDelegation, + printBalances, + encodeAddSponsor, +} from './common'; + +async function main(): Promise { + const eoa = loadKey('EIP7702_EOA_PRIVATE_KEY', 'eip7702-eoa.env'); + const gasTank = loadKey('EIP7702_GAS_TANK_PRIVATE_KEY', 'eip7702-gastank.env'); + const recipientA = process.env.RECIPIENT_A || ethers.Wallet.createRandom().address; + const recipientB = process.env.RECIPIENT_B || ethers.Wallet.createRandom().address; + + console.log('=== gas-tank sponsored batch (sponsoredExecuteBatch) ==='); + console.log('EOA:', eoa.address); + console.log('gas tank:', gasTank.address); + console.log('recipientA:', recipientA); + console.log('recipientB:', recipientB); + + const delegated = await verifyDelegation(eoa.address); + if (!delegated) { + throw new Error('EOA is not delegated — run delegate.ts first'); + } + + // 1) EOA authorizes the gas tank as a sponsor (owner-only addSponsor). + const addSponsorData = encodeAddSponsor(gasTank.address); + const nonce1 = await getNonce(eoa.address); + const { maxPriorityFeePerGas, maxFeePerGas } = await getFeeData(); + const tx1 = await eoa.sendTransaction({ + to: eoa.address, + data: addSponsorData, + value: 0, + nonce: nonce1, + gasLimit: ethers.BigNumber.from('100000'), + maxFeePerGas, + maxPriorityFeePerGas, + chainId: CHAIN_ID, + type: 2, + }); + await waitAndAnalyze(tx1.hash, 'addSponsor(gasTank)'); + + // 2) Gas tank sends the sponsored batch — gas tank pays gas. + const amountA = ethers.utils.parseEther(process.env.AMOUNT_A || '0.001'); + const amountB = ethers.utils.parseEther(process.env.AMOUNT_B || '0.002'); + const calls = [ + { to: recipientA, value: amountA.toString(), data: '0x' }, + { to: recipientB, value: amountB.toString(), data: '0x' }, + ]; + const sponsoredData = encodeSetCodeSponsoredExecuteBatch(calls); + console.log(`\nsponsored calldata (${sponsoredData.length / 2 - 1} bytes): ${sponsoredData.slice(0, 100)}...`); + console.log(`amountA: ${ethers.utils.formatEther(amountA)} ETH`); + console.log(`amountB: ${ethers.utils.formatEther(amountB)} ETH`); + + const gasTankNonce = await getNonce(gasTank.address); + const tx2 = await gasTank.sendTransaction({ + to: eoa.address, // destination = the delegated EOA -> runs delegate code + data: sponsoredData, + value: 0, + nonce: gasTankNonce, + gasLimit: ethers.BigNumber.from('200000'), + maxFeePerGas, + maxPriorityFeePerGas, + chainId: CHAIN_ID, + type: 2, + }); + await waitAndAnalyze(tx2.hash, 'sponsored batch tx (gas tank pays gas)'); + + await printBalances({ EOA: eoa.address, gasTank: gasTank.address, recipientA, recipientB }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/modules/abstract-eth/scripts/eip7702/verify.ts b/modules/abstract-eth/scripts/eip7702/verify.ts new file mode 100644 index 0000000000..6b637d72be --- /dev/null +++ b/modules/abstract-eth/scripts/eip7702/verify.ts @@ -0,0 +1,49 @@ +/** + * On-chain verification — read-only analysis of the EIP-7702 state. + * + * Checks: EOA delegation indicator, implementation code, balances, and the + * last delegation/batch tx receipts if TX_HASH is provided. + * + * Run: node -r ts-node/register/transpile-only scripts/eip7702/verify.ts + * TX_HASH=0x... node -r ts-node/register/transpile-only scripts/eip7702/verify.ts + */ + +import { ethers } from 'ethers'; +import { + IMPLEMENTATION, + EXPLORER, + provider, + loadKey, + verifyDelegation, + printBalances, + waitAndAnalyze, +} from './common'; + +async function main(): Promise { + const eoa = loadKey('EIP7702_EOA_PRIVATE_KEY', 'eip7702-eoa.env'); + + console.log('=== EIP-7702 on-chain verification ==='); + console.log('EOA:', eoa.address); + console.log('implementation:', IMPLEMENTATION); + console.log('chainId:', 560048); + + await verifyDelegation(eoa.address); + + const implCode = await provider.getCode(IMPLEMENTATION); + console.log(`\nimplementation code length: ${(implCode.length - 2) / 2} bytes (deployed: ${implCode !== '0x'})`); + + await printBalances({ EOA: eoa.address }); + + const txHash = process.env.TX_HASH; + if (txHash) { + const receipt = await waitAndAnalyze(txHash, 'requested tx'); + console.log(`\nfull receipt: ${JSON.stringify(receipt, null, 2)}`); + } else { + console.log(`\n(optional) pass TX_HASH=0x... to analyze a specific tx: ${EXPLORER}/tx/...`); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 48135214708e499f6528e41493fd90b61eba7e95 Mon Sep 17 00:00:00 2001 From: prithvishet2503 Date: Thu, 17 Sep 2026 08:49:59 +0530 Subject: [PATCH 6/6] fix(abstract-eth): scalar auth nonce + digest over implementation; verified on-chain on Hoodi --- .../abstract-eth/scripts/eip7702/README.md | 19 +++++++++++++++++ .../abstract-eth/scripts/eip7702/common.ts | 21 +++++++++++++++++++ .../abstract-eth/scripts/eip7702/delegate.ts | 21 +++++++++++++------ modules/abstract-eth/src/lib/eip7702.ts | 12 +++++------ modules/abstract-eth/test/unit/eip7702.ts | 16 +++++++------- .../abstract-eth/test/unit/eip7702TxData.ts | 16 +++++++------- 6 files changed, 77 insertions(+), 28 deletions(-) diff --git a/modules/abstract-eth/scripts/eip7702/README.md b/modules/abstract-eth/scripts/eip7702/README.md index 2ed84c5dd2..f5fecbbf26 100644 --- a/modules/abstract-eth/scripts/eip7702/README.md +++ b/modules/abstract-eth/scripts/eip7702/README.md @@ -48,3 +48,22 @@ Optional env: `RECIPIENT_A`, `RECIPIENT_B`, `AMOUNT_A`, `AMOUNT_B`. ## Explorer All tx hashes link to https://hoodi.beaconcha.in/tx/. + +## Verified on-chain findings (Hoodi, 2026-09-17) +Three critical wire-format facts were confirmed by broadcasting real txs: +1. **Authorization nonce is a SCALAR** in the tuple RLP (geth rejects a nested + list: `expected input string or byte for uint64 ... AuthList[0].Nonce`). + `@ethereumjs/tx` v5's JSON path encodes it as a nested list — do NOT copy that. +2. **The authorization digest is over the IMPLEMENTATION address** (the + tuple's `address` field), not the delegating EOA. The node recomputes + `keccak256(0x05 || rlp([chainId, address, nonce]))` from the tuple, so + signing over the EOA recovers a different authority and the tuple is skipped. +3. **Self-delegation auth nonce = tx nonce + 1**: the spec increments the + SENDER's nonce before processing the authorization list, so the authority's + nonce at check time is tx nonce + 1. + +Live on-chain proof (Hoodi): +- EOA `0xDad010e3A43A850B86bDaA61FA2cD580b600b3A0` delegated to + `0xd9b435f8aaa0d2c6d789eceeba8a1c3a5cf8089a` (code = `0xef0100...`). +- Self-paid batch: 2 recipients in 1 tx, EOA paid gas. +- Gas-tank sponsored batch: gas tank paid gas, values from EOA balance. diff --git a/modules/abstract-eth/scripts/eip7702/common.ts b/modules/abstract-eth/scripts/eip7702/common.ts index d50841512f..3f1840a130 100644 --- a/modules/abstract-eth/scripts/eip7702/common.ts +++ b/modules/abstract-eth/scripts/eip7702/common.ts @@ -101,3 +101,24 @@ export async function printBalances(labels: Record): Promise { + const res = await fetch(HOODI_RPC, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_sendRawTransaction', params: [rawHex] }), + }); + const json = (await res.json()) as { result?: string; error?: { message?: string } }; + if (json.error) { + throw new Error(`eth_sendRawTransaction failed: ${json.error.message ?? JSON.stringify(json.error)}`); + } + if (!json.result) { + throw new Error('eth_sendRawTransaction returned no result'); + } + return json.result; +} diff --git a/modules/abstract-eth/scripts/eip7702/delegate.ts b/modules/abstract-eth/scripts/eip7702/delegate.ts index 20748c0f2d..fa253961e7 100644 --- a/modules/abstract-eth/scripts/eip7702/delegate.ts +++ b/modules/abstract-eth/scripts/eip7702/delegate.ts @@ -23,6 +23,7 @@ import { getFeeData, getNonce, signDigest, + sendRawTransaction, waitAndAnalyze, verifyDelegation, printBalances, @@ -38,15 +39,22 @@ async function main(): Promise { await printBalances({ EOA: eoa.address }); const nonce = await getNonce(eoa.address); + // Self-delegation: the spec increments the SENDER's nonce before processing + // the authorization list, so the authorization nonce must be tx nonce + 1 + // (verified on-chain on Hoodi). + const authNonce = nonce + 1; const { maxPriorityFeePerGas, maxFeePerGas } = await getFeeData(); const gasLimit = ethers.BigNumber.from('100000'); - console.log(`\nnonce: ${nonce}`); + console.log(`\nnonce: ${nonce} (authorization nonce: ${authNonce})`); console.log(`maxFeePerGas: ${ethers.utils.formatUnits(maxFeePerGas, 'gwei')} gwei`); console.log(`maxPriorityFeePerGas: ${ethers.utils.formatUnits(maxPriorityFeePerGas, 'gwei')} gwei`); console.log(`gasLimit: ${gasLimit.toString()}`); // 1) Authorization digest: keccak256(0x05 || rlp([chainId, address, nonce])) - const digest = computeSetCodeAuthorizationDigest({ chainId: CHAIN_ID, address: eoa.address, nonce }); + // The `address` is the TUPLE's address field = the implementation address + // (the node recomputes the digest from the tuple, so signing over the EOA + // would recover a different authority and the tuple would be skipped). + const digest = computeSetCodeAuthorizationDigest({ chainId: CHAIN_ID, address: IMPLEMENTATION, nonce: authNonce }); console.log(`\nauthorization digest: 0x${digest.toString('hex')}`); const authSig = signDigest(eoa, digest); console.log(`authorization sig: r=${authSig.r}`); @@ -56,7 +64,7 @@ async function main(): Promise { const auth = { chainId: CHAIN_ID, address: IMPLEMENTATION, - nonce, + nonce: authNonce, yParity: authSig.yParity as 0 | 1, r: authSig.r, s: authSig.s, @@ -95,11 +103,12 @@ async function main(): Promise { r: envSig.r, s: envSig.s, }); - const hex = '0x' + serialized.toString('hex'); +const hex = '0x' + serialized.toString('hex'); console.log(`\nserialized 0x04 tx (${serialized.length} bytes): ${hex.slice(0, 100)}...`); - const tx = await provider.sendTransaction(hex); - await waitAndAnalyze(tx.hash, 'delegation tx'); + // Broadcast via raw eth_sendRawTransaction (ethers v5 cannot parse type 4). + const txHash = await sendRawTransaction(hex); + await waitAndAnalyze(txHash, 'delegation tx'); await verifyDelegation(eoa.address); await printBalances({ EOA: eoa.address }); diff --git a/modules/abstract-eth/src/lib/eip7702.ts b/modules/abstract-eth/src/lib/eip7702.ts index a7b6836daf..fb1e5147d6 100644 --- a/modules/abstract-eth/src/lib/eip7702.ts +++ b/modules/abstract-eth/src/lib/eip7702.ts @@ -136,9 +136,9 @@ export function buildSetCodeTransaction(params: SignedSetCodeTransaction): Buffe const authorizationList = params.authorizationList.map((auth) => [ toMinimalBuffer(auth.chainId), toBytes(auth.address, 20), - // Per the reference implementations, the authorization `nonce` is RLP - // encoded as a nested list. - [toMinimalBuffer(auth.nonce)], + // The authorization `nonce` is a scalar uint64 on the wire (geth rejects a + // nested list here: "expected input string or byte for uint64"). + toMinimalBuffer(auth.nonce), toMinimalBuffer(auth.yParity), toBytes(auth.r, 32), toBytes(auth.s, 32), @@ -172,7 +172,7 @@ export function getSetCodeTransactionSigningHash(params: SetCodeTransactionParam const authorizationList = params.authorizationList.map((auth) => [ toMinimalBuffer(auth.chainId), toBytes(auth.address, 20), - [toMinimalBuffer(auth.nonce)], + toMinimalBuffer(auth.nonce), toMinimalBuffer(auth.yParity), toBytes(auth.r, 32), toBytes(auth.s, 32), @@ -244,11 +244,11 @@ export function parseSetCodeTransaction(serialized: string): SignedSetCodeTransa return { address: bufferToHex(addr), storageKeys: storageKeys.map(bufferToHex) }; }), authorizationList: rawAuthList.map((auth) => { - const [c, a, n, yp, rr, ss] = auth as unknown as [Buffer, Buffer, Buffer[], Buffer, Buffer, Buffer]; + const [c, a, n, yp, rr, ss] = auth as unknown as [Buffer, Buffer, Buffer | Buffer[], Buffer, Buffer, Buffer]; return { chainId: bufferToHex(c), address: bufferToHex(a), - nonce: bufferToHex(Buffer.concat(n)), + nonce: bufferToHex(Array.isArray(n) ? Buffer.concat(n) : Buffer.from(n)), yParity: yp.length === 0 ? 0 : (yp[0] as 0 | 1), r: bufferToHex(rr), s: bufferToHex(ss), diff --git a/modules/abstract-eth/test/unit/eip7702.ts b/modules/abstract-eth/test/unit/eip7702.ts index 861deb44b9..f629225d0f 100644 --- a/modules/abstract-eth/test/unit/eip7702.ts +++ b/modules/abstract-eth/test/unit/eip7702.ts @@ -53,14 +53,14 @@ const ENVELOPE_S_BYTES = [ // Fully signed set code transaction serialized by @ethereumjs/tx v5. const EXPECTED_SERIALIZED_BYTES = [ - 4, 248, 193, 1, 128, 1, 30, 130, 82, 8, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, 227, 114, 149, - 77, 29, 40, 127, 226, 128, 128, 192, 248, 93, 248, 91, 1, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, - 227, 114, 149, 77, 29, 40, 127, 226, 193, 128, 128, 160, 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, - 155, 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, 212, 148, 74, 48, 85, 15, 18, 160, 113, 131, 176, 111, 21, - 92, 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, 229, 16, 60, 235, 190, 188, - 221, 235, 1, 160, 47, 223, 167, 78, 21, 168, 232, 3, 184, 81, 179, 23, 135, 208, 15, 246, 230, 97, 31, 50, 233, 68, - 50, 252, 85, 107, 206, 81, 237, 215, 86, 247, 160, 122, 136, 155, 55, 93, 184, 202, 64, 135, 102, 78, 167, 138, 136, - 145, 21, 253, 192, 94, 138, 158, 241, 135, 149, 161, 49, 99, 16, 51, 244, 161, 252, + 4, 248, 192, 1, 128, 1, 30, 130, 82, 8, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, 227, 114, 149, + 77, 29, 40, 127, 226, 128, 128, 192, 248, 92, 248, 90, 1, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, + 227, 114, 149, 77, 29, 40, 127, 226, 128, 128, 160, 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, 155, + 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, 212, 148, 74, 48, 85, 15, 18, 160, 113, 131, 176, 111, 21, 92, + 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, 229, 16, 60, 235, 190, 188, 221, + 235, 1, 160, 47, 223, 167, 78, 21, 168, 232, 3, 184, 81, 179, 23, 135, 208, 15, 246, 230, 97, 31, 50, 233, 68, 50, + 252, 85, 107, 206, 81, 237, 215, 86, 247, 160, 122, 136, 155, 55, 93, 184, 202, 64, 135, 102, 78, 167, 138, 136, 145, + 21, 253, 192, 94, 138, 158, 241, 135, 149, 161, 49, 99, 16, 51, 244, 161, 252, ]; const toHex = (bytes: number[]): string => bufferToHex(Buffer.from(bytes)); diff --git a/modules/abstract-eth/test/unit/eip7702TxData.ts b/modules/abstract-eth/test/unit/eip7702TxData.ts index fed09ca5fc..ae998eb74e 100644 --- a/modules/abstract-eth/test/unit/eip7702TxData.ts +++ b/modules/abstract-eth/test/unit/eip7702TxData.ts @@ -38,14 +38,14 @@ const ENVELOPE_S_BYTES = [ ]; const EXPECTED_SERIALIZED_BYTES = [ - 4, 248, 193, 1, 128, 1, 30, 130, 82, 8, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, 227, 114, 149, - 77, 29, 40, 127, 226, 128, 128, 192, 248, 93, 248, 91, 1, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, - 227, 114, 149, 77, 29, 40, 127, 226, 193, 128, 128, 160, 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, - 155, 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, 212, 148, 74, 48, 85, 15, 18, 160, 113, 131, 176, 111, 21, - 92, 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, 229, 16, 60, 235, 190, 188, - 221, 235, 1, 160, 47, 223, 167, 78, 21, 168, 232, 3, 184, 81, 179, 23, 135, 208, 15, 246, 230, 97, 31, 50, 233, 68, - 50, 252, 85, 107, 206, 81, 237, 215, 86, 247, 160, 122, 136, 155, 55, 93, 184, 202, 64, 135, 102, 78, 167, 138, 136, - 145, 21, 253, 192, 94, 138, 158, 241, 135, 149, 161, 49, 99, 16, 51, 244, 161, 252, + 4, 248, 192, 1, 128, 1, 30, 130, 82, 8, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, 227, 114, 149, + 77, 29, 40, 127, 226, 128, 128, 192, 248, 92, 248, 90, 1, 148, 190, 120, 173, 222, 243, 191, 67, 46, 102, 15, 9, 68, + 227, 114, 149, 77, 29, 40, 127, 226, 128, 128, 160, 25, 229, 118, 20, 137, 169, 86, 96, 43, 249, 69, 39, 108, 155, + 222, 155, 34, 67, 100, 144, 182, 168, 92, 18, 23, 212, 148, 74, 48, 85, 15, 18, 160, 113, 131, 176, 111, 21, 92, + 14, 192, 100, 196, 201, 90, 152, 101, 90, 103, 97, 132, 66, 18, 156, 199, 146, 43, 229, 16, 60, 235, 190, 188, 221, + 235, 1, 160, 47, 223, 167, 78, 21, 168, 232, 3, 184, 81, 179, 23, 135, 208, 15, 246, 230, 97, 31, 50, 233, 68, 50, + 252, 85, 107, 206, 81, 237, 215, 86, 247, 160, 122, 136, 155, 55, 93, 184, 202, 64, 135, 102, 78, 167, 138, 136, 145, + 21, 253, 192, 94, 138, 158, 241, 135, 149, 161, 49, 99, 16, 51, 244, 161, 252, ]; const toHex = (bytes: number[]): string => bufferToHex(Buffer.from(bytes));