77import { Command } from "jsr:@cliffy/command@^1.0.0-rc.7" ;
88import {
99 type AddressLike ,
10+ concat ,
11+ decodeRlp ,
12+ encodeRlp ,
1013 getBytes ,
14+ hexlify ,
1115 keccak256 ,
16+ sha256 ,
1217 toUtf8String ,
1318 Transaction ,
1419 Wallet ,
@@ -20,9 +25,99 @@ const STATIC_PREFUNDED = [
2025 "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" ,
2126] ;
2227
28+ export type TxType = "legacy" | "blob" ;
29+
2330const DEFAULT_VALUE_WEI = 100_000_000_000_000_000n ; // 0.1 ETH
2431const DEFAULT_GAS_LIMIT = 21000n ;
2532const DEFAULT_GAS_PRICE = 1_000_000_000n ; // 1 gwei
33+ // EIP-1559/blob fee fields (blob tx is type 3, so it can't use gasPrice).
34+ const DEFAULT_PRIORITY_FEE = 1_000_000_000n ; // 1 gwei
35+ const DEFAULT_MAX_FEE = 10_000_000_000n ; // 10 gwei — covers devnet base fee
36+ const DEFAULT_MAX_BLOB_FEE = 1_000_000_000n ; // 1 gwei — well above the 1 wei min
37+
38+ const BLOB_SIZE = 4096 * 32 ; // one EIP-4844 blob: 4096 field elements × 32 bytes
39+
40+ // kzg-wasm, loaded lazily so the legacy path never pays for the WASM init. Its
41+ // trusted setup is network-agnostic, so the bundled mainnet setup is correct on
42+ // any devnet. v1 exposes the EIP-7594 cell-proof ops required post-Osaka.
43+ type KzgApi = {
44+ blobToKZGCommitment : ( blob : string ) => string ;
45+ computeCellsAndProofs : ( blob : string ) => [ string [ ] , string [ ] ] ;
46+ } ;
47+ let kzgLib : Promise < KzgApi > | null = null ;
48+ function getKzg ( ) : Promise < KzgApi > {
49+ if ( ! kzgLib ) {
50+ kzgLib = import ( "npm:kzg-wasm@^1.0.0" ) . then ( ( m ) => m . loadKZG ( ) as Promise < KzgApi > ) ;
51+ }
52+ return kzgLib ;
53+ }
54+
55+ const hx = ( s : string ) : string => ( s . startsWith ( "0x" ) ? s : `0x${ s } ` ) ;
56+
57+ // A single valid blob: a short marker in the first field element (leading byte
58+ // left 0 so the element stays below the BLS modulus), the rest zero-filled.
59+ function makeBlob ( marker : string ) : Uint8Array {
60+ const blob = new Uint8Array ( BLOB_SIZE ) ;
61+ blob . set ( new TextEncoder ( ) . encode ( marker ) . subarray ( 0 , 31 ) , 1 ) ;
62+ return blob ;
63+ }
64+
65+ // versioned hash = 0x01 ‖ sha256(commitment)[1:] (VERSIONED_HASH_VERSION_KZG)
66+ function blobVersionedHash ( commitment : string ) : string {
67+ const h = getBytes ( sha256 ( hx ( commitment ) ) ) ;
68+ h [ 0 ] = 0x01 ;
69+ return hexlify ( h ) ;
70+ }
71+
72+ // Build a post-Osaka (EIP-7594/PeerDAS) blob tx. ethers only serializes the
73+ // legacy EIP-4844 sidecar, so we let it produce the signed canonical tx
74+ // (0x03 ‖ rlp(body)) and re-wrap it as the cell-proof network form it can't
75+ // emit: 0x03 ‖ rlp([body, wrapper_version=1, blobs, commitments, cell_proofs])
76+ async function signBlobTx ( wallet : Wallet , to : string , nonce : number , cid : bigint ) : Promise < string > {
77+ const kzg = await getKzg ( ) ;
78+ const blob = hexlify ( makeBlob ( "decker blob tx" ) ) ;
79+ const commitment = hx ( kzg . blobToKZGCommitment ( blob ) ) ;
80+ const cellProofs = kzg . computeCellsAndProofs ( blob ) [ 1 ] . map ( hx ) ; // 128 proofs per blob
81+
82+ const tx = new Transaction ( ) ;
83+ tx . type = 3 ;
84+ tx . to = to ;
85+ tx . value = DEFAULT_VALUE_WEI ;
86+ tx . nonce = nonce ;
87+ tx . chainId = cid ;
88+ tx . gasLimit = DEFAULT_GAS_LIMIT ;
89+ tx . maxPriorityFeePerGas = DEFAULT_PRIORITY_FEE ;
90+ tx . maxFeePerGas = DEFAULT_MAX_FEE ;
91+ tx . maxFeePerBlobGas = DEFAULT_MAX_BLOB_FEE ;
92+ tx . blobVersionedHashes = [ blobVersionedHash ( commitment ) ] ;
93+ // Sign the instance directly so we keep the canonical (sidecar-free) signed tx.
94+ tx . signature = wallet . signingKey . sign ( tx . unsignedHash ) ;
95+
96+ const body = decodeRlp ( hx ( tx . serialized . slice ( 4 ) ) ) ; // strip 0x03, decode rlp(body)
97+ const wrapper = encodeRlp ( [ body , "0x01" , [ blob ] , [ commitment ] , cellProofs ] ) ;
98+ return concat ( [ "0x03" , wrapper ] ) ;
99+ }
100+
101+ async function signTx (
102+ type : TxType ,
103+ wallet : Wallet ,
104+ to : string ,
105+ nonce : number ,
106+ cid : bigint ,
107+ ) : Promise < string > {
108+ if ( type === "blob" ) return await signBlobTx ( wallet , to , nonce , cid ) ;
109+ const tx = Transaction . from ( {
110+ type : 0 ,
111+ to,
112+ value : DEFAULT_VALUE_WEI ,
113+ gasLimit : DEFAULT_GAS_LIMIT ,
114+ gasPrice : DEFAULT_GAS_PRICE ,
115+ nonce,
116+ chainId : cid ,
117+ data : "0x" ,
118+ } ) ;
119+ return await wallet . signTransaction ( tx ) ;
120+ }
26121
27122function hhmmss ( d : Date ) : string {
28123 const pad = ( n : number ) => String ( n ) . padStart ( 2 , "0" ) ;
@@ -102,6 +197,7 @@ export type TestOpts = {
102197 retries : number ; // 0 = retry forever
103198 insecure : boolean ;
104199 expectedExtraData ?: string ;
200+ type : TxType ;
105201} ;
106202
107203export async function runTest ( opts : TestOpts ) : Promise < number > {
@@ -120,19 +216,10 @@ export async function runTest(opts: TestOpts): Promise<number> {
120216 const nonce = await pendingNonce ( el , wallet . address ) ;
121217 console . log ( `${ dim ( "Nonce:" ) } ${ accent ( String ( nonce ) ) } ` ) ;
122218
123- const tx = Transaction . from ( {
124- type : 0 ,
125- to : toAddress ,
126- value : DEFAULT_VALUE_WEI ,
127- gasLimit : DEFAULT_GAS_LIMIT ,
128- gasPrice : DEFAULT_GAS_PRICE ,
129- nonce,
130- chainId : cid ,
131- data : "0x" ,
132- } ) ;
133- const signed = await wallet . signTransaction ( tx ) ;
219+ const signed = await signTx ( opts . type , wallet , toAddress , nonce , cid ) ;
134220
135- console . log ( `${ dim ( "Sending transaction at" ) } ${ accent ( hhmmss ( new Date ( ) ) ) } ` ) ;
221+ const label = opts . type === "blob" ? "blob transaction" : "transaction" ;
222+ console . log ( `${ dim ( `Sending ${ label } at` ) } ${ accent ( hhmmss ( new Date ( ) ) ) } ` ) ;
136223 const txHash = await sendRawTx ( target , signed ) ;
137224 console . log ( `${ dim ( "TX Hash:" ) } ${ accent ( txHash ) } ` ) ;
138225
@@ -205,14 +292,22 @@ export const command = new Command()
205292 . option ( "--expected-extra-data <s:string>" , "Verify block extra data matches this string" , {
206293 default : "" ,
207294 } )
295+ . option ( "--type <type:string>" , "Transaction type to send: legacy or blob" , {
296+ default : "legacy" ,
297+ } )
208298 . action ( async ( opts ) => {
299+ if ( opts . type !== "legacy" && opts . type !== "blob" ) {
300+ console . error ( err ( `unknown --type ${ JSON . stringify ( opts . type ) } (expected: legacy, blob)` ) ) ;
301+ Deno . exit ( 2 ) ;
302+ }
209303 Deno . exit ( await runTest ( {
210304 rpc : opts . rpc ,
211305 elRpc : opts . elRpc ,
212306 timeoutMs : parseDuration ( opts . timeout ) ,
213307 retries : opts . retries ,
214308 insecure : opts . insecure ,
215309 expectedExtraData : opts . expectedExtraData ,
310+ type : opts . type ,
216311 } ) ) ;
217312 } ) ;
218313
0 commit comments