From 688302305596184c8da932b7202bb290fd5ded27 Mon Sep 17 00:00:00 2001 From: Jonathan Gimeno Date: Wed, 28 Jan 2026 10:21:16 +0100 Subject: [PATCH 01/14] client impl part 1 --- clients/evnode-viem.ts | 622 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 622 insertions(+) create mode 100644 clients/evnode-viem.ts diff --git a/clients/evnode-viem.ts b/clients/evnode-viem.ts new file mode 100644 index 0000000..2991ff2 --- /dev/null +++ b/clients/evnode-viem.ts @@ -0,0 +1,622 @@ +import { + type AccessList, + type Address, + type Client, + type Hex, + type Signature, + bytesToHex, + concat, + defineTransaction, + fromRlp, + hexToBigInt, + hexToBytes, + hexToSignature, + isHex, + keccak256, + recoverAddress, + toHex, + toRlp, +} from 'viem'; + +export const EVNODE_TX_TYPE = 0x76; +export const EVNODE_EXECUTOR_DOMAIN = 0x76; +export const EVNODE_SPONSOR_DOMAIN = 0x78; + +const EMPTY_BYTES = '0x' as const; +const TX_TYPE_HEX = toHex(EVNODE_TX_TYPE, { size: 1 }); +const EXECUTOR_DOMAIN_HEX = toHex(EVNODE_EXECUTOR_DOMAIN, { size: 1 }); +const SPONSOR_DOMAIN_HEX = toHex(EVNODE_SPONSOR_DOMAIN, { size: 1 }); + +type RlpValue = Hex | RlpValue[]; + +export interface Call { + to: Address | null; + value: bigint; + data: Hex; +} + +export interface EvNodeTransaction { + chainId: bigint; + nonce: bigint; + maxPriorityFeePerGas: bigint; + maxFeePerGas: bigint; + gasLimit: bigint; + calls: Call[]; + accessList: AccessList; + feePayerSignature?: Signature; +} + +export interface EvNodeSignedTransaction { + transaction: EvNodeTransaction; + executorSignature: Signature; +} + +export interface SponsorableIntent { + tx: EvNodeTransaction; + executorSignature: Signature; + executorAddress: Address; +} + +export interface HashSigner { + address: Address; + // Must sign the raw 32-byte hash without EIP-191 prefixing. + signHash: (hash: Hex) => Promise; +} + +export interface EvnodeClientOptions { + client: Client; + executor?: HashSigner; + sponsor?: HashSigner; +} + +export interface EvnodeSendArgs { + calls: Call[]; + executor?: HashSigner; + chainId?: bigint; + nonce?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + gasLimit?: bigint; + accessList?: AccessList; +} + +export interface EvnodeIntentArgs { + calls: Call[]; + executor?: HashSigner; + chainId?: bigint; + nonce?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + gasLimit?: bigint; + accessList?: AccessList; +} + +export interface EvnodeSponsorArgs { + intent: SponsorableIntent; + sponsor?: HashSigner; +} + +export function encodeSignedTransaction(signedTx: EvNodeSignedTransaction): Hex { + const fields = buildPayloadFields(signedTx.transaction, true); + const execSig = normalizeSignature(signedTx.executorSignature); + const envelope = toRlp([ + ...fields, + execSig.v, + hexToBigInt(execSig.r), + hexToBigInt(execSig.s), + ]); + return concat([TX_TYPE_HEX, envelope]); +} + +export function decodeEvNodeTransaction(encoded: Hex): EvNodeSignedTransaction { + const bytes = hexToBytes(encoded); + if (bytes.length === 0 || bytes[0] !== EVNODE_TX_TYPE) { + throw new Error('Invalid EvNode transaction type'); + } + + const decoded = fromRlp(bytesToHex(bytes.slice(1))) as RlpValue; + if (!Array.isArray(decoded)) { + throw new Error('Invalid EvNode transaction payload'); + } + + if (decoded.length !== 11) { + throw new Error('Invalid EvNode transaction length'); + } + + const [ + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + calls, + accessList, + feePayerSignature, + v, + r, + s, + ] = decoded; + + const transaction: EvNodeTransaction = { + chainId: hexToBigIntSafe(chainId), + nonce: hexToBigIntSafe(nonce), + maxPriorityFeePerGas: hexToBigIntSafe(maxPriorityFeePerGas), + maxFeePerGas: hexToBigIntSafe(maxFeePerGas), + gasLimit: hexToBigIntSafe(gasLimit), + calls: decodeCalls(calls), + accessList: decodeAccessList(accessList), + feePayerSignature: decodeSignature(feePayerSignature), + }; + + const executorSignature = signatureFromParts(v, r, s); + return { transaction, executorSignature }; +} + +export function computeExecutorSigningHash(tx: EvNodeTransaction): Hex { + const payload = toRlp(buildPayloadFields(tx, false)); + return keccak256(concat([EXECUTOR_DOMAIN_HEX, payload])); +} + +export function computeSponsorSigningHash( + tx: EvNodeTransaction, + executorAddress: Address, +): Hex { + const payload = toRlp(buildPayloadFields(tx, false)); + return keccak256(concat([SPONSOR_DOMAIN_HEX, executorAddress, payload])); +} + +export function computeTxHash(signedTx: EvNodeSignedTransaction): Hex { + return keccak256(encodeSignedTransaction(signedTx)); +} + +export async function recoverExecutor( + signedTx: EvNodeSignedTransaction, +): Promise
{ + const hash = computeExecutorSigningHash(signedTx.transaction); + return recoverAddress({ hash, signature: normalizeSignature(signedTx.executorSignature) }); +} + +export async function recoverSponsor( + tx: EvNodeTransaction, + executorAddress: Address, +): Promise
{ + if (!tx.feePayerSignature) return null; + const hash = computeSponsorSigningHash(tx, executorAddress); + return recoverAddress({ hash, signature: normalizeSignature(tx.feePayerSignature) }); +} + +export async function signAsExecutor( + tx: EvNodeTransaction, + signer: HashSigner, +): Promise { + const hash = computeExecutorSigningHash(tx); + return signer.signHash(hash); +} + +export async function signAsSponsor( + tx: EvNodeTransaction, + executorAddress: Address, + signer: HashSigner, +): Promise { + const hash = computeSponsorSigningHash(tx, executorAddress); + return signer.signHash(hash); +} + +export function estimateIntrinsicGas(calls: Call[]): bigint { + let gas = 21000n; + + for (const call of calls) { + if (call.to === null) gas += 32000n; + + for (const byte of hexToBytes(call.data)) { + gas += byte === 0 ? 4n : 16n; + } + } + + return gas; +} + +export function validateEvNodeTx(tx: EvNodeTransaction): void { + if (tx.calls.length === 0) { + throw new Error('EvNode transaction must include at least one call'); + } + + for (let i = 1; i < tx.calls.length; i += 1) { + if (tx.calls[i].to === null) { + throw new Error('Only the first call may be CREATE'); + } + } +} + +export function evnodeActions(client: Client) { + return { + async sendEvNodeTransaction(args: { + calls: Call[]; + executor: HashSigner; + chainId?: bigint; + nonce?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + gasLimit?: bigint; + accessList?: AccessList; + }): Promise { + const base = await resolveBaseFields(client, args.executor.address, { + chainId: args.chainId, + nonce: args.nonce, + maxFeePerGas: args.maxFeePerGas, + maxPriorityFeePerGas: args.maxPriorityFeePerGas, + gasLimit: args.gasLimit, + accessList: args.accessList, + }, args.calls); + + const tx: EvNodeTransaction = { + ...base, + calls: args.calls, + feePayerSignature: undefined, + }; + + validateEvNodeTx(tx); + + const executorSignature = await signAsExecutor(tx, args.executor); + const signedTx: EvNodeSignedTransaction = { + transaction: tx, + executorSignature, + }; + + const serialized = encodeSignedTransaction(signedTx); + return client.request({ + method: 'eth_sendRawTransaction', + params: [serialized], + }) as Promise; + }, + + async createSponsorableIntent(args: { + calls: Call[]; + executor: HashSigner; + chainId?: bigint; + nonce?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + gasLimit?: bigint; + accessList?: AccessList; + }): Promise { + const base = await resolveBaseFields(client, args.executor.address, { + chainId: args.chainId, + nonce: args.nonce, + maxFeePerGas: args.maxFeePerGas, + maxPriorityFeePerGas: args.maxPriorityFeePerGas, + gasLimit: args.gasLimit, + accessList: args.accessList, + }, args.calls); + + const tx: EvNodeTransaction = { + ...base, + calls: args.calls, + feePayerSignature: undefined, + }; + + validateEvNodeTx(tx); + + const executorSignature = await signAsExecutor(tx, args.executor); + + return { + tx, + executorSignature, + executorAddress: args.executor.address, + }; + }, + + async sponsorIntent(args: { + intent: SponsorableIntent; + sponsor: HashSigner; + }): Promise { + const sponsorSignature = await signAsSponsor( + args.intent.tx, + args.intent.executorAddress, + args.sponsor, + ); + + return { + transaction: { + ...args.intent.tx, + feePayerSignature: sponsorSignature, + }, + executorSignature: args.intent.executorSignature, + }; + }, + + serializeEvNodeTransaction(signedTx: EvNodeSignedTransaction): Hex { + return encodeSignedTransaction(signedTx); + }, + + deserializeEvNodeTransaction(encoded: Hex): EvNodeSignedTransaction { + return decodeEvNodeTransaction(encoded); + }, + }; +} + +export function createEvnodeClient(options: EvnodeClientOptions) { + const actions = evnodeActions(options.client); + let defaultExecutor = options.executor; + let defaultSponsor = options.sponsor; + + const requireExecutor = (executor?: HashSigner) => { + const resolved = executor ?? defaultExecutor; + if (!resolved) throw new Error('Executor signer is required'); + return resolved; + }; + + const requireSponsor = (sponsor?: HashSigner) => { + const resolved = sponsor ?? defaultSponsor; + if (!resolved) throw new Error('Sponsor signer is required'); + return resolved; + }; + + return { + client: options.client, + actions, + setDefaultExecutor(executor: HashSigner) { + defaultExecutor = executor; + }, + setDefaultSponsor(sponsor: HashSigner) { + defaultSponsor = sponsor; + }, + send(args: EvnodeSendArgs): Promise { + return actions.sendEvNodeTransaction({ + ...args, + executor: requireExecutor(args.executor), + }); + }, + createIntent(args: EvnodeIntentArgs): Promise { + return actions.createSponsorableIntent({ + ...args, + executor: requireExecutor(args.executor), + }); + }, + sponsorIntent(args: EvnodeSponsorArgs): Promise { + return actions.sponsorIntent({ + intent: args.intent, + sponsor: requireSponsor(args.sponsor), + }); + }, + async sponsorAndSend(args: EvnodeSponsorArgs): Promise { + const signed = await actions.sponsorIntent({ + intent: args.intent, + sponsor: requireSponsor(args.sponsor), + }); + const serialized = actions.serializeEvNodeTransaction(signed); + return options.client.request({ + method: 'eth_sendRawTransaction', + params: [serialized], + }) as Promise; + }, + serialize: actions.serializeEvNodeTransaction, + deserialize: actions.deserializeEvNodeTransaction, + }; +} + +export const evnodeSerializer = defineTransaction({ + type: 'evnode', + typeId: EVNODE_TX_TYPE, + serialize: (tx) => encodeSignedTransaction(tx as EvNodeSignedTransaction), + deserialize: (bytes) => decodeEvNodeTransaction(bytes as Hex), +}); + +export function hashSignerFromRpcClient( + client: Client, + address: Address, +): HashSigner { + return { + address, + signHash: async (hash) => { + // eth_sign is expected to sign raw bytes (no EIP-191 prefix). + const signature = await client.request({ + method: 'eth_sign', + params: [address, hash], + }); + if (!isHex(signature)) { + throw new Error('eth_sign returned non-hex signature'); + } + return signature; + }, + }; +} + +function buildPayloadFields(tx: EvNodeTransaction, includeSponsorSig: boolean): RlpValue[] { + return [ + tx.chainId, + tx.nonce, + tx.maxPriorityFeePerGas, + tx.maxFeePerGas, + tx.gasLimit, + encodeCalls(tx.calls), + encodeAccessList(tx.accessList), + includeSponsorSig && tx.feePayerSignature + ? encodeSignatureList(tx.feePayerSignature) + : EMPTY_BYTES, + ]; +} + +function encodeCalls(calls: Call[]): RlpValue[] { + return calls.map((call) => [ + call.to ?? EMPTY_BYTES, + call.value, + call.data, + ]); +} + +function decodeCalls(value: RlpValue): Call[] { + if (!Array.isArray(value)) { + throw new Error('Invalid EvNode calls encoding'); + } + + return value.map((call) => { + if (!Array.isArray(call) || call.length !== 3) { + throw new Error('Invalid EvNode call encoding'); + } + + const [to, val, data] = call; + if (!isHex(to) || !isHex(val) || !isHex(data)) { + throw new Error('Invalid EvNode call values'); + } + + return { + to: to === EMPTY_BYTES ? null : (to as Address), + value: hexToBigIntSafe(val), + data, + }; + }); +} + +function encodeAccessList(accessList: AccessList): RlpValue[] { + return accessList.map((item) => [item.address, item.storageKeys]); +} + +function decodeAccessList(value: RlpValue): AccessList { + if (!Array.isArray(value)) { + throw new Error('Invalid access list encoding'); + } + + return value.map((item) => { + if (!Array.isArray(item) || item.length !== 2) { + throw new Error('Invalid access list item encoding'); + } + + const [address, storageKeys] = item; + if (!isHex(address) || !Array.isArray(storageKeys)) { + throw new Error('Invalid access list values'); + } + + return { + address: address as Address, + storageKeys: storageKeys.map((key) => { + if (!isHex(key)) throw new Error('Invalid storage key'); + return key; + }), + }; + }); +} + +function encodeSignatureList(signature: Signature): RlpValue[] { + const normalized = normalizeSignature(signature); + return [ + normalized.v, + hexToBigInt(normalized.r), + hexToBigInt(normalized.s), + ]; +} + +function decodeSignature(value: RlpValue): Signature | undefined { + if (value === EMPTY_BYTES) return undefined; + + if (!Array.isArray(value) || value.length !== 3) { + throw new Error('Invalid sponsor signature encoding'); + } + + const [v, r, s] = value; + return signatureFromParts(v, r, s); +} + +function signatureFromParts(v: RlpValue, r: RlpValue, s: RlpValue): Signature { + if (!isHex(v) || !isHex(r) || !isHex(s)) { + throw new Error('Invalid signature fields'); + } + + const vNumber = Number(hexToBigIntSafe(v)); + if (vNumber !== 0 && vNumber !== 1) { + throw new Error('Invalid signature v value'); + } + + return { + v: vNumber, + r: padTo32Bytes(r), + s: padTo32Bytes(s), + }; +} + +function normalizeSignature(signature: Signature): { v: number; r: Hex; s: Hex } { + const parsed = typeof signature === 'string' ? hexToSignature(signature) : signature; + + const v = Number(parsed.v); + const normalizedV = v === 27 || v === 28 ? v - 27 : v; + if (normalizedV !== 0 && normalizedV !== 1) { + throw new Error('Invalid signature v value'); + } + + return { + v: normalizedV, + r: padTo32Bytes(parsed.r), + s: padTo32Bytes(parsed.s), + }; +} + +function padTo32Bytes(value: Hex): Hex { + return toHex(hexToBigIntSafe(value), { size: 32 }); +} + +function hexToBigIntSafe(value: RlpValue): bigint { + if (!isHex(value)) throw new Error('Invalid hex value'); + return value === EMPTY_BYTES ? 0n : hexToBigInt(value); +} + +async function resolveBaseFields( + client: Client, + address: Address, + overrides: { + chainId?: bigint; + nonce?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + gasLimit?: bigint; + accessList?: AccessList; + }, + calls: Call[], +): Promise> { + const chainId = overrides.chainId ?? (await fetchChainId(client)); + const nonce = overrides.nonce ?? (await fetchNonce(client, address)); + const maxPriorityFeePerGas = + overrides.maxPriorityFeePerGas ?? (await fetchMaxPriorityFee(client)); + const maxFeePerGas = overrides.maxFeePerGas ?? (await fetchGasPrice(client)); + const gasLimit = overrides.gasLimit ?? estimateIntrinsicGas(calls); + const accessList = overrides.accessList ?? []; + + return { + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + accessList, + }; +} + +async function fetchChainId(client: Client): Promise { + const result = await client.request({ method: 'eth_chainId' }); + if (!isHex(result)) throw new Error('eth_chainId returned non-hex'); + return hexToBigIntSafe(result); +} + +async function fetchNonce(client: Client, address: Address): Promise { + const result = await client.request({ + method: 'eth_getTransactionCount', + params: [address, 'pending'], + }); + if (!isHex(result)) throw new Error('eth_getTransactionCount returned non-hex'); + return hexToBigIntSafe(result); +} + +async function fetchMaxPriorityFee(client: Client): Promise { + try { + const result = await client.request({ method: 'eth_maxPriorityFeePerGas' }); + if (!isHex(result)) throw new Error('eth_maxPriorityFeePerGas returned non-hex'); + return hexToBigIntSafe(result); + } catch { + return 0n; + } +} + +async function fetchGasPrice(client: Client): Promise { + const result = await client.request({ method: 'eth_gasPrice' }); + if (!isHex(result)) throw new Error('eth_gasPrice returned non-hex'); + return hexToBigIntSafe(result); +} From 77965a06fa3a19833b3d83c5c75b71131c359bf5 Mon Sep 17 00:00:00 2001 From: Jonathan Gimeno Date: Thu, 29 Jan 2026 10:00:41 +0100 Subject: [PATCH 02/14] add tests for client --- .gitignore | 5 +- clients/evnode-viem.ts | 68 ++- clients/package-lock.json | 775 +++++++++++++++++++++++++++++++ clients/package.json | 16 + clients/test/evnode-basic.ts | 68 +++ clients/test/evnode-flows.ts | 126 +++++ clients/test/evnode-sponsored.ts | 116 +++++ clients/tsconfig.json | 11 + package-lock.json | 220 +++++++++ package.json | 19 + 10 files changed, 1406 insertions(+), 18 deletions(-) create mode 100644 clients/package-lock.json create mode 100644 clients/package.json create mode 100644 clients/test/evnode-basic.ts create mode 100644 clients/test/evnode-flows.ts create mode 100644 clients/test/evnode-sponsored.ts create mode 100644 clients/tsconfig.json create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 22a5da9..b057fee 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ criterion/ *.tmp *.log +# Node dependencies +node_modules/ + # Environment files .env .env.local @@ -57,4 +60,4 @@ flamegraph.svg Thumbs.db # Docker build artifacts -/dist/ \ No newline at end of file +/dist/ diff --git a/clients/evnode-viem.ts b/clients/evnode-viem.ts index 2991ff2..09d880c 100644 --- a/clients/evnode-viem.ts +++ b/clients/evnode-viem.ts @@ -101,9 +101,9 @@ export function encodeSignedTransaction(signedTx: EvNodeSignedTransaction): Hex const execSig = normalizeSignature(signedTx.executorSignature); const envelope = toRlp([ ...fields, - execSig.v, - hexToBigInt(execSig.r), - hexToBigInt(execSig.s), + rlpHexFromBigInt(BigInt(execSig.v)), + rlpHexFromBigInt(hexToBigInt(execSig.r)), + rlpHexFromBigInt(hexToBigInt(execSig.s)), ]); return concat([TX_TYPE_HEX, envelope]); } @@ -161,7 +161,8 @@ export function computeSponsorSigningHash( tx: EvNodeTransaction, executorAddress: Address, ): Hex { - const payload = toRlp(buildPayloadFields(tx, false)); + const payload = encodePayloadFieldsNoList(tx, false); + // Sponsor hash preimage: 0x78 || executor_address (20 bytes) || RLP(field encodings without list header). return keccak256(concat([SPONSOR_DOMAIN_HEX, executorAddress, payload])); } @@ -424,23 +425,32 @@ export function hashSignerFromRpcClient( function buildPayloadFields(tx: EvNodeTransaction, includeSponsorSig: boolean): RlpValue[] { return [ - tx.chainId, - tx.nonce, - tx.maxPriorityFeePerGas, - tx.maxFeePerGas, - tx.gasLimit, + rlpHexFromBigInt(tx.chainId), + rlpHexFromBigInt(tx.nonce), + rlpHexFromBigInt(tx.maxPriorityFeePerGas), + rlpHexFromBigInt(tx.maxFeePerGas), + rlpHexFromBigInt(tx.gasLimit), encodeCalls(tx.calls), encodeAccessList(tx.accessList), includeSponsorSig && tx.feePayerSignature - ? encodeSignatureList(tx.feePayerSignature) + ? encodeSponsorSignature(tx.feePayerSignature) : EMPTY_BYTES, ]; } +function encodePayloadFieldsNoList( + tx: EvNodeTransaction, + includeSponsorSig: boolean, +): Hex { + const fields = buildPayloadFields(tx, includeSponsorSig); + const encodedFields = fields.map((field) => toRlp(field)); + return concat(encodedFields); +} + function encodeCalls(calls: Call[]): RlpValue[] { return calls.map((call) => [ call.to ?? EMPTY_BYTES, - call.value, + rlpHexFromBigInt(call.value), call.data, ]); } @@ -497,19 +507,24 @@ function decodeAccessList(value: RlpValue): AccessList { }); } -function encodeSignatureList(signature: Signature): RlpValue[] { +function encodeSponsorSignature(signature: Signature): RlpValue { + // Encode sponsor signature as 65-byte signature bytes (r || s || v). + // This matches the common Signature encoding used by alloy primitives. + if (typeof signature === 'string') { + return signature; + } const normalized = normalizeSignature(signature); - return [ - normalized.v, - hexToBigInt(normalized.r), - hexToBigInt(normalized.s), - ]; + const vByte = toHex(BigInt(normalized.v), { size: 1 }); + return concat([normalized.r, normalized.s, vByte]); } function decodeSignature(value: RlpValue): Signature | undefined { if (value === EMPTY_BYTES) return undefined; if (!Array.isArray(value) || value.length !== 3) { + if (isHex(value)) { + return signatureFromBytes(value); + } throw new Error('Invalid sponsor signature encoding'); } @@ -517,6 +532,21 @@ function decodeSignature(value: RlpValue): Signature | undefined { return signatureFromParts(v, r, s); } +function signatureFromBytes(value: Hex): Signature { + const bytes = hexToBytes(value); + if (bytes.length !== 65) { + throw new Error('Invalid sponsor signature length'); + } + const r = bytesToHex(bytes.slice(0, 32)); + const s = bytesToHex(bytes.slice(32, 64)); + const vRaw = bytes[64]; + const v = vRaw === 27 || vRaw === 28 ? vRaw - 27 : vRaw; + if (v !== 0 && v !== 1) { + throw new Error('Invalid signature v value'); + } + return { v, r: padTo32Bytes(r), s: padTo32Bytes(s) }; +} + function signatureFromParts(v: RlpValue, r: RlpValue, s: RlpValue): Signature { if (!isHex(v) || !isHex(r) || !isHex(s)) { throw new Error('Invalid signature fields'); @@ -554,6 +584,10 @@ function padTo32Bytes(value: Hex): Hex { return toHex(hexToBigIntSafe(value), { size: 32 }); } +function rlpHexFromBigInt(value: bigint): Hex { + return value === 0n ? EMPTY_BYTES : toHex(value); +} + function hexToBigIntSafe(value: RlpValue): bigint { if (!isHex(value)) throw new Error('Invalid hex value'); return value === EMPTY_BYTES ? 0n : hexToBigInt(value); diff --git a/clients/package-lock.json b/clients/package-lock.json new file mode 100644 index 0000000..6c9960e --- /dev/null +++ b/clients/package-lock.json @@ -0,0 +1,775 @@ +{ + "name": "@evstack/evnode-viem-client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@evstack/evnode-viem-client", + "version": "0.0.0", + "devDependencies": { + "tsx": "^4.19.2", + "viem": "^2.45.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ox": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.11.3.tgz", + "integrity": "sha512-1bWYGk/xZel3xro3l8WGg6eq4YEKlaqvyMtVhfMFpbJzK2F6rj4EDRtqDCWVEJMkzcmEi9uW2QxsqELokOlarw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/viem": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.45.0.tgz", + "integrity": "sha512-iVA9qrAgRdtpWa80lCZ6Jri6XzmLOwwA1wagX2HnKejKeliFLpON0KOdyfqvcy+gUpBVP59LBxP2aKiL3aj8fg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.11.3", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/clients/package.json b/clients/package.json new file mode 100644 index 0000000..c31ccd6 --- /dev/null +++ b/clients/package.json @@ -0,0 +1,16 @@ +{ + "name": "@evstack/evnode-viem-client", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Local tooling and tests for the EvNode Viem client", + "scripts": { + "test:basic": "tsx test/evnode-basic.ts", + "test:flows": "tsx test/evnode-flows.ts", + "test:sponsored": "tsx test/evnode-sponsored.ts" + }, + "devDependencies": { + "tsx": "^4.19.2", + "viem": "^2.45.0" + } +} diff --git a/clients/test/evnode-basic.ts b/clients/test/evnode-basic.ts new file mode 100644 index 0000000..99ea0b0 --- /dev/null +++ b/clients/test/evnode-basic.ts @@ -0,0 +1,68 @@ +import { createClient, http } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { createEvnodeClient } from '../evnode-viem.ts'; + +const RPC_URL = 'http://localhost:8545'; +const PRIVATE_KEY = + (process.env.PRIVATE_KEY?.startsWith('0x') + ? process.env.PRIVATE_KEY + : process.env.PRIVATE_KEY + ? `0x${process.env.PRIVATE_KEY}` + : undefined) ?? + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; +const TO_ADDRESS = process.env.TO_ADDRESS as `0x${string}` | undefined; + +const client = createClient({ + transport: http(RPC_URL), +}); + +const account = privateKeyToAccount(PRIVATE_KEY); + +const executor = { + address: account.address, + signHash: async (hash: `0x${string}`) => sign({ hash, privateKey: PRIVATE_KEY }), +}; + +const evnode = createEvnodeClient({ + client, + executor, +}); + +async function main() { + const to = TO_ADDRESS ?? account.address; + const hash = await evnode.send({ + calls: [ + { + to, + value: 0n, + data: '0x', + }, + ], + }); + + console.log('submitted tx:', hash); + + const receipt = await pollReceipt(hash); + if (receipt) { + console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); + } else { + console.log('receipt not found yet'); + } +} + +async function pollReceipt(hash: `0x${string}`) { + for (let i = 0; i < 12; i += 1) { + const receipt = await client.request({ + method: 'eth_getTransactionReceipt', + params: [hash], + }); + if (receipt) return receipt as { status: `0x${string}`; blockNumber: `0x${string}` }; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return null; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/clients/test/evnode-flows.ts b/clients/test/evnode-flows.ts new file mode 100644 index 0000000..7fcb82a --- /dev/null +++ b/clients/test/evnode-flows.ts @@ -0,0 +1,126 @@ +import { createClient, hexToBigInt, http, type Hex, toHex } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { randomBytes } from 'crypto'; +import { createEvnodeClient, type Call } from '../evnode-viem.ts'; + +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const EXECUTOR_KEY = normalizeKey( + process.env.EXECUTOR_PRIVATE_KEY ?? process.env.PRIVATE_KEY, +); +const SPONSOR_KEY = normalizeKey(process.env.SPONSOR_PRIVATE_KEY ?? ''); +const TO_ADDRESS = process.env.TO_ADDRESS as `0x${string}` | undefined; +const AUTO_SPONSOR = + process.env.AUTO_SPONSOR === '1' || process.env.AUTO_SPONSOR === 'true'; +const FUND_SPONSOR = + process.env.FUND_SPONSOR === '1' || process.env.FUND_SPONSOR === 'true'; +const SPONSOR_MIN_BALANCE_WEI = BigInt(process.env.SPONSOR_MIN_BALANCE_WEI ?? '0'); +const SPONSOR_FUND_WEI = BigInt(process.env.SPONSOR_FUND_WEI ?? '10000000000000000'); + +if (!EXECUTOR_KEY) { + throw new Error('Missing EXECUTOR_PRIVATE_KEY/PRIVATE_KEY'); +} + +const client = createClient({ transport: http(RPC_URL) }); + +const executorAccount = privateKeyToAccount(EXECUTOR_KEY); +const autoSponsorKey = AUTO_SPONSOR ? toHex(randomBytes(32)) : undefined; +const sponsorKey = (SPONSOR_KEY || autoSponsorKey || EXECUTOR_KEY) as `0x${string}`; +const sponsorAccount = privateKeyToAccount(sponsorKey); + +const executor = { + address: executorAccount.address, + signHash: async (hash: Hex) => sign({ hash, privateKey: EXECUTOR_KEY }), +}; +const sponsor = { + address: sponsorAccount.address, + signHash: async (hash: Hex) => sign({ hash, privateKey: sponsorKey }), +}; + +const evnode = createEvnodeClient({ + client, + executor, + sponsor, +}); + +async function main() { + const to = TO_ADDRESS ?? executorAccount.address; + + console.log('executor', executorAccount.address); + console.log('sponsor', sponsorAccount.address); + if (autoSponsorKey) { + console.log('auto sponsor key', sponsorKey); + } + + await maybeFundSponsor(); + + await runFlow('unsponsored-single', [call(to)], false); + await runFlow('unsponsored-batch', [call(to), call(to)], false); + await runFlow('sponsored-single', [call(to)], true); + await runFlow('sponsored-batch', [call(to), call(to)], true); +} + +async function maybeFundSponsor() { + if (sponsorAccount.address === executorAccount.address) return; + if (!FUND_SPONSOR && SPONSOR_MIN_BALANCE_WEI === 0n) return; + const balanceHex = await client.request({ + method: 'eth_getBalance', + params: [sponsorAccount.address, 'latest'], + }); + const balance = hexToBigInt(balanceHex as Hex); + if (!FUND_SPONSOR && balance >= SPONSOR_MIN_BALANCE_WEI) return; + const target = SPONSOR_MIN_BALANCE_WEI > 0n ? SPONSOR_MIN_BALANCE_WEI : SPONSOR_FUND_WEI; + const amount = target > balance ? target - balance : SPONSOR_FUND_WEI; + if (amount <= 0n) return; + console.log('funding sponsor with', amount.toString(), 'wei'); + const hash = await evnode.send({ + calls: [{ to: sponsorAccount.address, value: amount, data: '0x' }], + }); + const receipt = await pollReceipt(hash); + if (!receipt) throw new Error('sponsor funding tx not mined'); +} + +async function runFlow(name: string, calls: Call[], sponsored: boolean) { + console.log(`\n== ${name} ==`); + + let hash: Hex; + if (!sponsored) { + hash = await evnode.send({ calls }); + } else { + const intent = await evnode.createIntent({ calls }); + hash = await evnode.sponsorAndSend({ intent }); + } + + console.log('submitted tx:', hash); + const receipt = await pollReceipt(hash); + if (receipt) { + console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); + } else { + console.log('receipt not found yet'); + } +} + +function call(to: `0x${string}`): Call { + return { to, value: 0n, data: '0x' }; +} + +async function pollReceipt(hash: Hex) { + for (let i = 0; i < 20; i += 1) { + const receipt = await client.request({ + method: 'eth_getTransactionReceipt', + params: [hash], + }); + if (receipt) return receipt as { status: Hex; blockNumber: Hex }; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return null; +} + +function normalizeKey(key?: string): `0x${string}` | '' | undefined { + if (!key) return undefined; + return key.startsWith('0x') ? (key as `0x${string}`) : (`0x${key}` as `0x${string}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/clients/test/evnode-sponsored.ts b/clients/test/evnode-sponsored.ts new file mode 100644 index 0000000..e0a91de --- /dev/null +++ b/clients/test/evnode-sponsored.ts @@ -0,0 +1,116 @@ +import { createClient, hexToBigInt, http, type Hex, toHex } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { randomBytes } from 'crypto'; +import { createEvnodeClient } from '../evnode-viem.ts'; + +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const EXECUTOR_KEY = normalizeKey( + process.env.EXECUTOR_PRIVATE_KEY ?? process.env.PRIVATE_KEY, +); +const SPONSOR_KEY = normalizeKey(process.env.SPONSOR_PRIVATE_KEY ?? ''); +const TO_ADDRESS = process.env.TO_ADDRESS as `0x${string}` | undefined; +const AUTO_SPONSOR = + process.env.AUTO_SPONSOR === '1' || process.env.AUTO_SPONSOR === 'true'; +const FUND_SPONSOR = + process.env.FUND_SPONSOR === '1' || process.env.FUND_SPONSOR === 'true'; +const SPONSOR_MIN_BALANCE_WEI = BigInt(process.env.SPONSOR_MIN_BALANCE_WEI ?? '0'); +const SPONSOR_FUND_WEI = BigInt(process.env.SPONSOR_FUND_WEI ?? '10000000000000000'); + +if (!EXECUTOR_KEY) { + throw new Error('Missing EXECUTOR_PRIVATE_KEY/PRIVATE_KEY'); +} + +const autoSponsorKey = AUTO_SPONSOR ? toHex(randomBytes(32)) : undefined; +const sponsorKey = (SPONSOR_KEY || autoSponsorKey || EXECUTOR_KEY) as `0x${string}`; +const client = createClient({ transport: http(RPC_URL) }); + +const executorAccount = privateKeyToAccount(EXECUTOR_KEY); +const sponsorAccount = privateKeyToAccount(sponsorKey); + +const evnode = createEvnodeClient({ + client, + executor: { + address: executorAccount.address, + signHash: async (hash: Hex) => sign({ hash, privateKey: EXECUTOR_KEY }), + }, + sponsor: { + address: sponsorAccount.address, + signHash: async (hash: Hex) => sign({ hash, privateKey: sponsorKey }), + }, +}); + +async function main() { + const to = TO_ADDRESS ?? executorAccount.address; + console.log('executor', executorAccount.address); + console.log('sponsor', sponsorAccount.address); + if (autoSponsorKey) { + console.log('auto sponsor key', sponsorKey); + } + + await maybeFundSponsor(); + const intent = await evnode.createIntent({ + calls: [{ to, value: 0n, data: '0x' }], + }); + const hash = await evnode.sponsorAndSend({ intent }); + console.log('submitted sponsored tx:', hash); + const receipt = await pollReceipt(hash); + if (receipt) { + console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); + } else { + console.log('receipt not found yet'); + } +} + +async function maybeFundSponsor() { + if (sponsorAccount.address === executorAccount.address) return; + if (!FUND_SPONSOR && SPONSOR_MIN_BALANCE_WEI === 0n) return; + const balanceHex = await client.request({ + method: 'eth_getBalance', + params: [sponsorAccount.address, 'latest'], + }); + const balance = hexToBigInt(balanceHex as Hex); + if (!FUND_SPONSOR && balance >= SPONSOR_MIN_BALANCE_WEI) return; + const target = SPONSOR_MIN_BALANCE_WEI > 0n ? SPONSOR_MIN_BALANCE_WEI : SPONSOR_FUND_WEI; + const amount = target > balance ? target - balance : SPONSOR_FUND_WEI; + if (amount <= 0n) return; + console.log('funding sponsor with', amount.toString(), 'wei'); + const hash = await evnode.send({ + calls: [{ to: sponsorAccount.address, value: amount, data: '0x' }], + }); + const receipt = await pollReceiptWithTimeout(hash, 30); + if (!receipt) throw new Error('sponsor funding tx not mined'); +} + +async function pollReceipt(hash: Hex) { + for (let i = 0; i < 15; i += 1) { + const receipt = await client.request({ + method: 'eth_getTransactionReceipt', + params: [hash], + }); + if (receipt) return receipt as { status: Hex; blockNumber: Hex }; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return null; +} + +async function pollReceiptWithTimeout(hash: Hex, attempts: number) { + for (let i = 0; i < attempts; i += 1) { + const receipt = await client.request({ + method: 'eth_getTransactionReceipt', + params: [hash], + }); + if (receipt) return receipt as { status: Hex; blockNumber: Hex }; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return null; +} + +function normalizeKey(key?: string): `0x${string}` | '' | undefined { + if (!key) return undefined; + return key.startsWith('0x') ? (key as `0x${string}`) : (`0x${key}` as `0x${string}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/clients/tsconfig.json b/clients/tsconfig.json new file mode 100644 index 0000000..d21bf26 --- /dev/null +++ b/clients/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["evnode-viem.ts", "test/**/*.ts"] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1a99f36 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,220 @@ +{ + "name": "sun-valley", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sun-valley", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "viem": "^2.45.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ox": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.11.3.tgz", + "integrity": "sha512-1bWYGk/xZel3xro3l8WGg6eq4YEKlaqvyMtVhfMFpbJzK2F6rj4EDRtqDCWVEJMkzcmEi9uW2QxsqELokOlarw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.45.0.tgz", + "integrity": "sha512-iVA9qrAgRdtpWa80lCZ6Jri6XzmLOwwA1wagX2HnKejKeliFLpON0KOdyfqvcy+gUpBVP59LBxP2aKiL3aj8fg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.11.3", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c1bb9f2 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "sun-valley", + "version": "1.0.0", + "description": "EV-reth is a specialized integration layer that enables [Reth](https://github.com/paradigmxyz/reth) to work seamlessly with Evolve, providing a custom payload builder that supports transaction submission via the Engine API.", + "main": "index.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "viem": "^2.45.0" + } +} From 7656099376a8701921969dcf38a0b00a8c64031b Mon Sep 17 00:00:00 2001 From: Jonathan Gimeno Date: Thu, 29 Jan 2026 11:50:17 +0100 Subject: [PATCH 03/14] fix: gas estimation for batch transactions and improve tests - Add 21000 gas per call in estimateIntrinsicGas for batch support - Unify env vars to use EXECUTOR_PRIVATE_KEY across all tests - Rewrite evnode-flows.ts with balance verification for all 4 flows --- clients/evnode-viem.ts | 5 +- clients/test/evnode-basic.ts | 29 +++-- clients/test/evnode-flows.ts | 228 +++++++++++++++++++++++++++-------- 3 files changed, 198 insertions(+), 64 deletions(-) diff --git a/clients/evnode-viem.ts b/clients/evnode-viem.ts index 09d880c..458bdbc 100644 --- a/clients/evnode-viem.ts +++ b/clients/evnode-viem.ts @@ -204,10 +204,11 @@ export async function signAsSponsor( } export function estimateIntrinsicGas(calls: Call[]): bigint { - let gas = 21000n; + let gas = 21000n; // base transaction cost for (const call of calls) { - if (call.to === null) gas += 32000n; + gas += 21000n; // each call costs at least 21000 gas + if (call.to === null) gas += 32000n; // CREATE costs extra for (const byte of hexToBytes(call.data)) { gas += byte === 0 ? 4n : 16n; diff --git a/clients/test/evnode-basic.ts b/clients/test/evnode-basic.ts index 99ea0b0..b7d1d03 100644 --- a/clients/test/evnode-basic.ts +++ b/clients/test/evnode-basic.ts @@ -2,25 +2,30 @@ import { createClient, http } from 'viem'; import { privateKeyToAccount, sign } from 'viem/accounts'; import { createEvnodeClient } from '../evnode-viem.ts'; -const RPC_URL = 'http://localhost:8545'; -const PRIVATE_KEY = - (process.env.PRIVATE_KEY?.startsWith('0x') - ? process.env.PRIVATE_KEY - : process.env.PRIVATE_KEY - ? `0x${process.env.PRIVATE_KEY}` - : undefined) ?? - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const EXECUTOR_KEY = normalizeKey( + process.env.EXECUTOR_PRIVATE_KEY ?? process.env.PRIVATE_KEY, +); const TO_ADDRESS = process.env.TO_ADDRESS as `0x${string}` | undefined; +if (!EXECUTOR_KEY) { + throw new Error('Missing EXECUTOR_PRIVATE_KEY or PRIVATE_KEY'); +} + +function normalizeKey(key?: string): `0x${string}` | undefined { + if (!key) return undefined; + return key.startsWith('0x') ? (key as `0x${string}`) : (`0x${key}` as `0x${string}`); +} + const client = createClient({ transport: http(RPC_URL), }); -const account = privateKeyToAccount(PRIVATE_KEY); +const executorAccount = privateKeyToAccount(EXECUTOR_KEY); const executor = { - address: account.address, - signHash: async (hash: `0x${string}`) => sign({ hash, privateKey: PRIVATE_KEY }), + address: executorAccount.address, + signHash: async (hash: `0x${string}`) => sign({ hash, privateKey: EXECUTOR_KEY }), }; const evnode = createEvnodeClient({ @@ -29,7 +34,7 @@ const evnode = createEvnodeClient({ }); async function main() { - const to = TO_ADDRESS ?? account.address; + const to = TO_ADDRESS ?? executorAccount.address; const hash = await evnode.send({ calls: [ { diff --git a/clients/test/evnode-flows.ts b/clients/test/evnode-flows.ts index 7fcb82a..7323883 100644 --- a/clients/test/evnode-flows.ts +++ b/clients/test/evnode-flows.ts @@ -1,4 +1,4 @@ -import { createClient, hexToBigInt, http, type Hex, toHex } from 'viem'; +import { createClient, hexToBigInt, http, type Hex, toHex, formatEther } from 'viem'; import { privateKeyToAccount, sign } from 'viem/accounts'; import { randomBytes } from 'crypto'; import { createEvnodeClient, type Call } from '../evnode-viem.ts'; @@ -7,93 +7,213 @@ const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; const EXECUTOR_KEY = normalizeKey( process.env.EXECUTOR_PRIVATE_KEY ?? process.env.PRIVATE_KEY, ); -const SPONSOR_KEY = normalizeKey(process.env.SPONSOR_PRIVATE_KEY ?? ''); const TO_ADDRESS = process.env.TO_ADDRESS as `0x${string}` | undefined; -const AUTO_SPONSOR = - process.env.AUTO_SPONSOR === '1' || process.env.AUTO_SPONSOR === 'true'; -const FUND_SPONSOR = - process.env.FUND_SPONSOR === '1' || process.env.FUND_SPONSOR === 'true'; -const SPONSOR_MIN_BALANCE_WEI = BigInt(process.env.SPONSOR_MIN_BALANCE_WEI ?? '0'); -const SPONSOR_FUND_WEI = BigInt(process.env.SPONSOR_FUND_WEI ?? '10000000000000000'); +const SPONSOR_FUND_WEI = BigInt(process.env.SPONSOR_FUND_WEI ?? '10000000000000000'); // 0.01 ETH if (!EXECUTOR_KEY) { throw new Error('Missing EXECUTOR_PRIVATE_KEY/PRIVATE_KEY'); } const client = createClient({ transport: http(RPC_URL) }); - const executorAccount = privateKeyToAccount(EXECUTOR_KEY); -const autoSponsorKey = AUTO_SPONSOR ? toHex(randomBytes(32)) : undefined; -const sponsorKey = (SPONSOR_KEY || autoSponsorKey || EXECUTOR_KEY) as `0x${string}`; -const sponsorAccount = privateKeyToAccount(sponsorKey); const executor = { address: executorAccount.address, signHash: async (hash: Hex) => sign({ hash, privateKey: EXECUTOR_KEY }), }; -const sponsor = { - address: sponsorAccount.address, - signHash: async (hash: Hex) => sign({ hash, privateKey: sponsorKey }), -}; -const evnode = createEvnodeClient({ +// For unsponsored txs, we use executor-only client +const evnodeUnsponsored = createEvnodeClient({ client, executor, - sponsor, }); async function main() { const to = TO_ADDRESS ?? executorAccount.address; console.log('executor', executorAccount.address); - console.log('sponsor', sponsorAccount.address); - if (autoSponsorKey) { - console.log('auto sponsor key', sponsorKey); - } + console.log(''); - await maybeFundSponsor(); + // Run all flows + await runUnsponsoredFlow('unsponsored-single', [call(to)]); + await runUnsponsoredBatchFlow(); + await runSponsoredFlow('sponsored-single', [call(to)]); + await runSponsoredFlow('sponsored-batch', [call(to), call(to)]); +} + +async function runUnsponsoredFlow(name: string, calls: Call[]) { + console.log(`\n== ${name} ==`); + + const executorBalanceBefore = await getBalance(executorAccount.address); + console.log('executor balance before:', formatEther(executorBalanceBefore), 'ETH'); + + const hash = await evnodeUnsponsored.send({ calls }); + console.log('submitted tx:', hash); + + const receipt = await pollReceipt(hash); + if (receipt) { + console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); - await runFlow('unsponsored-single', [call(to)], false); - await runFlow('unsponsored-batch', [call(to), call(to)], false); - await runFlow('sponsored-single', [call(to)], true); - await runFlow('sponsored-batch', [call(to), call(to)], true); + const executorBalanceAfter = await getBalance(executorAccount.address); + const executorSpent = executorBalanceBefore - executorBalanceAfter; + console.log('executor balance after:', formatEther(executorBalanceAfter), 'ETH'); + console.log('executor spent (gas):', formatEther(executorSpent), 'ETH'); + } else { + console.log('receipt not found yet'); + } } -async function maybeFundSponsor() { - if (sponsorAccount.address === executorAccount.address) return; - if (!FUND_SPONSOR && SPONSOR_MIN_BALANCE_WEI === 0n) return; - const balanceHex = await client.request({ - method: 'eth_getBalance', - params: [sponsorAccount.address, 'latest'], - }); - const balance = hexToBigInt(balanceHex as Hex); - if (!FUND_SPONSOR && balance >= SPONSOR_MIN_BALANCE_WEI) return; - const target = SPONSOR_MIN_BALANCE_WEI > 0n ? SPONSOR_MIN_BALANCE_WEI : SPONSOR_FUND_WEI; - const amount = target > balance ? target - balance : SPONSOR_FUND_WEI; - if (amount <= 0n) return; - console.log('funding sponsor with', amount.toString(), 'wei'); - const hash = await evnode.send({ - calls: [{ to: sponsorAccount.address, value: amount, data: '0x' }], +const TRANSFER_AMOUNT = BigInt('1000000000000000'); // 0.001 ETH + +async function runUnsponsoredBatchFlow() { + console.log('\n== unsponsored-batch =='); + + // Create 2 random recipient addresses + const recipient1Key = toHex(randomBytes(32)) as `0x${string}`; + const recipient2Key = toHex(randomBytes(32)) as `0x${string}`; + const recipient1 = privateKeyToAccount(recipient1Key).address; + const recipient2 = privateKeyToAccount(recipient2Key).address; + + console.log('recipient1:', recipient1); + console.log('recipient2:', recipient2); + + // Get balances before + const executorBalanceBefore = await getBalance(executorAccount.address); + const recipient1Before = await getBalance(recipient1); + const recipient2Before = await getBalance(recipient2); + + console.log('\n1. Balances before:'); + console.log(' executor:', formatEther(executorBalanceBefore), 'ETH'); + console.log(' recipient1:', formatEther(recipient1Before), 'ETH'); + console.log(' recipient2:', formatEther(recipient2Before), 'ETH'); + + // Send batch: transfer 0.001 ETH to each recipient + console.log('\n2. Sending batch (0.001 ETH to each recipient)...'); + const hash = await evnodeUnsponsored.send({ + calls: [ + { to: recipient1, value: TRANSFER_AMOUNT, data: '0x' }, + { to: recipient2, value: TRANSFER_AMOUNT, data: '0x' }, + ], }); + console.log('submitted tx:', hash); + const receipt = await pollReceipt(hash); - if (!receipt) throw new Error('sponsor funding tx not mined'); + if (receipt) { + console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); + + // Get balances after + const executorBalanceAfter = await getBalance(executorAccount.address); + const recipient1After = await getBalance(recipient1); + const recipient2After = await getBalance(recipient2); + + console.log('\n3. Balances after:'); + console.log(' executor:', formatEther(executorBalanceAfter), 'ETH'); + console.log(' recipient1:', formatEther(recipient1After), 'ETH'); + console.log(' recipient2:', formatEther(recipient2After), 'ETH'); + + // Verify transfers + const executorSpent = executorBalanceBefore - executorBalanceAfter; + const recipient1Received = recipient1After - recipient1Before; + const recipient2Received = recipient2After - recipient2Before; + const totalTransferred = TRANSFER_AMOUNT * 2n; + const gasSpent = executorSpent - totalTransferred; + + console.log('\n4. Verification:'); + console.log(' executor total spent:', formatEther(executorSpent), 'ETH'); + console.log(' gas cost:', formatEther(gasSpent), 'ETH'); + console.log(' recipient1 received:', formatEther(recipient1Received), 'ETH'); + console.log(' recipient2 received:', formatEther(recipient2Received), 'ETH'); + + if (recipient1Received === TRANSFER_AMOUNT && recipient2Received === TRANSFER_AMOUNT) { + console.log('\n✓ VERIFIED: Both recipients received exactly 0.001 ETH'); + } else { + console.log('\n✗ UNEXPECTED: Transfer amounts do not match'); + } + } else { + console.log('receipt not found yet'); + } } -async function runFlow(name: string, calls: Call[], sponsored: boolean) { +async function runSponsoredFlow(name: string, calls: Call[]) { console.log(`\n== ${name} ==`); - let hash: Hex; - if (!sponsored) { - hash = await evnode.send({ calls }); - } else { - const intent = await evnode.createIntent({ calls }); - hash = await evnode.sponsorAndSend({ intent }); + // Create a fresh sponsor for each sponsored test + const sponsorKey = toHex(randomBytes(32)) as `0x${string}`; + const sponsorAccount = privateKeyToAccount(sponsorKey); + + console.log('sponsor address:', sponsorAccount.address); + console.log('sponsor key:', sponsorKey); + + const sponsor = { + address: sponsorAccount.address, + signHash: async (hash: Hex) => sign({ hash, privateKey: sponsorKey }), + }; + + // Create evnode client with this sponsor + const evnodeSponsored = createEvnodeClient({ + client, + executor, + sponsor, + }); + + // Step 1: Fund the sponsor + console.log('\n1. Funding sponsor with', formatEther(SPONSOR_FUND_WEI), 'ETH...'); + const fundingHash = await evnodeUnsponsored.send({ + calls: [{ to: sponsorAccount.address, value: SPONSOR_FUND_WEI, data: '0x' }], + }); + console.log('funding tx:', fundingHash); + + const fundingReceipt = await pollReceipt(fundingHash); + if (!fundingReceipt) { + console.log('ERROR: funding tx not mined'); + return; } + console.log('funding tx mined in block:', fundingReceipt.blockNumber); + + // Step 2: Get balances before sponsored tx + const executorBalanceBefore = await getBalance(executorAccount.address); + const sponsorBalanceBefore = await getBalance(sponsorAccount.address); + console.log('\n2. Balances before sponsored tx:'); + console.log(' executor:', formatEther(executorBalanceBefore), 'ETH'); + console.log(' sponsor:', formatEther(sponsorBalanceBefore), 'ETH'); + + // Step 3: Execute sponsored tx + console.log('\n3. Executing sponsored tx...'); + const intent = await evnodeSponsored.createIntent({ calls }); + const hash = await evnodeSponsored.sponsorAndSend({ intent }); console.log('submitted tx:', hash); + const receipt = await pollReceipt(hash); if (receipt) { console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); + + // Step 4: Get balances after and verify + const executorBalanceAfter = await getBalance(executorAccount.address); + const sponsorBalanceAfter = await getBalance(sponsorAccount.address); + + const executorDiff = executorBalanceBefore - executorBalanceAfter; + const sponsorDiff = sponsorBalanceBefore - sponsorBalanceAfter; + + console.log('\n4. Balances after sponsored tx:'); + console.log(' executor:', formatEther(executorBalanceAfter), 'ETH'); + console.log(' sponsor:', formatEther(sponsorBalanceAfter), 'ETH'); + + console.log('\n5. Balance changes:'); + console.log(' executor spent:', formatEther(executorDiff), 'ETH'); + console.log(' sponsor spent:', formatEther(sponsorDiff), 'ETH (should be gas cost)'); + + // Verify sponsor paid gas + if (sponsorDiff > 0n && executorDiff === 0n) { + console.log('\n✓ VERIFIED: Sponsor paid gas, executor paid nothing'); + } else if (sponsorDiff > 0n) { + console.log('\n✓ Sponsor paid gas:', formatEther(sponsorDiff), 'ETH'); + if (executorDiff > 0n) { + console.log(' (executor also spent some, possibly from value transfer in calls)'); + } + } else { + console.log('\n✗ UNEXPECTED: Sponsor did not pay gas'); + } } else { console.log('receipt not found yet'); } @@ -103,6 +223,14 @@ function call(to: `0x${string}`): Call { return { to, value: 0n, data: '0x' }; } +async function getBalance(address: `0x${string}`): Promise { + const balanceHex = await client.request({ + method: 'eth_getBalance', + params: [address, 'latest'], + }); + return hexToBigInt(balanceHex as Hex); +} + async function pollReceipt(hash: Hex) { for (let i = 0; i < 20; i += 1) { const receipt = await client.request({ From 3473e67cbb1e5cd12fa4b4ccaf2878ee54983eec Mon Sep 17 00:00:00 2001 From: Jonathan Gimeno Date: Thu, 29 Jan 2026 12:11:46 +0100 Subject: [PATCH 04/14] make package ready for npm --- clients/.gitignore | 3 + clients/README.md | 106 ++++++++++++++++++ clients/examples/basic.ts | 53 +++++++++ clients/package-lock.json | 27 ++++- clients/package.json | 40 +++++-- clients/{evnode-viem.ts => src/index.ts} | 38 ++++--- .../evnode-basic.ts => tests/basic.test.ts} | 2 +- .../evnode-flows.ts => tests/flows.test.ts} | 2 +- .../sponsored.test.ts} | 2 +- clients/tsconfig.build.json | 8 ++ clients/tsconfig.json | 8 +- 11 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 clients/.gitignore create mode 100644 clients/README.md create mode 100644 clients/examples/basic.ts rename clients/{evnode-viem.ts => src/index.ts} (95%) rename clients/{test/evnode-basic.ts => tests/basic.test.ts} (97%) rename clients/{test/evnode-flows.ts => tests/flows.test.ts} (99%) rename clients/{test/evnode-sponsored.ts => tests/sponsored.test.ts} (98%) create mode 100644 clients/tsconfig.build.json diff --git a/clients/.gitignore b/clients/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/clients/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/clients/README.md b/clients/README.md new file mode 100644 index 0000000..43abe7a --- /dev/null +++ b/clients/README.md @@ -0,0 +1,106 @@ +# @evstack/evnode-viem + +Viem client extension for EvNode transactions (type 0x76). + +## Installation + +```bash +npm install @evstack/evnode-viem viem +``` + +## Usage + +### Basic Transaction + +```typescript +import { createClient, http } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { createEvnodeClient } from '@evstack/evnode-viem'; + +const client = createClient({ + transport: http('http://localhost:8545'), +}); + +const account = privateKeyToAccount('0x...'); + +const evnode = createEvnodeClient({ + client, + executor: { + address: account.address, + signHash: async (hash) => sign({ hash, privateKey: '0x...' }), + }, +}); + +// Send a transaction +const txHash = await evnode.send({ + calls: [ + { to: '0x...', value: 0n, data: '0x' }, + ], +}); +``` + +### Batch Transactions + +EvNode transactions support multiple calls in a single transaction: + +```typescript +const txHash = await evnode.send({ + calls: [ + { to: recipient1, value: 1000000000000000n, data: '0x' }, + { to: recipient2, value: 1000000000000000n, data: '0x' }, + ], +}); +``` + +### Sponsored Transactions + +A sponsor can pay gas fees on behalf of the executor: + +```typescript +const evnode = createEvnodeClient({ + client, + executor: { address: executorAddr, signHash: executorSignFn }, + sponsor: { address: sponsorAddr, signHash: sponsorSignFn }, +}); + +// Create intent (signed by executor) +const intent = await evnode.createIntent({ + calls: [{ to: '0x...', value: 0n, data: '0x' }], +}); + +// Sponsor signs and sends +const txHash = await evnode.sponsorAndSend({ intent }); +``` + +## API + +### `createEvnodeClient(options)` + +Creates a new EvNode client. + +**Options:** +- `client` - Viem Client instance +- `executor` - (optional) Default executor signer +- `sponsor` - (optional) Default sponsor signer + +### Client Methods + +- `send(args)` - Sign and send an EvNode transaction +- `createIntent(args)` - Create a sponsorable intent +- `sponsorIntent(args)` - Add sponsor signature to an intent +- `sponsorAndSend(args)` - Sponsor and send in one call +- `serialize(signedTx)` - Serialize a signed transaction +- `deserialize(hex)` - Deserialize a signed transaction + +### Utility Functions + +- `computeExecutorSigningHash(tx)` - Get hash for executor to sign +- `computeSponsorSigningHash(tx, executorAddress)` - Get hash for sponsor to sign +- `computeTxHash(signedTx)` - Get transaction hash +- `recoverExecutor(signedTx)` - Recover executor address from signature +- `recoverSponsor(tx, executorAddress)` - Recover sponsor address from signature +- `estimateIntrinsicGas(calls)` - Estimate minimum gas for calls + +## License + +MIT diff --git a/clients/examples/basic.ts b/clients/examples/basic.ts new file mode 100644 index 0000000..56cb9ea --- /dev/null +++ b/clients/examples/basic.ts @@ -0,0 +1,53 @@ +/** + * Basic example: Send an EvNode transaction + * + * Run with: + * PRIVATE_KEY=0x... npx tsx examples/basic.ts + */ +import { createClient, http } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { createEvnodeClient } from '../src/index.ts'; + +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`; + +if (!PRIVATE_KEY) { + console.error('Usage: PRIVATE_KEY=0x... npx tsx examples/basic.ts'); + process.exit(1); +} + +async function main() { + // Create a standard viem client + const client = createClient({ + transport: http(RPC_URL), + }); + + // Create account from private key + const account = privateKeyToAccount(PRIVATE_KEY); + + // Create EvNode client with executor signer + const evnode = createEvnodeClient({ + client, + executor: { + address: account.address, + signHash: async (hash) => sign({ hash, privateKey: PRIVATE_KEY }), + }, + }); + + console.log('Sending EvNode transaction from:', account.address); + + // Send a simple self-transfer + const txHash = await evnode.send({ + calls: [ + { + to: account.address, + value: 0n, + data: '0x', + }, + ], + }); + + console.log('Transaction hash:', txHash); +} + +main().catch(console.error); diff --git a/clients/package-lock.json b/clients/package-lock.json index 6c9960e..a7d2db8 100644 --- a/clients/package-lock.json +++ b/clients/package-lock.json @@ -1,15 +1,20 @@ { - "name": "@evstack/evnode-viem-client", - "version": "0.0.0", + "name": "@evstack/evnode-viem", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@evstack/evnode-viem-client", - "version": "0.0.0", + "name": "@evstack/evnode-viem", + "version": "0.1.0", + "license": "MIT", "devDependencies": { "tsx": "^4.19.2", + "typescript": "^5.7.0", "viem": "^2.45.0" + }, + "peerDependencies": { + "viem": "^2.0.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -718,6 +723,20 @@ "fsevents": "~2.3.3" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/viem": { "version": "2.45.0", "resolved": "https://registry.npmjs.org/viem/-/viem-2.45.0.tgz", diff --git a/clients/package.json b/clients/package.json index c31ccd6..fc6f37f 100644 --- a/clients/package.json +++ b/clients/package.json @@ -1,16 +1,42 @@ { - "name": "@evstack/evnode-viem-client", - "version": "0.0.0", - "private": true, + "name": "@evstack/evnode-viem", + "version": "0.1.0", + "description": "Viem client extension for EvNode transactions", "type": "module", - "description": "Local tooling and tests for the EvNode Viem client", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], "scripts": { - "test:basic": "tsx test/evnode-basic.ts", - "test:flows": "tsx test/evnode-flows.ts", - "test:sponsored": "tsx test/evnode-sponsored.ts" + "build": "tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build", + "test:basic": "tsx tests/basic.test.ts", + "test:flows": "tsx tests/flows.test.ts", + "test:sponsored": "tsx tests/sponsored.test.ts", + "test": "npm run test:basic" + }, + "keywords": [ + "ethereum", + "viem", + "evnode", + "evm", + "rollup" + ], + "license": "MIT", + "peerDependencies": { + "viem": "^2.0.0" }, "devDependencies": { "tsx": "^4.19.2", + "typescript": "^5.7.0", "viem": "^2.45.0" } } diff --git a/clients/evnode-viem.ts b/clients/src/index.ts similarity index 95% rename from clients/evnode-viem.ts rename to clients/src/index.ts index 458bdbc..3f830a6 100644 --- a/clients/evnode-viem.ts +++ b/clients/src/index.ts @@ -6,7 +6,6 @@ import { type Signature, bytesToHex, concat, - defineTransaction, fromRlp, hexToBigInt, hexToBytes, @@ -98,7 +97,7 @@ export interface EvnodeSponsorArgs { export function encodeSignedTransaction(signedTx: EvNodeSignedTransaction): Hex { const fields = buildPayloadFields(signedTx.transaction, true); - const execSig = normalizeSignature(signedTx.executorSignature); + const execSig = normalizeSignatureForRlp(signedTx.executorSignature); const envelope = toRlp([ ...fields, rlpHexFromBigInt(BigInt(execSig.v)), @@ -397,13 +396,6 @@ export function createEvnodeClient(options: EvnodeClientOptions) { }; } -export const evnodeSerializer = defineTransaction({ - type: 'evnode', - typeId: EVNODE_TX_TYPE, - serialize: (tx) => encodeSignedTransaction(tx as EvNodeSignedTransaction), - deserialize: (bytes) => decodeEvNodeTransaction(bytes as Hex), -}); - export function hashSignerFromRpcClient( client: Client, address: Address, @@ -419,7 +411,7 @@ export function hashSignerFromRpcClient( if (!isHex(signature)) { throw new Error('eth_sign returned non-hex signature'); } - return signature; + return hexToSignature(signature); }, }; } @@ -480,7 +472,7 @@ function decodeCalls(value: RlpValue): Call[] { } function encodeAccessList(accessList: AccessList): RlpValue[] { - return accessList.map((item) => [item.address, item.storageKeys]); + return accessList.map((item) => [item.address, [...item.storageKeys]]); } function decodeAccessList(value: RlpValue): AccessList { @@ -514,8 +506,8 @@ function encodeSponsorSignature(signature: Signature): RlpValue { if (typeof signature === 'string') { return signature; } - const normalized = normalizeSignature(signature); - const vByte = toHex(BigInt(normalized.v), { size: 1 }); + const normalized = normalizeSignatureForRlp(signature); + const vByte = toHex(normalized.v, { size: 1 }); return concat([normalized.r, normalized.s, vByte]); } @@ -545,7 +537,7 @@ function signatureFromBytes(value: Hex): Signature { if (v !== 0 && v !== 1) { throw new Error('Invalid signature v value'); } - return { v, r: padTo32Bytes(r), s: padTo32Bytes(s) }; + return { yParity: v, v: BigInt(v), r: padTo32Bytes(r), s: padTo32Bytes(s) }; } function signatureFromParts(v: RlpValue, r: RlpValue, s: RlpValue): Signature { @@ -559,28 +551,38 @@ function signatureFromParts(v: RlpValue, r: RlpValue, s: RlpValue): Signature { } return { - v: vNumber, + yParity: vNumber, + v: BigInt(vNumber), r: padTo32Bytes(r), s: padTo32Bytes(s), }; } -function normalizeSignature(signature: Signature): { v: number; r: Hex; s: Hex } { +function normalizeSignature(signature: Signature): { yParity: number; r: Hex; s: Hex; v?: bigint } { const parsed = typeof signature === 'string' ? hexToSignature(signature) : signature; - const v = Number(parsed.v); + const v = Number(parsed.v ?? parsed.yParity); const normalizedV = v === 27 || v === 28 ? v - 27 : v; if (normalizedV !== 0 && normalizedV !== 1) { throw new Error('Invalid signature v value'); } return { - v: normalizedV, + yParity: normalizedV, r: padTo32Bytes(parsed.r), s: padTo32Bytes(parsed.s), }; } +function normalizeSignatureForRlp(signature: Signature): { v: number; r: Hex; s: Hex } { + const normalized = normalizeSignature(signature); + return { + v: normalized.yParity, + r: normalized.r, + s: normalized.s, + }; +} + function padTo32Bytes(value: Hex): Hex { return toHex(hexToBigIntSafe(value), { size: 32 }); } diff --git a/clients/test/evnode-basic.ts b/clients/tests/basic.test.ts similarity index 97% rename from clients/test/evnode-basic.ts rename to clients/tests/basic.test.ts index b7d1d03..4e3179f 100644 --- a/clients/test/evnode-basic.ts +++ b/clients/tests/basic.test.ts @@ -1,6 +1,6 @@ import { createClient, http } from 'viem'; import { privateKeyToAccount, sign } from 'viem/accounts'; -import { createEvnodeClient } from '../evnode-viem.ts'; +import { createEvnodeClient } from '../src/index.ts'; const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; const EXECUTOR_KEY = normalizeKey( diff --git a/clients/test/evnode-flows.ts b/clients/tests/flows.test.ts similarity index 99% rename from clients/test/evnode-flows.ts rename to clients/tests/flows.test.ts index 7323883..3c2c8b7 100644 --- a/clients/test/evnode-flows.ts +++ b/clients/tests/flows.test.ts @@ -1,7 +1,7 @@ import { createClient, hexToBigInt, http, type Hex, toHex, formatEther } from 'viem'; import { privateKeyToAccount, sign } from 'viem/accounts'; import { randomBytes } from 'crypto'; -import { createEvnodeClient, type Call } from '../evnode-viem.ts'; +import { createEvnodeClient, type Call } from '../src/index.ts'; const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; const EXECUTOR_KEY = normalizeKey( diff --git a/clients/test/evnode-sponsored.ts b/clients/tests/sponsored.test.ts similarity index 98% rename from clients/test/evnode-sponsored.ts rename to clients/tests/sponsored.test.ts index e0a91de..a44e260 100644 --- a/clients/test/evnode-sponsored.ts +++ b/clients/tests/sponsored.test.ts @@ -1,7 +1,7 @@ import { createClient, hexToBigInt, http, type Hex, toHex } from 'viem'; import { privateKeyToAccount, sign } from 'viem/accounts'; import { randomBytes } from 'crypto'; -import { createEvnodeClient } from '../evnode-viem.ts'; +import { createEvnodeClient } from '../src/index.ts'; const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; const EXECUTOR_KEY = normalizeKey( diff --git a/clients/tsconfig.build.json b/clients/tsconfig.build.json new file mode 100644 index 0000000..38d4608 --- /dev/null +++ b/clients/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["tests", "examples", "node_modules"] +} diff --git a/clients/tsconfig.json b/clients/tsconfig.json index d21bf26..01c8696 100644 --- a/clients/tsconfig.json +++ b/clients/tsconfig.json @@ -5,7 +5,11 @@ "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist" }, - "include": ["evnode-viem.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"] } From fb4b8dd80577bbe7f2c0036f5a33854a8d4582a4 Mon Sep 17 00:00:00 2001 From: Jonathan Gimeno Date: Thu, 29 Jan 2026 12:14:57 +0100 Subject: [PATCH 05/14] add examples for multiple types of tx --- clients/examples/basic.ts | 14 ++----- clients/examples/batch.ts | 52 ++++++++++++++++++++++++ clients/examples/contract-call.ts | 63 +++++++++++++++++++++++++++++ clients/examples/contract-deploy.ts | 54 +++++++++++++++++++++++++ clients/examples/manual-params.ts | 61 ++++++++++++++++++++++++++++ clients/examples/sponsored.ts | 63 +++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 clients/examples/batch.ts create mode 100644 clients/examples/contract-call.ts create mode 100644 clients/examples/contract-deploy.ts create mode 100644 clients/examples/manual-params.ts create mode 100644 clients/examples/sponsored.ts diff --git a/clients/examples/basic.ts b/clients/examples/basic.ts index 56cb9ea..d87123d 100644 --- a/clients/examples/basic.ts +++ b/clients/examples/basic.ts @@ -1,5 +1,5 @@ /** - * Basic example: Send an EvNode transaction + * Basic example: Send a simple EvNode transaction * * Run with: * PRIVATE_KEY=0x... npx tsx examples/basic.ts @@ -17,15 +17,9 @@ if (!PRIVATE_KEY) { } async function main() { - // Create a standard viem client - const client = createClient({ - transport: http(RPC_URL), - }); - - // Create account from private key + const client = createClient({ transport: http(RPC_URL) }); const account = privateKeyToAccount(PRIVATE_KEY); - // Create EvNode client with executor signer const evnode = createEvnodeClient({ client, executor: { @@ -34,9 +28,9 @@ async function main() { }, }); - console.log('Sending EvNode transaction from:', account.address); + console.log('Executor:', account.address); - // Send a simple self-transfer + // Send a simple transaction (self-transfer with no value) const txHash = await evnode.send({ calls: [ { diff --git a/clients/examples/batch.ts b/clients/examples/batch.ts new file mode 100644 index 0000000..93b6cf9 --- /dev/null +++ b/clients/examples/batch.ts @@ -0,0 +1,52 @@ +/** + * Batch example: Send multiple calls in a single transaction + * + * Run with: + * PRIVATE_KEY=0x... npx tsx examples/batch.ts + */ +import { createClient, http, formatEther } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { createEvnodeClient } from '../src/index.ts'; + +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`; + +if (!PRIVATE_KEY) { + console.error('Usage: PRIVATE_KEY=0x... npx tsx examples/batch.ts'); + process.exit(1); +} + +async function main() { + const client = createClient({ transport: http(RPC_URL) }); + const account = privateKeyToAccount(PRIVATE_KEY); + + const evnode = createEvnodeClient({ + client, + executor: { + address: account.address, + signHash: async (hash) => sign({ hash, privateKey: PRIVATE_KEY }), + }, + }); + + console.log('Executor:', account.address); + + // Example recipients (in practice, use real addresses) + const recipient1 = '0x1111111111111111111111111111111111111111' as const; + const recipient2 = '0x2222222222222222222222222222222222222222' as const; + const amount = 1000000000000000n; // 0.001 ETH + + console.log(`\nSending ${formatEther(amount)} ETH to each recipient...`); + + // Send batch transaction: multiple transfers in one tx + const txHash = await evnode.send({ + calls: [ + { to: recipient1, value: amount, data: '0x' }, + { to: recipient2, value: amount, data: '0x' }, + ], + }); + + console.log('Transaction hash:', txHash); + console.log('\nBoth transfers executed atomically in a single transaction.'); +} + +main().catch(console.error); diff --git a/clients/examples/contract-call.ts b/clients/examples/contract-call.ts new file mode 100644 index 0000000..c545ed5 --- /dev/null +++ b/clients/examples/contract-call.ts @@ -0,0 +1,63 @@ +/** + * Contract call example: Interact with a smart contract + * + * Run with: + * PRIVATE_KEY=0x... CONTRACT=0x... npx tsx examples/contract-call.ts + */ +import { createClient, http, encodeFunctionData, parseAbi } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { createEvnodeClient } from '../src/index.ts'; + +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`; +const CONTRACT = process.env.CONTRACT as `0x${string}`; + +if (!PRIVATE_KEY) { + console.error('Usage: PRIVATE_KEY=0x... CONTRACT=0x... npx tsx examples/contract-call.ts'); + process.exit(1); +} + +async function main() { + const client = createClient({ transport: http(RPC_URL) }); + const account = privateKeyToAccount(PRIVATE_KEY); + + const evnode = createEvnodeClient({ + client, + executor: { + address: account.address, + signHash: async (hash) => sign({ hash, privateKey: PRIVATE_KEY }), + }, + }); + + console.log('Executor:', account.address); + + // Example: ERC20 transfer + // In practice, replace with your contract's ABI and function + const abi = parseAbi([ + 'function transfer(address to, uint256 amount) returns (bool)', + ]); + + const data = encodeFunctionData({ + abi, + functionName: 'transfer', + args: ['0x1111111111111111111111111111111111111111', 1000000n], + }); + + const contractAddress = CONTRACT ?? '0x0000000000000000000000000000000000000000'; + + console.log('\nCalling contract:', contractAddress); + + const txHash = await evnode.send({ + calls: [ + { + to: contractAddress, + value: 0n, + data, + }, + ], + }); + + console.log('Transaction hash:', txHash); +} + +main().catch(console.error); diff --git a/clients/examples/contract-deploy.ts b/clients/examples/contract-deploy.ts new file mode 100644 index 0000000..3195c27 --- /dev/null +++ b/clients/examples/contract-deploy.ts @@ -0,0 +1,54 @@ +/** + * Contract deploy example: Deploy a smart contract + * + * Run with: + * PRIVATE_KEY=0x... npx tsx examples/contract-deploy.ts + */ +import { createClient, http, type Hex } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { createEvnodeClient } from '../src/index.ts'; + +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`; + +if (!PRIVATE_KEY) { + console.error('Usage: PRIVATE_KEY=0x... npx tsx examples/contract-deploy.ts'); + process.exit(1); +} + +async function main() { + const client = createClient({ transport: http(RPC_URL) }); + const account = privateKeyToAccount(PRIVATE_KEY); + + const evnode = createEvnodeClient({ + client, + executor: { + address: account.address, + signHash: async (hash) => sign({ hash, privateKey: PRIVATE_KEY }), + }, + }); + + console.log('Executor:', account.address); + + // Simple storage contract bytecode + // contract Storage { uint256 value; function set(uint256 v) { value = v; } function get() view returns (uint256) { return value; } } + const bytecode: Hex = '0x608060405234801561001057600080fd5b5060df8061001f6000396000f3fe6080604052348015600f57600080fd5b5060043610603c5760003560e01c80636d4ce63c1460415780638a42ebe914605b575b600080fd5b60005460405190815260200160405180910390f35b606b6066366004606d565b600055565b005b600060208284031215607e57600080fd5b503591905056fea264697066735822122041c7f6d2d7b0d1c0d6c0d8e7f4c5b3a2918d7e6f5c4b3a291807d6e5f4c3b2a164736f6c63430008110033'; + + console.log('\nDeploying contract...'); + + // Deploy with to=null (CREATE) + const txHash = await evnode.send({ + calls: [ + { + to: null, + value: 0n, + data: bytecode, + }, + ], + }); + + console.log('Transaction hash:', txHash); + console.log('\nContract deployed. Check receipt for contract address.'); +} + +main().catch(console.error); diff --git a/clients/examples/manual-params.ts b/clients/examples/manual-params.ts new file mode 100644 index 0000000..9226a2d --- /dev/null +++ b/clients/examples/manual-params.ts @@ -0,0 +1,61 @@ +/** + * Manual params example: Specify gas, nonce, and fees manually + * + * Run with: + * PRIVATE_KEY=0x... npx tsx examples/manual-params.ts + */ +import { createClient, http } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { createEvnodeClient } from '../src/index.ts'; + +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`; + +if (!PRIVATE_KEY) { + console.error('Usage: PRIVATE_KEY=0x... npx tsx examples/manual-params.ts'); + process.exit(1); +} + +async function main() { + const client = createClient({ transport: http(RPC_URL) }); + const account = privateKeyToAccount(PRIVATE_KEY); + + const evnode = createEvnodeClient({ + client, + executor: { + address: account.address, + signHash: async (hash) => sign({ hash, privateKey: PRIVATE_KEY }), + }, + }); + + console.log('Executor:', account.address); + + // Get current nonce + const nonce = await client.request({ + method: 'eth_getTransactionCount', + params: [account.address, 'pending'], + }); + + console.log('\nCurrent nonce:', nonce); + + // Send with manual parameters + const txHash = await evnode.send({ + calls: [ + { + to: account.address, + value: 0n, + data: '0x', + }, + ], + // Manual overrides + nonce: BigInt(nonce as string), + gasLimit: 100000n, + maxFeePerGas: 1000000000n, // 1 gwei + maxPriorityFeePerGas: 0n, + accessList: [], + }); + + console.log('Transaction hash:', txHash); +} + +main().catch(console.error); diff --git a/clients/examples/sponsored.ts b/clients/examples/sponsored.ts new file mode 100644 index 0000000..1b73139 --- /dev/null +++ b/clients/examples/sponsored.ts @@ -0,0 +1,63 @@ +/** + * Sponsored example: Sponsor pays gas on behalf of executor + * + * Run with: + * EXECUTOR_KEY=0x... SPONSOR_KEY=0x... npx tsx examples/sponsored.ts + */ +import { createClient, http, formatEther, type Hex } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { createEvnodeClient } from '../src/index.ts'; + +const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; +const EXECUTOR_KEY = process.env.EXECUTOR_KEY as `0x${string}`; +const SPONSOR_KEY = process.env.SPONSOR_KEY as `0x${string}`; + +if (!EXECUTOR_KEY || !SPONSOR_KEY) { + console.error('Usage: EXECUTOR_KEY=0x... SPONSOR_KEY=0x... npx tsx examples/sponsored.ts'); + process.exit(1); +} + +async function main() { + const client = createClient({ transport: http(RPC_URL) }); + + const executorAccount = privateKeyToAccount(EXECUTOR_KEY); + const sponsorAccount = privateKeyToAccount(SPONSOR_KEY); + + console.log('Executor:', executorAccount.address); + console.log('Sponsor:', sponsorAccount.address); + + const evnode = createEvnodeClient({ + client, + executor: { + address: executorAccount.address, + signHash: async (hash) => sign({ hash, privateKey: EXECUTOR_KEY }), + }, + sponsor: { + address: sponsorAccount.address, + signHash: async (hash) => sign({ hash, privateKey: SPONSOR_KEY }), + }, + }); + + // Step 1: Executor creates an intent (signs the transaction) + console.log('\n1. Creating intent (executor signs)...'); + const intent = await evnode.createIntent({ + calls: [ + { + to: executorAccount.address, + value: 0n, + data: '0x', + }, + ], + }); + + console.log(' Intent created with executor signature'); + + // Step 2: Sponsor signs and sends the transaction + console.log('\n2. Sponsor signs and sends...'); + const txHash = await evnode.sponsorAndSend({ intent }); + + console.log('Transaction hash:', txHash); + console.log('\nThe sponsor paid the gas fees, executor paid nothing.'); +} + +main().catch(console.error); From c4010bef8bcb7e11bbfaf5200b7c3208b76da0a9 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 29 Jan 2026 17:37:27 +0100 Subject: [PATCH 06/14] refactor: consolidate duplicate type definitions in client --- clients/src/index.ts | 37 ++++++------------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/clients/src/index.ts b/clients/src/index.ts index 3f830a6..a17a879 100644 --- a/clients/src/index.ts +++ b/clients/src/index.ts @@ -79,16 +79,9 @@ export interface EvnodeSendArgs { accessList?: AccessList; } -export interface EvnodeIntentArgs { - calls: Call[]; - executor?: HashSigner; - chainId?: bigint; - nonce?: bigint; - maxFeePerGas?: bigint; - maxPriorityFeePerGas?: bigint; - gasLimit?: bigint; - accessList?: AccessList; -} +type EvnodeSendArgsWithExecutor = Omit & { + executor: HashSigner; +}; export interface EvnodeSponsorArgs { intent: SponsorableIntent; @@ -231,16 +224,7 @@ export function validateEvNodeTx(tx: EvNodeTransaction): void { export function evnodeActions(client: Client) { return { - async sendEvNodeTransaction(args: { - calls: Call[]; - executor: HashSigner; - chainId?: bigint; - nonce?: bigint; - maxFeePerGas?: bigint; - maxPriorityFeePerGas?: bigint; - gasLimit?: bigint; - accessList?: AccessList; - }): Promise { + async sendEvNodeTransaction(args: EvnodeSendArgsWithExecutor): Promise { const base = await resolveBaseFields(client, args.executor.address, { chainId: args.chainId, nonce: args.nonce, @@ -271,16 +255,7 @@ export function evnodeActions(client: Client) { }) as Promise; }, - async createSponsorableIntent(args: { - calls: Call[]; - executor: HashSigner; - chainId?: bigint; - nonce?: bigint; - maxFeePerGas?: bigint; - maxPriorityFeePerGas?: bigint; - gasLimit?: bigint; - accessList?: AccessList; - }): Promise { + async createSponsorableIntent(args: EvnodeSendArgsWithExecutor): Promise { const base = await resolveBaseFields(client, args.executor.address, { chainId: args.chainId, nonce: args.nonce, @@ -368,7 +343,7 @@ export function createEvnodeClient(options: EvnodeClientOptions) { executor: requireExecutor(args.executor), }); }, - createIntent(args: EvnodeIntentArgs): Promise { + createIntent(args: EvnodeSendArgs): Promise { return actions.createSponsorableIntent({ ...args, executor: requireExecutor(args.executor), From 2eaf6d1a96aa591f9e08036d3893bb90d8a63795 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 10 Feb 2026 10:27:01 +0100 Subject: [PATCH 07/14] refactor: extract BASE_TX_GAS constant for intrinsic gas estimation --- clients/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/clients/src/index.ts b/clients/src/index.ts index a17a879..52cf13d 100644 --- a/clients/src/index.ts +++ b/clients/src/index.ts @@ -21,6 +21,7 @@ export const EVNODE_TX_TYPE = 0x76; export const EVNODE_EXECUTOR_DOMAIN = 0x76; export const EVNODE_SPONSOR_DOMAIN = 0x78; +const BASE_TX_GAS = 21000n; const EMPTY_BYTES = '0x' as const; const TX_TYPE_HEX = toHex(EVNODE_TX_TYPE, { size: 1 }); const EXECUTOR_DOMAIN_HEX = toHex(EVNODE_EXECUTOR_DOMAIN, { size: 1 }); @@ -196,10 +197,10 @@ export async function signAsSponsor( } export function estimateIntrinsicGas(calls: Call[]): bigint { - let gas = 21000n; // base transaction cost + let gas = BASE_TX_GAS; // base transaction cost for (const call of calls) { - gas += 21000n; // each call costs at least 21000 gas + gas += BASE_TX_GAS; // each call costs at least 21000 gas if (call.to === null) gas += 32000n; // CREATE costs extra for (const byte of hexToBytes(call.data)) { From 87013ef603cc0185a6b4bff92b01146583104e12 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 10 Feb 2026 10:28:52 +0100 Subject: [PATCH 08/14] refactor: replace ternary with if/else in calldata gas calculation --- clients/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/clients/src/index.ts b/clients/src/index.ts index 52cf13d..3f5aacd 100644 --- a/clients/src/index.ts +++ b/clients/src/index.ts @@ -204,7 +204,11 @@ export function estimateIntrinsicGas(calls: Call[]): bigint { if (call.to === null) gas += 32000n; // CREATE costs extra for (const byte of hexToBytes(call.data)) { - gas += byte === 0 ? 4n : 16n; + if (byte === 0) { + gas += 4n; + } else { + gas += 16n; + } } } From cf8b1a70a62a8104def5efba0f55cd8bcda510fb Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 10 Feb 2026 10:30:32 +0100 Subject: [PATCH 09/14] refactor: extract CREATE_GAS constant for contract deployment gas cost --- clients/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clients/src/index.ts b/clients/src/index.ts index 3f5aacd..27726ee 100644 --- a/clients/src/index.ts +++ b/clients/src/index.ts @@ -22,6 +22,8 @@ export const EVNODE_EXECUTOR_DOMAIN = 0x76; export const EVNODE_SPONSOR_DOMAIN = 0x78; const BASE_TX_GAS = 21000n; +// Extra gas charged when a call deploys a new contract (to === null) +const CREATE_GAS = 32000n; const EMPTY_BYTES = '0x' as const; const TX_TYPE_HEX = toHex(EVNODE_TX_TYPE, { size: 1 }); const EXECUTOR_DOMAIN_HEX = toHex(EVNODE_EXECUTOR_DOMAIN, { size: 1 }); @@ -201,7 +203,7 @@ export function estimateIntrinsicGas(calls: Call[]): bigint { for (const call of calls) { gas += BASE_TX_GAS; // each call costs at least 21000 gas - if (call.to === null) gas += 32000n; // CREATE costs extra + if (call.to === null) gas += CREATE_GAS; // CREATE costs extra for (const byte of hexToBytes(call.data)) { if (byte === 0) { From e556c23ec1692d184e9cf17d4ce26eb379e0631b Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 10 Feb 2026 10:42:41 +0100 Subject: [PATCH 10/14] refactor: add isCreateCall helper for contract creation checks --- clients/src/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/clients/src/index.ts b/clients/src/index.ts index 27726ee..66308ee 100644 --- a/clients/src/index.ts +++ b/clients/src/index.ts @@ -198,12 +198,16 @@ export async function signAsSponsor( return signer.signHash(hash); } +function isCreateCall(call: Call): boolean { + return call.to === null; +} + export function estimateIntrinsicGas(calls: Call[]): bigint { let gas = BASE_TX_GAS; // base transaction cost for (const call of calls) { gas += BASE_TX_GAS; // each call costs at least 21000 gas - if (call.to === null) gas += CREATE_GAS; // CREATE costs extra + if (isCreateCall(call)) gas += CREATE_GAS; for (const byte of hexToBytes(call.data)) { if (byte === 0) { @@ -223,7 +227,7 @@ export function validateEvNodeTx(tx: EvNodeTransaction): void { } for (let i = 1; i < tx.calls.length; i += 1) { - if (tx.calls[i].to === null) { + if (isCreateCall(tx.calls[i])) { throw new Error('Only the first call may be CREATE'); } } From 59a713b344dae4c47d6367e9839a9f05a62ac8f0 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 10 Feb 2026 10:44:09 +0100 Subject: [PATCH 11/14] refactor: extract EVNODE_TX_FIELD_COUNT constant for RLP decode validation --- clients/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clients/src/index.ts b/clients/src/index.ts index 66308ee..857e7c9 100644 --- a/clients/src/index.ts +++ b/clients/src/index.ts @@ -24,6 +24,7 @@ export const EVNODE_SPONSOR_DOMAIN = 0x78; const BASE_TX_GAS = 21000n; // Extra gas charged when a call deploys a new contract (to === null) const CREATE_GAS = 32000n; +const EVNODE_TX_FIELD_COUNT = 11; const EMPTY_BYTES = '0x' as const; const TX_TYPE_HEX = toHex(EVNODE_TX_TYPE, { size: 1 }); const EXECUTOR_DOMAIN_HEX = toHex(EVNODE_EXECUTOR_DOMAIN, { size: 1 }); @@ -114,7 +115,7 @@ export function decodeEvNodeTransaction(encoded: Hex): EvNodeSignedTransaction { throw new Error('Invalid EvNode transaction payload'); } - if (decoded.length !== 11) { + if (decoded.length !== EVNODE_TX_FIELD_COUNT) { throw new Error('Invalid EvNode transaction length'); } From f11784032e66d960f05be9757e2ef6d2cc7cb69a Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 10 Feb 2026 10:50:23 +0100 Subject: [PATCH 12/14] refactor: split index.ts into types, encoding, signing, and client modules --- clients/src/client.ts | 249 +++++++++++++++ clients/src/encoding.ts | 326 +++++++++++++++++++ clients/src/index.ts | 685 +++------------------------------------- clients/src/signing.ts | 69 ++++ clients/src/types.ts | 67 ++++ 5 files changed, 750 insertions(+), 646 deletions(-) create mode 100644 clients/src/client.ts create mode 100644 clients/src/encoding.ts create mode 100644 clients/src/signing.ts create mode 100644 clients/src/types.ts diff --git a/clients/src/client.ts b/clients/src/client.ts new file mode 100644 index 0000000..5468868 --- /dev/null +++ b/clients/src/client.ts @@ -0,0 +1,249 @@ +import { + type AccessList, + type Address, + type Client, + type Hex, + hexToBigInt, + isHex, +} from 'viem'; + +import type { + Call, + EvNodeTransaction, + EvNodeSignedTransaction, + SponsorableIntent, + HashSigner, + EvnodeClientOptions, + EvnodeSendArgs, + EvnodeSendArgsWithExecutor, + EvnodeSponsorArgs, +} from './types.js'; + +import { + encodeSignedTransaction, + decodeEvNodeTransaction, + estimateIntrinsicGas, + validateEvNodeTx, +} from './encoding.js'; + +import { signAsExecutor, signAsSponsor } from './signing.js'; + +export function evnodeActions(client: Client) { + return { + async sendEvNodeTransaction(args: EvnodeSendArgsWithExecutor): Promise { + const base = await resolveBaseFields(client, args.executor.address, { + chainId: args.chainId, + nonce: args.nonce, + maxFeePerGas: args.maxFeePerGas, + maxPriorityFeePerGas: args.maxPriorityFeePerGas, + gasLimit: args.gasLimit, + accessList: args.accessList, + }, args.calls); + + const tx: EvNodeTransaction = { + ...base, + calls: args.calls, + feePayerSignature: undefined, + }; + + validateEvNodeTx(tx); + + const executorSignature = await signAsExecutor(tx, args.executor); + const signedTx: EvNodeSignedTransaction = { + transaction: tx, + executorSignature, + }; + + const serialized = encodeSignedTransaction(signedTx); + return client.request({ + method: 'eth_sendRawTransaction', + params: [serialized], + }) as Promise; + }, + + async createSponsorableIntent(args: EvnodeSendArgsWithExecutor): Promise { + const base = await resolveBaseFields(client, args.executor.address, { + chainId: args.chainId, + nonce: args.nonce, + maxFeePerGas: args.maxFeePerGas, + maxPriorityFeePerGas: args.maxPriorityFeePerGas, + gasLimit: args.gasLimit, + accessList: args.accessList, + }, args.calls); + + const tx: EvNodeTransaction = { + ...base, + calls: args.calls, + feePayerSignature: undefined, + }; + + validateEvNodeTx(tx); + + const executorSignature = await signAsExecutor(tx, args.executor); + + return { + tx, + executorSignature, + executorAddress: args.executor.address, + }; + }, + + async sponsorIntent(args: { + intent: SponsorableIntent; + sponsor: HashSigner; + }): Promise { + const sponsorSignature = await signAsSponsor( + args.intent.tx, + args.intent.executorAddress, + args.sponsor, + ); + + return { + transaction: { + ...args.intent.tx, + feePayerSignature: sponsorSignature, + }, + executorSignature: args.intent.executorSignature, + }; + }, + + serializeEvNodeTransaction(signedTx: EvNodeSignedTransaction): Hex { + return encodeSignedTransaction(signedTx); + }, + + deserializeEvNodeTransaction(encoded: Hex): EvNodeSignedTransaction { + return decodeEvNodeTransaction(encoded); + }, + }; +} + +export function createEvnodeClient(options: EvnodeClientOptions) { + const actions = evnodeActions(options.client); + let defaultExecutor = options.executor; + let defaultSponsor = options.sponsor; + + const requireExecutor = (executor?: HashSigner) => { + const resolved = executor ?? defaultExecutor; + if (!resolved) throw new Error('Executor signer is required'); + return resolved; + }; + + const requireSponsor = (sponsor?: HashSigner) => { + const resolved = sponsor ?? defaultSponsor; + if (!resolved) throw new Error('Sponsor signer is required'); + return resolved; + }; + + return { + client: options.client, + actions, + setDefaultExecutor(executor: HashSigner) { + defaultExecutor = executor; + }, + setDefaultSponsor(sponsor: HashSigner) { + defaultSponsor = sponsor; + }, + send(args: EvnodeSendArgs): Promise { + return actions.sendEvNodeTransaction({ + ...args, + executor: requireExecutor(args.executor), + }); + }, + createIntent(args: EvnodeSendArgs): Promise { + return actions.createSponsorableIntent({ + ...args, + executor: requireExecutor(args.executor), + }); + }, + sponsorIntent(args: EvnodeSponsorArgs): Promise { + return actions.sponsorIntent({ + intent: args.intent, + sponsor: requireSponsor(args.sponsor), + }); + }, + async sponsorAndSend(args: EvnodeSponsorArgs): Promise { + const signed = await actions.sponsorIntent({ + intent: args.intent, + sponsor: requireSponsor(args.sponsor), + }); + const serialized = actions.serializeEvNodeTransaction(signed); + return options.client.request({ + method: 'eth_sendRawTransaction', + params: [serialized], + }) as Promise; + }, + serialize: actions.serializeEvNodeTransaction, + deserialize: actions.deserializeEvNodeTransaction, + }; +} + +// --- internal helpers --- + +async function resolveBaseFields( + client: Client, + address: Address, + overrides: { + chainId?: bigint; + nonce?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + gasLimit?: bigint; + accessList?: AccessList; + }, + calls: Call[], +): Promise> { + const chainId = overrides.chainId ?? (await fetchChainId(client)); + const nonce = overrides.nonce ?? (await fetchNonce(client, address)); + const maxPriorityFeePerGas = + overrides.maxPriorityFeePerGas ?? (await fetchMaxPriorityFee(client)); + const maxFeePerGas = overrides.maxFeePerGas ?? (await fetchGasPrice(client)); + const gasLimit = overrides.gasLimit ?? estimateIntrinsicGas(calls); + const accessList = overrides.accessList ?? []; + + return { + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + accessList, + }; +} + +async function fetchChainId(client: Client): Promise { + const result = await client.request({ method: 'eth_chainId' }); + if (!isHex(result)) throw new Error('eth_chainId returned non-hex'); + return hexToBigIntSafe(result); +} + +async function fetchNonce(client: Client, address: Address): Promise { + const result = await client.request({ + method: 'eth_getTransactionCount', + params: [address, 'pending'], + }); + if (!isHex(result)) throw new Error('eth_getTransactionCount returned non-hex'); + return hexToBigIntSafe(result); +} + +async function fetchMaxPriorityFee(client: Client): Promise { + try { + const result = await client.request({ method: 'eth_maxPriorityFeePerGas' }); + if (!isHex(result)) throw new Error('eth_maxPriorityFeePerGas returned non-hex'); + return hexToBigIntSafe(result); + } catch { + return 0n; + } +} + +async function fetchGasPrice(client: Client): Promise { + const result = await client.request({ method: 'eth_gasPrice' }); + if (!isHex(result)) throw new Error('eth_gasPrice returned non-hex'); + return hexToBigIntSafe(result); +} + +function hexToBigIntSafe(value: unknown): bigint { + if (typeof value !== 'string' || !value.startsWith('0x')) { + throw new Error('Invalid hex value'); + } + return value === '0x' ? 0n : hexToBigInt(value as `0x${string}`); +} diff --git a/clients/src/encoding.ts b/clients/src/encoding.ts new file mode 100644 index 0000000..7f18503 --- /dev/null +++ b/clients/src/encoding.ts @@ -0,0 +1,326 @@ +import { + type AccessList, + type Address, + type Hex, + type Signature, + bytesToHex, + concat, + fromRlp, + hexToBigInt, + hexToBytes, + hexToSignature, + isHex, + keccak256, + toHex, + toRlp, +} from 'viem'; + +import { + EVNODE_TX_TYPE, + EVNODE_EXECUTOR_DOMAIN, + EVNODE_SPONSOR_DOMAIN, + type RlpValue, + type Call, + type EvNodeTransaction, + type EvNodeSignedTransaction, +} from './types.js'; + +const BASE_TX_GAS = 21000n; +// Extra gas charged when a call deploys a new contract (to === null) +const CREATE_GAS = 32000n; +const EVNODE_TX_FIELD_COUNT = 11; +const EMPTY_BYTES = '0x' as const; +const TX_TYPE_HEX = toHex(EVNODE_TX_TYPE, { size: 1 }); +const EXECUTOR_DOMAIN_HEX = toHex(EVNODE_EXECUTOR_DOMAIN, { size: 1 }); +const SPONSOR_DOMAIN_HEX = toHex(EVNODE_SPONSOR_DOMAIN, { size: 1 }); + +export function encodeSignedTransaction(signedTx: EvNodeSignedTransaction): Hex { + const fields = buildPayloadFields(signedTx.transaction, true); + const execSig = normalizeSignatureForRlp(signedTx.executorSignature); + const envelope = toRlp([ + ...fields, + rlpHexFromBigInt(BigInt(execSig.v)), + rlpHexFromBigInt(hexToBigInt(execSig.r)), + rlpHexFromBigInt(hexToBigInt(execSig.s)), + ]); + return concat([TX_TYPE_HEX, envelope]); +} + +export function decodeEvNodeTransaction(encoded: Hex): EvNodeSignedTransaction { + const bytes = hexToBytes(encoded); + if (bytes.length === 0 || bytes[0] !== EVNODE_TX_TYPE) { + throw new Error('Invalid EvNode transaction type'); + } + + const decoded = fromRlp(bytesToHex(bytes.slice(1))) as RlpValue; + if (!Array.isArray(decoded)) { + throw new Error('Invalid EvNode transaction payload'); + } + + if (decoded.length !== EVNODE_TX_FIELD_COUNT) { + throw new Error('Invalid EvNode transaction length'); + } + + const [ + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + calls, + accessList, + feePayerSignature, + v, + r, + s, + ] = decoded; + + const transaction: EvNodeTransaction = { + chainId: hexToBigIntSafe(chainId), + nonce: hexToBigIntSafe(nonce), + maxPriorityFeePerGas: hexToBigIntSafe(maxPriorityFeePerGas), + maxFeePerGas: hexToBigIntSafe(maxFeePerGas), + gasLimit: hexToBigIntSafe(gasLimit), + calls: decodeCalls(calls), + accessList: decodeAccessList(accessList), + feePayerSignature: decodeSignature(feePayerSignature), + }; + + const executorSignature = signatureFromParts(v, r, s); + return { transaction, executorSignature }; +} + +export function computeExecutorSigningHash(tx: EvNodeTransaction): Hex { + const payload = toRlp(buildPayloadFields(tx, false)); + return keccak256(concat([EXECUTOR_DOMAIN_HEX, payload])); +} + +export function computeSponsorSigningHash( + tx: EvNodeTransaction, + executorAddress: Address, +): Hex { + const payload = encodePayloadFieldsNoList(tx, false); + // Sponsor hash preimage: 0x78 || executor_address (20 bytes) || RLP(field encodings without list header). + return keccak256(concat([SPONSOR_DOMAIN_HEX, executorAddress, payload])); +} + +export function computeTxHash(signedTx: EvNodeSignedTransaction): Hex { + return keccak256(encodeSignedTransaction(signedTx)); +} + +function isCreateCall(call: Call): boolean { + return call.to === null; +} + +export function estimateIntrinsicGas(calls: Call[]): bigint { + let gas = BASE_TX_GAS; // base transaction cost + + for (const call of calls) { + gas += BASE_TX_GAS; // each call costs at least 21000 gas + if (isCreateCall(call)) gas += CREATE_GAS; + + for (const byte of hexToBytes(call.data)) { + if (byte === 0) { + gas += 4n; + } else { + gas += 16n; + } + } + } + + return gas; +} + +export function validateEvNodeTx(tx: EvNodeTransaction): void { + if (tx.calls.length === 0) { + throw new Error('EvNode transaction must include at least one call'); + } + + for (let i = 1; i < tx.calls.length; i += 1) { + if (isCreateCall(tx.calls[i])) { + throw new Error('Only the first call may be CREATE'); + } + } +} + +export function normalizeSignature(signature: Signature): { yParity: number; r: Hex; s: Hex; v?: bigint } { + const parsed = typeof signature === 'string' ? hexToSignature(signature) : signature; + + const v = Number(parsed.v ?? parsed.yParity); + const normalizedV = v === 27 || v === 28 ? v - 27 : v; + if (normalizedV !== 0 && normalizedV !== 1) { + throw new Error('Invalid signature v value'); + } + + return { + yParity: normalizedV, + r: padTo32Bytes(parsed.r), + s: padTo32Bytes(parsed.s), + }; +} + +// --- internal helpers --- + +function buildPayloadFields(tx: EvNodeTransaction, includeSponsorSig: boolean): RlpValue[] { + return [ + rlpHexFromBigInt(tx.chainId), + rlpHexFromBigInt(tx.nonce), + rlpHexFromBigInt(tx.maxPriorityFeePerGas), + rlpHexFromBigInt(tx.maxFeePerGas), + rlpHexFromBigInt(tx.gasLimit), + encodeCalls(tx.calls), + encodeAccessList(tx.accessList), + includeSponsorSig && tx.feePayerSignature + ? encodeSponsorSignature(tx.feePayerSignature) + : EMPTY_BYTES, + ]; +} + +function encodePayloadFieldsNoList( + tx: EvNodeTransaction, + includeSponsorSig: boolean, +): Hex { + const fields = buildPayloadFields(tx, includeSponsorSig); + const encodedFields = fields.map((field) => toRlp(field)); + return concat(encodedFields); +} + +function encodeCalls(calls: Call[]): RlpValue[] { + return calls.map((call) => [ + call.to ?? EMPTY_BYTES, + rlpHexFromBigInt(call.value), + call.data, + ]); +} + +function decodeCalls(value: RlpValue): Call[] { + if (!Array.isArray(value)) { + throw new Error('Invalid EvNode calls encoding'); + } + + return value.map((call) => { + if (!Array.isArray(call) || call.length !== 3) { + throw new Error('Invalid EvNode call encoding'); + } + + const [to, val, data] = call; + if (!isHex(to) || !isHex(val) || !isHex(data)) { + throw new Error('Invalid EvNode call values'); + } + + return { + to: to === EMPTY_BYTES ? null : (to as Address), + value: hexToBigIntSafe(val), + data, + }; + }); +} + +function encodeAccessList(accessList: AccessList): RlpValue[] { + return accessList.map((item) => [item.address, [...item.storageKeys]]); +} + +function decodeAccessList(value: RlpValue): AccessList { + if (!Array.isArray(value)) { + throw new Error('Invalid access list encoding'); + } + + return value.map((item) => { + if (!Array.isArray(item) || item.length !== 2) { + throw new Error('Invalid access list item encoding'); + } + + const [address, storageKeys] = item; + if (!isHex(address) || !Array.isArray(storageKeys)) { + throw new Error('Invalid access list values'); + } + + return { + address: address as Address, + storageKeys: storageKeys.map((key) => { + if (!isHex(key)) throw new Error('Invalid storage key'); + return key; + }), + }; + }); +} + +function encodeSponsorSignature(signature: Signature): RlpValue { + // Encode sponsor signature as 65-byte signature bytes (r || s || v). + // This matches the common Signature encoding used by alloy primitives. + if (typeof signature === 'string') { + return signature; + } + const normalized = normalizeSignatureForRlp(signature); + const vByte = toHex(normalized.v, { size: 1 }); + return concat([normalized.r, normalized.s, vByte]); +} + +function decodeSignature(value: RlpValue): Signature | undefined { + if (value === EMPTY_BYTES) return undefined; + + if (!Array.isArray(value) || value.length !== 3) { + if (isHex(value)) { + return signatureFromBytes(value); + } + throw new Error('Invalid sponsor signature encoding'); + } + + const [v, r, s] = value; + return signatureFromParts(v, r, s); +} + +function signatureFromBytes(value: Hex): Signature { + const bytes = hexToBytes(value); + if (bytes.length !== 65) { + throw new Error('Invalid sponsor signature length'); + } + const r = bytesToHex(bytes.slice(0, 32)); + const s = bytesToHex(bytes.slice(32, 64)); + const vRaw = bytes[64]; + const v = vRaw === 27 || vRaw === 28 ? vRaw - 27 : vRaw; + if (v !== 0 && v !== 1) { + throw new Error('Invalid signature v value'); + } + return { yParity: v, v: BigInt(v), r: padTo32Bytes(r), s: padTo32Bytes(s) }; +} + +function signatureFromParts(v: RlpValue, r: RlpValue, s: RlpValue): Signature { + if (!isHex(v) || !isHex(r) || !isHex(s)) { + throw new Error('Invalid signature fields'); + } + + const vNumber = Number(hexToBigIntSafe(v)); + if (vNumber !== 0 && vNumber !== 1) { + throw new Error('Invalid signature v value'); + } + + return { + yParity: vNumber, + v: BigInt(vNumber), + r: padTo32Bytes(r), + s: padTo32Bytes(s), + }; +} + +function normalizeSignatureForRlp(signature: Signature): { v: number; r: Hex; s: Hex } { + const normalized = normalizeSignature(signature); + return { + v: normalized.yParity, + r: normalized.r, + s: normalized.s, + }; +} + +function padTo32Bytes(value: Hex): Hex { + return toHex(hexToBigIntSafe(value), { size: 32 }); +} + +function rlpHexFromBigInt(value: bigint): Hex { + return value === 0n ? EMPTY_BYTES : toHex(value); +} + +function hexToBigIntSafe(value: RlpValue): bigint { + if (!isHex(value)) throw new Error('Invalid hex value'); + return value === EMPTY_BYTES ? 0n : hexToBigInt(value); +} diff --git a/clients/src/index.ts b/clients/src/index.ts index 857e7c9..8d3f29e 100644 --- a/clients/src/index.ts +++ b/clients/src/index.ts @@ -1,646 +1,39 @@ -import { - type AccessList, - type Address, - type Client, - type Hex, - type Signature, - bytesToHex, - concat, - fromRlp, - hexToBigInt, - hexToBytes, - hexToSignature, - isHex, - keccak256, - recoverAddress, - toHex, - toRlp, -} from 'viem'; - -export const EVNODE_TX_TYPE = 0x76; -export const EVNODE_EXECUTOR_DOMAIN = 0x76; -export const EVNODE_SPONSOR_DOMAIN = 0x78; - -const BASE_TX_GAS = 21000n; -// Extra gas charged when a call deploys a new contract (to === null) -const CREATE_GAS = 32000n; -const EVNODE_TX_FIELD_COUNT = 11; -const EMPTY_BYTES = '0x' as const; -const TX_TYPE_HEX = toHex(EVNODE_TX_TYPE, { size: 1 }); -const EXECUTOR_DOMAIN_HEX = toHex(EVNODE_EXECUTOR_DOMAIN, { size: 1 }); -const SPONSOR_DOMAIN_HEX = toHex(EVNODE_SPONSOR_DOMAIN, { size: 1 }); - -type RlpValue = Hex | RlpValue[]; - -export interface Call { - to: Address | null; - value: bigint; - data: Hex; -} - -export interface EvNodeTransaction { - chainId: bigint; - nonce: bigint; - maxPriorityFeePerGas: bigint; - maxFeePerGas: bigint; - gasLimit: bigint; - calls: Call[]; - accessList: AccessList; - feePayerSignature?: Signature; -} - -export interface EvNodeSignedTransaction { - transaction: EvNodeTransaction; - executorSignature: Signature; -} - -export interface SponsorableIntent { - tx: EvNodeTransaction; - executorSignature: Signature; - executorAddress: Address; -} - -export interface HashSigner { - address: Address; - // Must sign the raw 32-byte hash without EIP-191 prefixing. - signHash: (hash: Hex) => Promise; -} - -export interface EvnodeClientOptions { - client: Client; - executor?: HashSigner; - sponsor?: HashSigner; -} - -export interface EvnodeSendArgs { - calls: Call[]; - executor?: HashSigner; - chainId?: bigint; - nonce?: bigint; - maxFeePerGas?: bigint; - maxPriorityFeePerGas?: bigint; - gasLimit?: bigint; - accessList?: AccessList; -} - -type EvnodeSendArgsWithExecutor = Omit & { - executor: HashSigner; -}; - -export interface EvnodeSponsorArgs { - intent: SponsorableIntent; - sponsor?: HashSigner; -} - -export function encodeSignedTransaction(signedTx: EvNodeSignedTransaction): Hex { - const fields = buildPayloadFields(signedTx.transaction, true); - const execSig = normalizeSignatureForRlp(signedTx.executorSignature); - const envelope = toRlp([ - ...fields, - rlpHexFromBigInt(BigInt(execSig.v)), - rlpHexFromBigInt(hexToBigInt(execSig.r)), - rlpHexFromBigInt(hexToBigInt(execSig.s)), - ]); - return concat([TX_TYPE_HEX, envelope]); -} - -export function decodeEvNodeTransaction(encoded: Hex): EvNodeSignedTransaction { - const bytes = hexToBytes(encoded); - if (bytes.length === 0 || bytes[0] !== EVNODE_TX_TYPE) { - throw new Error('Invalid EvNode transaction type'); - } - - const decoded = fromRlp(bytesToHex(bytes.slice(1))) as RlpValue; - if (!Array.isArray(decoded)) { - throw new Error('Invalid EvNode transaction payload'); - } - - if (decoded.length !== EVNODE_TX_FIELD_COUNT) { - throw new Error('Invalid EvNode transaction length'); - } - - const [ - chainId, - nonce, - maxPriorityFeePerGas, - maxFeePerGas, - gasLimit, - calls, - accessList, - feePayerSignature, - v, - r, - s, - ] = decoded; - - const transaction: EvNodeTransaction = { - chainId: hexToBigIntSafe(chainId), - nonce: hexToBigIntSafe(nonce), - maxPriorityFeePerGas: hexToBigIntSafe(maxPriorityFeePerGas), - maxFeePerGas: hexToBigIntSafe(maxFeePerGas), - gasLimit: hexToBigIntSafe(gasLimit), - calls: decodeCalls(calls), - accessList: decodeAccessList(accessList), - feePayerSignature: decodeSignature(feePayerSignature), - }; - - const executorSignature = signatureFromParts(v, r, s); - return { transaction, executorSignature }; -} - -export function computeExecutorSigningHash(tx: EvNodeTransaction): Hex { - const payload = toRlp(buildPayloadFields(tx, false)); - return keccak256(concat([EXECUTOR_DOMAIN_HEX, payload])); -} - -export function computeSponsorSigningHash( - tx: EvNodeTransaction, - executorAddress: Address, -): Hex { - const payload = encodePayloadFieldsNoList(tx, false); - // Sponsor hash preimage: 0x78 || executor_address (20 bytes) || RLP(field encodings without list header). - return keccak256(concat([SPONSOR_DOMAIN_HEX, executorAddress, payload])); -} - -export function computeTxHash(signedTx: EvNodeSignedTransaction): Hex { - return keccak256(encodeSignedTransaction(signedTx)); -} - -export async function recoverExecutor( - signedTx: EvNodeSignedTransaction, -): Promise
{ - const hash = computeExecutorSigningHash(signedTx.transaction); - return recoverAddress({ hash, signature: normalizeSignature(signedTx.executorSignature) }); -} - -export async function recoverSponsor( - tx: EvNodeTransaction, - executorAddress: Address, -): Promise
{ - if (!tx.feePayerSignature) return null; - const hash = computeSponsorSigningHash(tx, executorAddress); - return recoverAddress({ hash, signature: normalizeSignature(tx.feePayerSignature) }); -} - -export async function signAsExecutor( - tx: EvNodeTransaction, - signer: HashSigner, -): Promise { - const hash = computeExecutorSigningHash(tx); - return signer.signHash(hash); -} - -export async function signAsSponsor( - tx: EvNodeTransaction, - executorAddress: Address, - signer: HashSigner, -): Promise { - const hash = computeSponsorSigningHash(tx, executorAddress); - return signer.signHash(hash); -} - -function isCreateCall(call: Call): boolean { - return call.to === null; -} - -export function estimateIntrinsicGas(calls: Call[]): bigint { - let gas = BASE_TX_GAS; // base transaction cost - - for (const call of calls) { - gas += BASE_TX_GAS; // each call costs at least 21000 gas - if (isCreateCall(call)) gas += CREATE_GAS; - - for (const byte of hexToBytes(call.data)) { - if (byte === 0) { - gas += 4n; - } else { - gas += 16n; - } - } - } - - return gas; -} - -export function validateEvNodeTx(tx: EvNodeTransaction): void { - if (tx.calls.length === 0) { - throw new Error('EvNode transaction must include at least one call'); - } - - for (let i = 1; i < tx.calls.length; i += 1) { - if (isCreateCall(tx.calls[i])) { - throw new Error('Only the first call may be CREATE'); - } - } -} - -export function evnodeActions(client: Client) { - return { - async sendEvNodeTransaction(args: EvnodeSendArgsWithExecutor): Promise { - const base = await resolveBaseFields(client, args.executor.address, { - chainId: args.chainId, - nonce: args.nonce, - maxFeePerGas: args.maxFeePerGas, - maxPriorityFeePerGas: args.maxPriorityFeePerGas, - gasLimit: args.gasLimit, - accessList: args.accessList, - }, args.calls); - - const tx: EvNodeTransaction = { - ...base, - calls: args.calls, - feePayerSignature: undefined, - }; - - validateEvNodeTx(tx); - - const executorSignature = await signAsExecutor(tx, args.executor); - const signedTx: EvNodeSignedTransaction = { - transaction: tx, - executorSignature, - }; - - const serialized = encodeSignedTransaction(signedTx); - return client.request({ - method: 'eth_sendRawTransaction', - params: [serialized], - }) as Promise; - }, - - async createSponsorableIntent(args: EvnodeSendArgsWithExecutor): Promise { - const base = await resolveBaseFields(client, args.executor.address, { - chainId: args.chainId, - nonce: args.nonce, - maxFeePerGas: args.maxFeePerGas, - maxPriorityFeePerGas: args.maxPriorityFeePerGas, - gasLimit: args.gasLimit, - accessList: args.accessList, - }, args.calls); - - const tx: EvNodeTransaction = { - ...base, - calls: args.calls, - feePayerSignature: undefined, - }; - - validateEvNodeTx(tx); - - const executorSignature = await signAsExecutor(tx, args.executor); - - return { - tx, - executorSignature, - executorAddress: args.executor.address, - }; - }, - - async sponsorIntent(args: { - intent: SponsorableIntent; - sponsor: HashSigner; - }): Promise { - const sponsorSignature = await signAsSponsor( - args.intent.tx, - args.intent.executorAddress, - args.sponsor, - ); - - return { - transaction: { - ...args.intent.tx, - feePayerSignature: sponsorSignature, - }, - executorSignature: args.intent.executorSignature, - }; - }, - - serializeEvNodeTransaction(signedTx: EvNodeSignedTransaction): Hex { - return encodeSignedTransaction(signedTx); - }, - - deserializeEvNodeTransaction(encoded: Hex): EvNodeSignedTransaction { - return decodeEvNodeTransaction(encoded); - }, - }; -} - -export function createEvnodeClient(options: EvnodeClientOptions) { - const actions = evnodeActions(options.client); - let defaultExecutor = options.executor; - let defaultSponsor = options.sponsor; - - const requireExecutor = (executor?: HashSigner) => { - const resolved = executor ?? defaultExecutor; - if (!resolved) throw new Error('Executor signer is required'); - return resolved; - }; - - const requireSponsor = (sponsor?: HashSigner) => { - const resolved = sponsor ?? defaultSponsor; - if (!resolved) throw new Error('Sponsor signer is required'); - return resolved; - }; - - return { - client: options.client, - actions, - setDefaultExecutor(executor: HashSigner) { - defaultExecutor = executor; - }, - setDefaultSponsor(sponsor: HashSigner) { - defaultSponsor = sponsor; - }, - send(args: EvnodeSendArgs): Promise { - return actions.sendEvNodeTransaction({ - ...args, - executor: requireExecutor(args.executor), - }); - }, - createIntent(args: EvnodeSendArgs): Promise { - return actions.createSponsorableIntent({ - ...args, - executor: requireExecutor(args.executor), - }); - }, - sponsorIntent(args: EvnodeSponsorArgs): Promise { - return actions.sponsorIntent({ - intent: args.intent, - sponsor: requireSponsor(args.sponsor), - }); - }, - async sponsorAndSend(args: EvnodeSponsorArgs): Promise { - const signed = await actions.sponsorIntent({ - intent: args.intent, - sponsor: requireSponsor(args.sponsor), - }); - const serialized = actions.serializeEvNodeTransaction(signed); - return options.client.request({ - method: 'eth_sendRawTransaction', - params: [serialized], - }) as Promise; - }, - serialize: actions.serializeEvNodeTransaction, - deserialize: actions.deserializeEvNodeTransaction, - }; -} - -export function hashSignerFromRpcClient( - client: Client, - address: Address, -): HashSigner { - return { - address, - signHash: async (hash) => { - // eth_sign is expected to sign raw bytes (no EIP-191 prefix). - const signature = await client.request({ - method: 'eth_sign', - params: [address, hash], - }); - if (!isHex(signature)) { - throw new Error('eth_sign returned non-hex signature'); - } - return hexToSignature(signature); - }, - }; -} - -function buildPayloadFields(tx: EvNodeTransaction, includeSponsorSig: boolean): RlpValue[] { - return [ - rlpHexFromBigInt(tx.chainId), - rlpHexFromBigInt(tx.nonce), - rlpHexFromBigInt(tx.maxPriorityFeePerGas), - rlpHexFromBigInt(tx.maxFeePerGas), - rlpHexFromBigInt(tx.gasLimit), - encodeCalls(tx.calls), - encodeAccessList(tx.accessList), - includeSponsorSig && tx.feePayerSignature - ? encodeSponsorSignature(tx.feePayerSignature) - : EMPTY_BYTES, - ]; -} - -function encodePayloadFieldsNoList( - tx: EvNodeTransaction, - includeSponsorSig: boolean, -): Hex { - const fields = buildPayloadFields(tx, includeSponsorSig); - const encodedFields = fields.map((field) => toRlp(field)); - return concat(encodedFields); -} - -function encodeCalls(calls: Call[]): RlpValue[] { - return calls.map((call) => [ - call.to ?? EMPTY_BYTES, - rlpHexFromBigInt(call.value), - call.data, - ]); -} - -function decodeCalls(value: RlpValue): Call[] { - if (!Array.isArray(value)) { - throw new Error('Invalid EvNode calls encoding'); - } - - return value.map((call) => { - if (!Array.isArray(call) || call.length !== 3) { - throw new Error('Invalid EvNode call encoding'); - } - - const [to, val, data] = call; - if (!isHex(to) || !isHex(val) || !isHex(data)) { - throw new Error('Invalid EvNode call values'); - } - - return { - to: to === EMPTY_BYTES ? null : (to as Address), - value: hexToBigIntSafe(val), - data, - }; - }); -} - -function encodeAccessList(accessList: AccessList): RlpValue[] { - return accessList.map((item) => [item.address, [...item.storageKeys]]); -} - -function decodeAccessList(value: RlpValue): AccessList { - if (!Array.isArray(value)) { - throw new Error('Invalid access list encoding'); - } - - return value.map((item) => { - if (!Array.isArray(item) || item.length !== 2) { - throw new Error('Invalid access list item encoding'); - } - - const [address, storageKeys] = item; - if (!isHex(address) || !Array.isArray(storageKeys)) { - throw new Error('Invalid access list values'); - } - - return { - address: address as Address, - storageKeys: storageKeys.map((key) => { - if (!isHex(key)) throw new Error('Invalid storage key'); - return key; - }), - }; - }); -} - -function encodeSponsorSignature(signature: Signature): RlpValue { - // Encode sponsor signature as 65-byte signature bytes (r || s || v). - // This matches the common Signature encoding used by alloy primitives. - if (typeof signature === 'string') { - return signature; - } - const normalized = normalizeSignatureForRlp(signature); - const vByte = toHex(normalized.v, { size: 1 }); - return concat([normalized.r, normalized.s, vByte]); -} - -function decodeSignature(value: RlpValue): Signature | undefined { - if (value === EMPTY_BYTES) return undefined; - - if (!Array.isArray(value) || value.length !== 3) { - if (isHex(value)) { - return signatureFromBytes(value); - } - throw new Error('Invalid sponsor signature encoding'); - } - - const [v, r, s] = value; - return signatureFromParts(v, r, s); -} - -function signatureFromBytes(value: Hex): Signature { - const bytes = hexToBytes(value); - if (bytes.length !== 65) { - throw new Error('Invalid sponsor signature length'); - } - const r = bytesToHex(bytes.slice(0, 32)); - const s = bytesToHex(bytes.slice(32, 64)); - const vRaw = bytes[64]; - const v = vRaw === 27 || vRaw === 28 ? vRaw - 27 : vRaw; - if (v !== 0 && v !== 1) { - throw new Error('Invalid signature v value'); - } - return { yParity: v, v: BigInt(v), r: padTo32Bytes(r), s: padTo32Bytes(s) }; -} - -function signatureFromParts(v: RlpValue, r: RlpValue, s: RlpValue): Signature { - if (!isHex(v) || !isHex(r) || !isHex(s)) { - throw new Error('Invalid signature fields'); - } - - const vNumber = Number(hexToBigIntSafe(v)); - if (vNumber !== 0 && vNumber !== 1) { - throw new Error('Invalid signature v value'); - } - - return { - yParity: vNumber, - v: BigInt(vNumber), - r: padTo32Bytes(r), - s: padTo32Bytes(s), - }; -} - -function normalizeSignature(signature: Signature): { yParity: number; r: Hex; s: Hex; v?: bigint } { - const parsed = typeof signature === 'string' ? hexToSignature(signature) : signature; - - const v = Number(parsed.v ?? parsed.yParity); - const normalizedV = v === 27 || v === 28 ? v - 27 : v; - if (normalizedV !== 0 && normalizedV !== 1) { - throw new Error('Invalid signature v value'); - } - - return { - yParity: normalizedV, - r: padTo32Bytes(parsed.r), - s: padTo32Bytes(parsed.s), - }; -} - -function normalizeSignatureForRlp(signature: Signature): { v: number; r: Hex; s: Hex } { - const normalized = normalizeSignature(signature); - return { - v: normalized.yParity, - r: normalized.r, - s: normalized.s, - }; -} - -function padTo32Bytes(value: Hex): Hex { - return toHex(hexToBigIntSafe(value), { size: 32 }); -} - -function rlpHexFromBigInt(value: bigint): Hex { - return value === 0n ? EMPTY_BYTES : toHex(value); -} - -function hexToBigIntSafe(value: RlpValue): bigint { - if (!isHex(value)) throw new Error('Invalid hex value'); - return value === EMPTY_BYTES ? 0n : hexToBigInt(value); -} - -async function resolveBaseFields( - client: Client, - address: Address, - overrides: { - chainId?: bigint; - nonce?: bigint; - maxFeePerGas?: bigint; - maxPriorityFeePerGas?: bigint; - gasLimit?: bigint; - accessList?: AccessList; - }, - calls: Call[], -): Promise> { - const chainId = overrides.chainId ?? (await fetchChainId(client)); - const nonce = overrides.nonce ?? (await fetchNonce(client, address)); - const maxPriorityFeePerGas = - overrides.maxPriorityFeePerGas ?? (await fetchMaxPriorityFee(client)); - const maxFeePerGas = overrides.maxFeePerGas ?? (await fetchGasPrice(client)); - const gasLimit = overrides.gasLimit ?? estimateIntrinsicGas(calls); - const accessList = overrides.accessList ?? []; - - return { - chainId, - nonce, - maxPriorityFeePerGas, - maxFeePerGas, - gasLimit, - accessList, - }; -} - -async function fetchChainId(client: Client): Promise { - const result = await client.request({ method: 'eth_chainId' }); - if (!isHex(result)) throw new Error('eth_chainId returned non-hex'); - return hexToBigIntSafe(result); -} - -async function fetchNonce(client: Client, address: Address): Promise { - const result = await client.request({ - method: 'eth_getTransactionCount', - params: [address, 'pending'], - }); - if (!isHex(result)) throw new Error('eth_getTransactionCount returned non-hex'); - return hexToBigIntSafe(result); -} - -async function fetchMaxPriorityFee(client: Client): Promise { - try { - const result = await client.request({ method: 'eth_maxPriorityFeePerGas' }); - if (!isHex(result)) throw new Error('eth_maxPriorityFeePerGas returned non-hex'); - return hexToBigIntSafe(result); - } catch { - return 0n; - } -} - -async function fetchGasPrice(client: Client): Promise { - const result = await client.request({ method: 'eth_gasPrice' }); - if (!isHex(result)) throw new Error('eth_gasPrice returned non-hex'); - return hexToBigIntSafe(result); -} +export { + EVNODE_TX_TYPE, + EVNODE_EXECUTOR_DOMAIN, + EVNODE_SPONSOR_DOMAIN, + type RlpValue, + type Call, + type EvNodeTransaction, + type EvNodeSignedTransaction, + type SponsorableIntent, + type HashSigner, + type EvnodeClientOptions, + type EvnodeSendArgs, + type EvnodeSendArgsWithExecutor, + type EvnodeSponsorArgs, +} from './types.js'; + +export { + encodeSignedTransaction, + decodeEvNodeTransaction, + computeExecutorSigningHash, + computeSponsorSigningHash, + computeTxHash, + estimateIntrinsicGas, + validateEvNodeTx, + normalizeSignature, +} from './encoding.js'; + +export { + recoverExecutor, + recoverSponsor, + signAsExecutor, + signAsSponsor, + hashSignerFromRpcClient, +} from './signing.js'; + +export { + evnodeActions, + createEvnodeClient, +} from './client.js'; diff --git a/clients/src/signing.ts b/clients/src/signing.ts new file mode 100644 index 0000000..d18e2b2 --- /dev/null +++ b/clients/src/signing.ts @@ -0,0 +1,69 @@ +import { + type Address, + type Client, + type Hex, + type Signature, + hexToSignature, + isHex, + recoverAddress, +} from 'viem'; + +import type { EvNodeTransaction, EvNodeSignedTransaction, HashSigner } from './types.js'; +import { + computeExecutorSigningHash, + computeSponsorSigningHash, + normalizeSignature, +} from './encoding.js'; + +export async function recoverExecutor( + signedTx: EvNodeSignedTransaction, +): Promise
{ + const hash = computeExecutorSigningHash(signedTx.transaction); + return recoverAddress({ hash, signature: normalizeSignature(signedTx.executorSignature) }); +} + +export async function recoverSponsor( + tx: EvNodeTransaction, + executorAddress: Address, +): Promise
{ + if (!tx.feePayerSignature) return null; + const hash = computeSponsorSigningHash(tx, executorAddress); + return recoverAddress({ hash, signature: normalizeSignature(tx.feePayerSignature) }); +} + +export async function signAsExecutor( + tx: EvNodeTransaction, + signer: HashSigner, +): Promise { + const hash = computeExecutorSigningHash(tx); + return signer.signHash(hash); +} + +export async function signAsSponsor( + tx: EvNodeTransaction, + executorAddress: Address, + signer: HashSigner, +): Promise { + const hash = computeSponsorSigningHash(tx, executorAddress); + return signer.signHash(hash); +} + +export function hashSignerFromRpcClient( + client: Client, + address: Address, +): HashSigner { + return { + address, + signHash: async (hash) => { + // eth_sign is expected to sign raw bytes (no EIP-191 prefix). + const signature = await client.request({ + method: 'eth_sign', + params: [address, hash], + }); + if (!isHex(signature)) { + throw new Error('eth_sign returned non-hex signature'); + } + return hexToSignature(signature); + }, + }; +} diff --git a/clients/src/types.ts b/clients/src/types.ts new file mode 100644 index 0000000..f5351fd --- /dev/null +++ b/clients/src/types.ts @@ -0,0 +1,67 @@ +import type { AccessList, Address, Client, Hex, Signature } from 'viem'; + +export const EVNODE_TX_TYPE = 0x76; +export const EVNODE_EXECUTOR_DOMAIN = 0x76; +export const EVNODE_SPONSOR_DOMAIN = 0x78; + +export type RlpValue = Hex | RlpValue[]; + +export interface Call { + to: Address | null; + value: bigint; + data: Hex; +} + +export interface EvNodeTransaction { + chainId: bigint; + nonce: bigint; + maxPriorityFeePerGas: bigint; + maxFeePerGas: bigint; + gasLimit: bigint; + calls: Call[]; + accessList: AccessList; + feePayerSignature?: Signature; +} + +export interface EvNodeSignedTransaction { + transaction: EvNodeTransaction; + executorSignature: Signature; +} + +export interface SponsorableIntent { + tx: EvNodeTransaction; + executorSignature: Signature; + executorAddress: Address; +} + +export interface HashSigner { + address: Address; + // Must sign the raw 32-byte hash without EIP-191 prefixing. + signHash: (hash: Hex) => Promise; +} + +export interface EvnodeClientOptions { + client: Client; + executor?: HashSigner; + sponsor?: HashSigner; +} + +export interface EvnodeSendArgs { + calls: Call[]; + executor?: HashSigner; + chainId?: bigint; + nonce?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + gasLimit?: bigint; + accessList?: AccessList; +} + +export type EvnodeSendArgsWithExecutor = Omit & { + executor: HashSigner; +}; + +export interface EvnodeSponsorArgs { + intent: SponsorableIntent; + sponsor?: HashSigner; +} From 6777c64170d2ea08d4720755db4678fa93de345c Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 10 Feb 2026 10:54:12 +0100 Subject: [PATCH 13/14] test: add unit tests for encoding, signing, gas estimation, and validation --- clients/package.json | 3 +- clients/tests/unit.test.ts | 305 +++++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 clients/tests/unit.test.ts diff --git a/clients/package.json b/clients/package.json index fc6f37f..ece6a13 100644 --- a/clients/package.json +++ b/clients/package.json @@ -18,10 +18,11 @@ "build": "tsc -p tsconfig.build.json", "clean": "rm -rf dist", "prepublishOnly": "npm run clean && npm run build", + "test:unit": "tsx --test tests/unit.test.ts", "test:basic": "tsx tests/basic.test.ts", "test:flows": "tsx tests/flows.test.ts", "test:sponsored": "tsx tests/sponsored.test.ts", - "test": "npm run test:basic" + "test": "npm run test:unit" }, "keywords": [ "ethereum", diff --git a/clients/tests/unit.test.ts b/clients/tests/unit.test.ts new file mode 100644 index 0000000..2195c55 --- /dev/null +++ b/clients/tests/unit.test.ts @@ -0,0 +1,305 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import type { Hex, Address } from 'viem'; + +import { + encodeSignedTransaction, + decodeEvNodeTransaction, + computeExecutorSigningHash, + computeSponsorSigningHash, + computeTxHash, + estimateIntrinsicGas, + validateEvNodeTx, + normalizeSignature, + signAsExecutor, + signAsSponsor, + recoverExecutor, + recoverSponsor, + type EvNodeTransaction, + type EvNodeSignedTransaction, + type Call, + type HashSigner, +} from '../src/index.ts'; + +const TEST_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const; +const TEST_ACCOUNT = privateKeyToAccount(TEST_KEY); + +function makeHashSigner(key: typeof TEST_KEY): HashSigner { + const account = privateKeyToAccount(key); + return { + address: account.address, + signHash: async (hash: Hex) => sign({ hash, privateKey: key }), + }; +} + +function makeTx(overrides: Partial = {}): EvNodeTransaction { + return { + chainId: 1n, + nonce: 0n, + maxPriorityFeePerGas: 1000000000n, + maxFeePerGas: 2000000000n, + gasLimit: 21000n, + calls: [{ to: TEST_ACCOUNT.address, value: 0n, data: '0x' }], + accessList: [], + ...overrides, + }; +} + +// --- estimateIntrinsicGas --- + +describe('estimateIntrinsicGas', () => { + it('returns base + per-call gas for a simple call', () => { + const calls: Call[] = [{ to: '0x0000000000000000000000000000000000000001', value: 0n, data: '0x' }]; + // base (21000) + 1 call (21000) = 42000 + assert.equal(estimateIntrinsicGas(calls), 42000n); + }); + + it('adds CREATE gas when to is null', () => { + const calls: Call[] = [{ to: null, value: 0n, data: '0x' }]; + // base (21000) + 1 call (21000) + CREATE (32000) = 74000 + assert.equal(estimateIntrinsicGas(calls), 74000n); + }); + + it('charges 4 gas per zero byte, 16 per non-zero byte', () => { + const calls: Call[] = [{ to: '0x0000000000000000000000000000000000000001', value: 0n, data: '0x00ff00' }]; + // base (21000) + 1 call (21000) + 2 zero bytes (8) + 1 non-zero byte (16) = 42024 + assert.equal(estimateIntrinsicGas(calls), 42024n); + }); + + it('handles multiple calls', () => { + const calls: Call[] = [ + { to: '0x0000000000000000000000000000000000000001', value: 0n, data: '0x' }, + { to: '0x0000000000000000000000000000000000000002', value: 0n, data: '0x' }, + ]; + // base (21000) + 2 calls (42000) = 63000 + assert.equal(estimateIntrinsicGas(calls), 63000n); + }); +}); + +// --- validateEvNodeTx --- + +describe('validateEvNodeTx', () => { + it('throws on empty calls', () => { + const tx = makeTx({ calls: [] }); + assert.throws(() => validateEvNodeTx(tx), /at least one call/); + }); + + it('allows CREATE as first call', () => { + const tx = makeTx({ calls: [{ to: null, value: 0n, data: '0x6000' }] }); + assert.doesNotThrow(() => validateEvNodeTx(tx)); + }); + + it('rejects CREATE in non-first position', () => { + const tx = makeTx({ + calls: [ + { to: '0x0000000000000000000000000000000000000001', value: 0n, data: '0x' }, + { to: null, value: 0n, data: '0x6000' }, + ], + }); + assert.throws(() => validateEvNodeTx(tx), /Only the first call may be CREATE/); + }); + + it('accepts multiple regular calls', () => { + const tx = makeTx({ + calls: [ + { to: '0x0000000000000000000000000000000000000001', value: 0n, data: '0x' }, + { to: '0x0000000000000000000000000000000000000002', value: 0n, data: '0x' }, + ], + }); + assert.doesNotThrow(() => validateEvNodeTx(tx)); + }); +}); + +// --- normalizeSignature --- + +describe('normalizeSignature', () => { + it('normalizes v=27 to yParity=0', () => { + const sig = { + v: 27n, + r: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + s: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }; + const normalized = normalizeSignature(sig); + assert.equal(normalized.yParity, 0); + }); + + it('normalizes v=28 to yParity=1', () => { + const sig = { + v: 28n, + r: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + s: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }; + const normalized = normalizeSignature(sig); + assert.equal(normalized.yParity, 1); + }); + + it('keeps v=0 as yParity=0', () => { + const sig = { + v: 0n, + r: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + s: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }; + const normalized = normalizeSignature(sig); + assert.equal(normalized.yParity, 0); + }); + + it('pads r and s to 32 bytes', () => { + const sig = { + v: 0n, + r: '0x01' as Hex, + s: '0x02' as Hex, + }; + const normalized = normalizeSignature(sig); + assert.equal(normalized.r.length, 66); // 0x + 64 hex chars + assert.equal(normalized.s.length, 66); + }); +}); + +// --- encode/decode roundtrip --- + +describe('encode/decode roundtrip', () => { + it('roundtrips a simple signed transaction', async () => { + const tx = makeTx(); + const signer = makeHashSigner(TEST_KEY); + const executorSignature = await signAsExecutor(tx, signer); + + const signedTx: EvNodeSignedTransaction = { transaction: tx, executorSignature }; + const encoded = encodeSignedTransaction(signedTx); + const decoded = decodeEvNodeTransaction(encoded); + + assert.equal(decoded.transaction.chainId, tx.chainId); + assert.equal(decoded.transaction.nonce, tx.nonce); + assert.equal(decoded.transaction.maxPriorityFeePerGas, tx.maxPriorityFeePerGas); + assert.equal(decoded.transaction.maxFeePerGas, tx.maxFeePerGas); + assert.equal(decoded.transaction.gasLimit, tx.gasLimit); + assert.equal(decoded.transaction.calls.length, 1); + assert.equal(decoded.transaction.calls[0].to?.toLowerCase(), tx.calls[0].to?.toLowerCase()); + assert.equal(decoded.transaction.calls[0].value, tx.calls[0].value); + assert.equal(decoded.transaction.calls[0].data, tx.calls[0].data); + assert.equal(decoded.transaction.accessList.length, 0); + assert.equal(decoded.transaction.feePayerSignature, undefined); + }); + + it('roundtrips a transaction with access list', async () => { + const tx = makeTx({ + accessList: [{ + address: '0x0000000000000000000000000000000000000001', + storageKeys: ['0x0000000000000000000000000000000000000000000000000000000000000001'], + }], + }); + const signer = makeHashSigner(TEST_KEY); + const executorSignature = await signAsExecutor(tx, signer); + + const signedTx: EvNodeSignedTransaction = { transaction: tx, executorSignature }; + const encoded = encodeSignedTransaction(signedTx); + const decoded = decodeEvNodeTransaction(encoded); + + assert.equal(decoded.transaction.accessList.length, 1); + assert.equal(decoded.transaction.accessList[0].address, '0x0000000000000000000000000000000000000001'); + assert.equal(decoded.transaction.accessList[0].storageKeys.length, 1); + }); + + it('roundtrips a CREATE transaction', async () => { + const tx = makeTx({ + calls: [{ to: null, value: 0n, data: '0x6000600060006000' }], + }); + const signer = makeHashSigner(TEST_KEY); + const executorSignature = await signAsExecutor(tx, signer); + + const signedTx: EvNodeSignedTransaction = { transaction: tx, executorSignature }; + const encoded = encodeSignedTransaction(signedTx); + const decoded = decodeEvNodeTransaction(encoded); + + assert.equal(decoded.transaction.calls[0].to, null); + assert.equal(decoded.transaction.calls[0].data, '0x6000600060006000'); + }); + + it('produces a deterministic tx hash', async () => { + const tx = makeTx(); + const signer = makeHashSigner(TEST_KEY); + const executorSignature = await signAsExecutor(tx, signer); + const signedTx: EvNodeSignedTransaction = { transaction: tx, executorSignature }; + + const hash1 = computeTxHash(signedTx); + const hash2 = computeTxHash(signedTx); + assert.equal(hash1, hash2); + assert.ok(hash1.startsWith('0x')); + assert.equal(hash1.length, 66); + }); +}); + +// --- signing hashes --- + +describe('signing hashes', () => { + it('executor signing hash is deterministic', () => { + const tx = makeTx(); + assert.equal(computeExecutorSigningHash(tx), computeExecutorSigningHash(tx)); + }); + + it('different txs produce different executor hashes', () => { + const tx1 = makeTx({ nonce: 0n }); + const tx2 = makeTx({ nonce: 1n }); + assert.notEqual(computeExecutorSigningHash(tx1), computeExecutorSigningHash(tx2)); + }); + + it('sponsor signing hash is deterministic', () => { + const tx = makeTx(); + const addr = TEST_ACCOUNT.address; + assert.equal(computeSponsorSigningHash(tx, addr), computeSponsorSigningHash(tx, addr)); + }); + + it('executor and sponsor hashes differ for same tx', () => { + const tx = makeTx(); + const addr = TEST_ACCOUNT.address; + assert.notEqual(computeExecutorSigningHash(tx), computeSponsorSigningHash(tx, addr)); + }); +}); + +// --- sign and recover --- + +describe('sign and recover', () => { + it('recovers executor address from signed tx', async () => { + const tx = makeTx(); + const signer = makeHashSigner(TEST_KEY); + const executorSignature = await signAsExecutor(tx, signer); + const signedTx: EvNodeSignedTransaction = { transaction: tx, executorSignature }; + + const recovered = await recoverExecutor(signedTx); + assert.equal(recovered.toLowerCase(), TEST_ACCOUNT.address.toLowerCase()); + }); + + it('recovers sponsor address from sponsored tx', async () => { + const SPONSOR_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const; + const sponsorAccount = privateKeyToAccount(SPONSOR_KEY); + + const tx = makeTx(); + const executorSigner = makeHashSigner(TEST_KEY); + const sponsorSigner = makeHashSigner(SPONSOR_KEY); + + const sponsorSig = await signAsSponsor(tx, executorSigner.address, sponsorSigner); + const sponsoredTx: EvNodeTransaction = { ...tx, feePayerSignature: sponsorSig }; + + const recovered = await recoverSponsor(sponsoredTx, executorSigner.address); + assert.ok(recovered); + assert.equal(recovered!.toLowerCase(), sponsorAccount.address.toLowerCase()); + }); + + it('recoverSponsor returns null when no sponsor signature', async () => { + const tx = makeTx(); + const recovered = await recoverSponsor(tx, TEST_ACCOUNT.address); + assert.equal(recovered, null); + }); +}); + +// --- decode errors --- + +describe('decode errors', () => { + it('rejects wrong tx type', () => { + assert.throws(() => decodeEvNodeTransaction('0xff'), /Invalid EvNode transaction type/); + }); + + it('rejects empty input', () => { + assert.throws(() => decodeEvNodeTransaction('0x'), /Invalid EvNode transaction type/); + }); +}); From 69ee053ffda3e81cd8fab6dc81731fbd2387c9d2 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 10 Feb 2026 10:55:39 +0100 Subject: [PATCH 14/14] ci: add client unit tests job to unit workflow --- .github/workflows/unit.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 048fdec..463d251 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -38,6 +38,22 @@ jobs: -E "(kind(lib) | kind(bin) | kind(proc-macro))" \ --no-tests=warn + client-test: + name: client unit tests + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install dependencies + working-directory: clients + run: npm ci + - name: Run unit tests + working-directory: clients + run: npm test + doc: name: doc tests env: @@ -57,7 +73,7 @@ jobs: name: unit success runs-on: ubuntu-latest if: always() - needs: [test] + needs: [test, client-test] timeout-minutes: 30 steps: - name: Decide whether the needed jobs succeeded or failed