diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88bf295..fd0f3a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,8 +11,8 @@ frame transactions as the hegota-testnet chain (chain ID `8141`) accepts them. T envelope is a composition of three draft EIPs that none of them specifies on its own: EIP-8141 frames, EIP-8250 keyed nonces, EIP-8272 recent-root references. -It is a deliberate second implementation. The reference client is -[ethrex, on its `hegota-testnet` branch](https://github.com/lambdaclass/ethrex/tree/hegota-testnet), +It is a deliberate second implementation. The migration is pinned against +[ethrex commit `d587cf9`](https://github.com/lambdaclass/ethrex/tree/d587cf9ff0996315381c4b2784a4d7d499decc0f), and this library exists partly to disagree with it usefully. Every `.rs`, `.py` and `docs/*.md` path cited in this repository's docs and comments — `transaction.rs`, `frame_tx_wire_tests.rs`, `frametx.py` and the rest — is a path in that repository, not in @@ -46,15 +46,14 @@ interchangeable. ```bash bun run test # hermetic, no network -bun run test:live # opt-in, hits rpc1.privacy.ethrex.xyz +bun run test:live # checks rpc1.frames.ethrex.xyz identity bun run typecheck bun run build # tsup -> dist/, dual ESM + CJS with both declaration flavours -bunx tsx scripts/capture-fixtures.ts [from-block] [to-block] ``` CI runs `typecheck`, `test`, `build`, and then `@arethetypeswrong/cli --pack .` against -the built package. All four must pass. The live suite is not expected to pass in every -environment and is not a gate. +the built package. All four must pass. The public frames RPC currently has no +frame-aware simulation method, so there is no live simulation gate. That last check is there because a broken `exports` map or a mismatched `.d.cts` breaks every consumer while leaving the test suite entirely green — the tests import from `src/`, @@ -81,7 +80,7 @@ authority; if the encoder disagrees with them, the encoder is wrong. Fixtures un **`src/gas.ts` must not import `src/envelope.ts`.** The gas model stays independently testable, so it can be independently wrong or independently right. The small duplication -this causes (a `sameAddress` helper, the framing for two calldata blobs) is deliberate. +this causes (for example, a `sameAddress` helper) is deliberate. `src/envelope.ts` must not import `src/signatures.ts`; the reverse direction is fine. **`encodeFrameTx` deliberately does not validate.** `frameTxSigHash` is defined over a @@ -99,8 +98,6 @@ path is `assertValidFrameTx(tx)` then `encodeFrameTx(tx)`. signature makes the transaction invalid at consensus, not merely unrelayable. - **RLP scalars are minimal big-endian**; zero is the empty string `0x`, not `0x00`. Never pass viem's `numberToHex` into `toRlp`; use `rlpUint`. -- **`recentRootCalldata` is empty (`0x`) when no reference is declared**, not `toRlp([])` - (`0xc0`). Getting this wrong adds billed bytes to every transaction on the chain. - **State gas is added on top of the calldata floor**, never absorbed by it. - **`limits` is always a two-element list**, including when `state` is zero. - **The frame's JSON field for the target is `to`, not `target`.** The node's `type` is @@ -170,7 +167,7 @@ That means **your commit messages set the version**. Use Pull request titles are validated by CI against that list, with an optional scope from `envelope`, `sighash`, `signatures`, `gas`, `divergence`, `rlp`, `rpc`, `viem`, `fixtures`, `docs`, `deps`, `ci`, `repo` — for example -`feat(gas): model the head EIP-8272 reference arm`. Subjects start lowercase. +`fix(envelope): encode the scalar frame nonce`. Subjects start lowercase. Do not hand-edit `version` in `package.json` or touch `CHANGELOG.md`; the release commit owns both. A wire-format or gas change is almost always at least a `fix:`, because @@ -184,10 +181,8 @@ someone downstream is encoding bytes with this. published figure, a golden vector, or captured chain data, not to the code's own output. - If you touched anything in **Rules that are not style preferences**, say why in the PR description. Those changes are not refused, but they are argued. -- Fixtures expire. They record the client version and genesis hash they were captured - against and the oracle suite asserts the genesis hash, so a re-genesis fails loudly. - This chain is already on its third genesis; re-capture with the script rather than - hand-editing. +- Files under `test/fixtures/chain` are archived captures from the superseded envelope. + Do not use them as current wire-format oracles. ## Where the rest of the docs are diff --git a/README.md b/README.md index 33d01c9..8648e43 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,8 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/JustaLab-co/frametx-kit/badge)](https://scorecard.dev/viewer/?uri=github.com/JustaLab-co/frametx-kit) -TypeScript for **EIP-8141 frame transactions** as hegota-testnet (chain ID `8141`) -actually accepts them: the composed envelope that also carries EIP-8250 keyed nonces -and EIP-8272 recent-root references. +TypeScript for the current **EIP-8141 frame transaction** envelope implemented by +the Ethrex frames devnet (chain ID `81410`). Decode, build, hash, sign, price, dry-run and broadcast. @@ -27,7 +26,7 @@ import { createPublicClient, http } from 'viem' import { frameActions } from '@jaw.id/frametx-kit/viem' const client = createPublicClient({ - transport: http('https://rpc1.privacy.ethrex.xyz'), + transport: http('https://rpc1.frames.ethrex.xyz'), }).extend(frameActions) const tx = await client.getFrameTransaction({ hash }) @@ -46,7 +45,7 @@ assertValidFrameTx(signed) const raw = encodeFrameTx(signed) ``` -`signFrameTx` also takes an account instead of a key — anything with an `address` and a +`signFrameTx` also takes a `FrameSigner` instead of a key — anything with an `address` and a raw-digest `sign`, which covers viem's `privateKeyToAccount` and `mnemonicToAccount`, a `toAccount` source, and your own wrapper around a hardware wallet, an HSM or a remote signer. It signs every SECP256K1 entry whose `msg` is empty, and refuses if the entry's @@ -98,45 +97,17 @@ the node's decoded JSON, re-encodes it, and refuses to return anything whose re- does not reproduce the transaction hash. What you get back is a transaction this library can put back on the wire byte-for-byte. -## Gas and rule sets +## Gas -Gas is priced under one of three rule sets, because the deployed binary and the pinned -EIP text disagree: - -| Rule set | Meaning | -|---|---| -| `'chain'` | ethrex `31b532266`, what the nodes actually run | -| `'pins'` | the pinned EIP text | -| `'head'` | current drafts, for anticipating the next re-genesis | +`frameTxGas` prices the current EIP-8141 transaction shape. The historical +`'chain'`, `'pins'`, and `'head'` rule-set names remain accepted as compatibility +aliases and currently produce identical results. ```ts import { compareRuleSets, decodeFrameTx } from '@jaw.id/frametx-kit' console.log(compareRuleSets(decodeFrameTx(raw), 'chain', 'pins')) ``` -The head EIP-8272 draft alters the *envelope*, not just the price: the recent-root field -is gone and references travel as a leading VERIFY frame against -`0x0000000000000000000000000000000000008272`, 72 bytes each. `compareRuleSets` prices -whichever side names `'head'` over `toHeadShape(tx)`, which applies exactly that change -as a transformation of the transaction, so a reference-carrying transaction is surveyed -rather than refused. - -```ts -import { toHeadShape } from '@jaw.id/frametx-kit' -compareRuleSets(tx, 'pins', 'head') // prices the head side over toHeadShape(tx) -toHeadShape(tx) // the same transform on its own; identity when no reference is carried -``` - -The synthetic frame claims zero execution and state. Zero state is normative: the draft -pins `limits.state` for the recent root verifier frame, along with its mode, target, flags -and value. `limits.execution` is the one figure it leaves open — that falls out of the -`STATICCALL` and one `SLOAD` per tuple — so every limit-derived term of the head price is -still a floor, fine to compare against and wrong to budget with. That is why -`frameTxGas(tx, 'head')` still throws for a reference-carrying transaction rather than -hand you a floor that reads like a budget. `'head'` otherwise prices identically to -`'pins'`; the EIP-8250 change it models is an execution-time charge against -`limits.state`, published as `HEAD_KEYED_NONCE_STATE_GAS`. - Every gas constant is written as its published figure rather than derived. ethrex's own suite missed its intrinsic dropping from 15000 to 12000 across 1372 tests by deriving the expected value from the constant under test. @@ -145,13 +116,11 @@ expected value from the constant under test. ```bash bun run test # hermetic, no network -bun run test:live # hits rpc1.privacy.ethrex.xyz bun run typecheck ``` -The default suite runs entirely off checked-in fixtures. Those are real transactions -captured with `bunx tsx scripts/capture-fixtures.ts`; each records the client version and -genesis hash it was taken against, so a re-genesis fails loudly rather than silently. +The default suite is hermetic. Its golden RLP and signature hash are pinned to the +current Ethrex `FrameTransaction` encoder. ## Contributing diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 78f98d8..a9f595d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,31 +1,22 @@ -# frametx-kit — design +# frametx-kit design -**Date:** 2026-09-06 -**Status:** implemented; binding on the code in this repository. Reviewed against ethrex `hegota-testnet` @ `19c065fa8` and the live node on 2026-09-06; §5, §6 and §9 amended for the RPC surface actually served (no raw transaction bytes). §5, §7 and §8 amended 2026-09-07 to match the module graph, strict-decode surface and entry-point name as built. §7 amended 2026-09-08: the byte-offset promise is delivered by a hand-rolled `walkRlp`, not deferred. §4 amended 2026-09-08: the `'head'` EIP-8272 frame shape and `72`-byte tuple order confirmed against upstream `eip-8272.md` @ `824cbc0b0`. -**Target network:** hegota-testnet, chain ID `8141`, genesis `0x7ca0f735…cd0f2332` -**Reference client:** [`lambdaclass/ethrex`, branch `hegota-testnet`](https://github.com/lambdaclass/ethrex/tree/hegota-testnet). Every `.rs`, `.py` and `docs/*.md` path cited below is a path in that repository, not in this one. +**Status:** implemented against the EIP-8141 frame transaction layout used by +Ethrex `v23.0.0-HEAD-d587cf9ff0996315381c4b2784a4d7d499decc0f`. -A TypeScript library for reading, building, hashing, pricing and simulating EIP-8141 frame -transactions as they exist on hegota-testnet — the composed envelope that also carries -EIP-8250 keyed nonces and EIP-8272 recent-root references. +## Scope -## 1. Why this exists +The package provides pure TypeScript tools to encode, decode, hash, sign, validate, +price, and format EIP-8141 frame transactions. It also provides a small account +abstraction for constructing account-specific validation and signatures. -**No JavaScript can touch this chain.** The only frame-transaction encoders are ethrex's Rust -implementation and ethrex's own `scripts/hegota-testnet/frametx.py` (126 lines). viem PR 4486 is a draft, -last touched 2026-05-06, with zero human review; it implements a pre-composition EIP-8141 -envelope with a flat `nonce`, flat fees, no `limits`, no recent-root references, and — across -its whole 3372-line diff — no `signatures` field and no `sig_hash`. It cannot authenticate a -frame transaction, and its bytes are not the bytes this chain accepts. +Transaction submission remains a normal `eth_sendRawTransaction` call. The optional +Ethrex simulation wrapper is retained, but not every frames-devnet RPC exposes that +method. -**A second implementation with hardcoded published figures catches a class of drift that -derived-constant tests cannot.** From `crates/common/types/transaction.rs:2215-2220`: +## Canonical envelope -> EIP-8141 publishes these in its Constants table; assert the constants reproduce them exactly, -> so a repricing or a re-spelling upstream is a compile error here rather than a silent -> consensus change. […] the reason the intrinsic drop from 15000 to 12000 was invisible to 1372 -> tests: the suite derives its expected intrinsic from this constant, so it can check the -> formula's composition but never the published figure. +A transaction is encoded as `0x06 || rlp(payload)`, where `payload` has exactly seven +fields: The same blindness applies to the wire format. From `test/tests/common/frame_tx_wire_tests.rs:3-8`: @@ -95,176 +86,85 @@ limits = rlp([execution, state]) signature = rlp([scheme, signer, msg, signature_bytes]) ``` -The composition of the three EIPs is this chain's own choice — none of the three specifies it. -EIP-8250 replaces the scalar `nonce` with `nonce_keys, nonce_seq` in place, EIP-8272 appends -`recent_root_references` last, and EIP-8141's `fees` list sits where its three flat fee fields -used to be. +`nonce` is a scalar u64 account nonce. The older experimental `nonce_keys`, +`nonce_seq`, and `recent_root_references` fields are intentionally unsupported. -> **`docs/eip-8141.md:78` in the ethrex branch still documents the pre-composition layout** — -> flat `nonce`, flat fee fields, and a frame as `[mode, flags, target, gas_limit, value, data]` -> with no `limits` nesting. Anyone building from that section produces bytes the node rejects. -> Reporting this is a candidate contribution (§10). +A frame is: -### sig_hash - -``` -sig_hash = keccak256(0x06 || rlp(envelope with empty-msg signatures' bytes elided)) +```text +[mode, flags, target, [execution_gas, state_gas], value, data] ``` -Each signature whose `msg` is empty has its `signature` field replaced with empty bytes, -because a signature over `compute_sig_hash` cannot commit to its own bytes. Signatures with an -explicit 32-byte `msg` keep their bytes — they sign that digest, not this hash. All frame data -is committed verbatim. - -The consequence for the API: **the encoder is a dependency of the decoder's verification -path**, because `sig_hash` is defined over a re-encoding. Read-only work still exercises the -full round trip. - -### Signature schemes and canonical rules - -`scheme`: 0 ARBITRARY, 1 SECP256K1, 2 P256. - -- ARBITRARY — `signer` MUST be empty; `signature` is arbitrary bytes, EVM-readable via - `SIGPARAM` param `0x04`. No ECDSA rules apply. -- SECP256K1 — `signature = v||r||s`, 65 bytes; `v` is a **bare recovery id (`v <= 1`, not the - EVM's 27/28)**; `0 < r < SECP256K1N`; `s` low (`0 < s <= SECP256K1N/2`). -- P256 — `signature = r||s||qx||qy`, 128 bytes; `0 < r < SECP256R1N`; `0 < s <= SECP256R1N/2`. - `P256VERIFY` itself accepts high-`s`, so the signer must normalize `s` to `n - s` before use. - -These are **consensus rules**, checked in `validate_frame_signatures` before any frame executes. -A high-`s` or 27/28-encoded signature makes the whole transaction invalid, not merely -unrelayable — which makes them a correctness requirement of this library, not a lint. - -An empty `signer` resolves to `tx.sender` for SECP256K1 and P256, including for EVM -introspection. - -### Signing - -`signFrameTx(tx, signer)` takes either a raw private key or a `FrameAccount` — -`{ address, sign }`, satisfied by viem's `privateKeyToAccount` and `mnemonicToAccount`, by a -`toAccount` source, and by any wrapper around a hardware wallet, an HSM or a remote signer. -It signs exactly the entries that are SECP256K1 with an empty `msg`, over `sig_hash`, and -refuses when an entry's resolved signer is not the account's address — otherwise the result -would recover to the wrong address and be rejected at consensus with no local error. - -**Raw-digest signing is required.** `signMessage` prefixes its argument per EIP-191, so it -cannot produce a signature over `sig_hash`, and a `JsonRpcAccount` cannot sign a raw digest -at all. Both are refused with an error rather than silently producing an unrecoverable -signature. `sign` is typed optional because it is optional on viem's own `LocalAccount`; -the check is at runtime. - -**A returned `v` is normalized, not assumed.** viem returns 27/28; HSMs and hand-rolled -wrappers commonly return a bare 0/1. Both are accepted. An EIP-155 `v` is refused: it -encodes a chain id this layout has no room for, and guessing at its parity would forge a -recovery id. Subtracting 27 unconditionally — the obvious implementation — writes `0x-1a…` -into the signature, malformed hex that surfaces only as a byte-alignment error naming the -wrong problem. - -This does not widen §2's out-of-scope line on key management. The account form holds no key -material; it delegates key management to the caller instead of doing any. - -### Structural limits - -| Constant | Value | Source | -|---|---|---| -| `FRAME_TX_MAX_FRAMES` | 64 | `transaction.rs:2209` | -| `FRAME_TX_MAX_NONCE_KEYS` | 16 | `transaction.rs:2211` | -| `FRAME_TX_MAX_RECENT_ROOT_REFERENCES` | 16 | `transaction.rs:2213` | -| `FRAME_TX_EXPIRY_DATA_LENGTH` | 8 | `transaction.rs:2273` | - -`nonce_keys` rules (`transaction.rs:2679-2694`): between 1 and 16 entries; strictly increasing; -if more than one entry, the first may not be zero. Frame `value` must be zero unless the frame -is SENDER. Frame `flags` bits 0-1 are the APPROVE scope restriction, bit 2 the atomic-batch -flag, bits 3-7 reserved and must be zero. - -EIP-8272 window: `RECENT_ROOT_LENGTH = 8192`, and a declared reference is valid iff -`1 <= current_slot - slot <= 8191`. Domain separators are `keccak256("RECENT_ROOT_ENTRY")` and -`keccak256("RECENT_ROOT_STORAGE")`. - -## 4. The gas model - -Transcribed from `crates/common/types/transaction.rs`, which is the branch's current -(post-`77e502ef4`) behaviour. Every constant is written as its published figure. +A signature is: +```text +[scheme, signer, msg, signature] ``` -value_transfer_cost = Σ 6000 over frames where value != 0 - AND target is present - AND target != sender -signature_verification_cost = Σ per signature: ARBITRARY 100, SECP256K1 2800, P256 6700 +All integer fields use canonical minimal big-endian RLP. Zero is the empty RLP string, +not the byte `0x00`. -mandatory_gas = 12000 - + 475 * len(frames) - + signature_verification_cost - + value_transfer_cost +## Signature hash -billed_bytes = concat( frame.data for each frame, - sig.signer, sig.msg, sig.signature for each signature, - nonce_calldata, - recent_root_calldata ) +The canonical signature hash is `keccak256(0x06 || rlp(payload))` after replacing the +raw `signature` bytes of every signature whose `msg` is empty with `0x`. Frames and all +other fields remain unchanged. Because the signature commits to the envelope, any wire +format change must update encoding, decoding, hashing, signing, and fixtures together. -nonce_calldata = rlp(nonce_keys) || rlp(nonce_seq) -recent_root_calldata = rlp(recent_root_references), or empty when no reference is declared +SECP256K1 signatures use `v || r || s`, with `v` normalized to a bare recovery id `0` +or `1`. P256 and ARBITRARY entries are validated structurally but are not generated by +`signFrameTx`. -data_cost = Σ over billed_bytes: 4 if byte == 0 else 16 +## Frame accounts -recent_root_reference_intrinsic_gas = 0 when no references - = 2400 + 2002 * len(references) otherwise +`FrameAccountImplementation` follows viem's implementation/resolved-account split. +It supplies: -frame_tx_intrinsic_gas = mandatory_gas + data_cost + recent_root_reference_intrinsic_gas +- `getAddress()` +- `getNonce({ blockTag? })` +- `getValidationData({ calls, chainId, nonce })` +- `signFrameTransaction(transaction)` +- an execution strategy -state_gas_limit = Σ frame.limits.state -standard_gas_limit = frame_tx_intrinsic_gas - + Σ frame.limits.execution - + state_gas_limit +`toEoaFrameAccount` implements protocol default-code validation for a code-less EOA. +It reads the scalar nonce with `eth_getTransactionCount`, returns empty validation +calldata, and installs one empty-message SECP256K1 signature entry. -calldata_tokens = len(billed_bytes) * 4 -calldata_floor_gas = calldata_tokens * 16 # 64 gas per byte -calldata_floor_total = mandatory_gas - + recent_root_reference_intrinsic_gas - + calldata_floor_gas +## Gas -max_gas = max(standard_gas_limit, calldata_floor_total + state_gas_limit) -max_cost = max_gas * max_fee_per_gas - + len(blob_versioned_hashes) * 131072 * blob_base_fee -``` - -Four traps worth naming, because each is a place a reimplementation goes quietly wrong: - -1. **The RLP framing and the scalar fields are not billed** — only the byte fields listed in - `billed_bytes`. But `nonce_calldata` and `recent_root_calldata` *are* RLP encodings, and - their framing bytes are billed, because the EIP prices the bytes those EIPs add to the - payload. -2. **`recent_root_calldata` is empty, not `rlp([])`, when no reference is declared** - (`transaction.rs:2538-2540`), so a reference-free transaction's gas is exactly the EIP-8141 - figure. Encoding `rlp([])` here would add billed bytes to every transaction on the chain. -3. **State gas sits outside the floor and is added on top of it** — `max_gas` takes - `calldata_floor_total + state_gas_limit`, not `calldata_floor_total`. Both bound an - execution-dimension resource, so the floor branch cannot absorb the state sum. -4. **`frame_tx_intrinsic_gas` must be computed directly, never as - `total_gas_limit − frame budgets`**, because `total_gas_limit` may be the floor branch. +The mandatory gas terms are: -The EIP-7825 per-transaction cap applies to a *different* quantity — -`frame_tx_intrinsic_gas + Σ frame.limits.execution`, state excluded — and is checked separately. +```text +12000 ++ 475 * len(frames) ++ signature verification cost ++ 6000 for each value transfer to an explicit non-sender target +``` -The published intrinsic is **12000**, down from an earlier 15000 (`transaction.rs:2189-2195`). -`docs/eip-8141.md:362` in the ethrex branch still says 15000, and its formulas omit both -`state_gas_limit` and `recent_root_reference_intrinsic_gas`. Second candidate contribution (§10). +Signature verification costs are ARBITRARY `100`, SECP256K1 `2800`, and P256 `6700`. +Calldata cost and the calldata floor cover frame `data` and each signature's `signer`, +`msg`, and `signature` bytes. Envelope RLP, nonce, and other scalar fields are not +billed data. State gas is added on top of the calldata-floor branch. -### Rule sets +The historical `'chain'`, `'pins'`, and `'head'` parameters remain accepted by gas +helpers as compatibility aliases; they currently use the same rules. -The gas module takes a rule-set parameter. This is load-bearing, not generality for its own -sake: the deployed nodes run ethrex `31b532266`, which carries two divergences from the pinned -EIP text, so a single hardcoded model cannot be simultaneously correct and green against live -data. +## Module boundaries -| Rule set | Meaning | +| Module | Responsibility | |---|---| -| `'chain'` | What `31b532266` does — the two live divergences included | -| `'pins'` | The pinned EIP text: EIP-8141 `7d1c8bfb94`, EIP-8250 `e5cf246ff1`, EIP-8272 `0231fb05f5` | -| `'head'` | Current drafts, for anticipating the next re-genesis | - -The two divergences that separate `'chain'` from `'pins'`: +| `types` | Public frame transaction and account-independent types | +| `rlp` | Canonical scalar and offset-aware RLP helpers | +| `envelope` | Encode, decode, and structural validation | +| `sighash` | Canonical signature hash | +| `signatures` | Signature validation, recovery, and signing | +| `accounts` | Generic frame accounts and the direct EOA adapter | +| `gas` | Pure gas and maximum-cost accounting | +| `rpc` | Frame transaction/receipt JSON parsing and optional simulation wrapper | +| `viem` | viem client extension | + +`gas` deliberately does not depend on `envelope`; this keeps accounting independently +testable. `envelope` does not depend on `signatures`; the reverse direction is allowed. - **`value_cost`** — the chain charges `TX_VALUE_COST` for every frame with `value > 0`; the pins charge it only when the frame has a target that is not `tx.sender`. A targetless or @@ -276,30 +176,15 @@ The two divergences that separate `'chain'` from `'pins'`: divergence and affects validation-prefix replay rather than the gas model, so it is recorded but not modelled. -What `'head'` changes, per the branch spec's "Changed upstream since the pins": - -- **EIP-8250** moves the first use of a keyed nonce from 20,000 execution gas, deducted from the - frame's remaining gas, to **97,920 state gas** charged during the payment `APPROVE`. A frame - consuming two fresh nullifier keys needs 195,840 of `limits.state` under `'head'` and none - here. -- **EIP-8272** drops the envelope field, `TXPARAM 0x11` and `RECENTROOTREFLOAD` entirely, - carrying references instead as a leading VERIFY frame targeting - `0x0000000000000000000000000000000000008272`. Confirmed against upstream - `ethereum/EIPs` `eip-8272.md` @ `824cbc0b0` (2026-09-07): the "recent root verifier frame" - is `mode == VERIFY`, `target == RECENT_ROOT_ADDRESS`, `flags == 0`, `value == 0`, - `limits.state == 0`, and its data is `n` tuples of `source_id: bytes32 || slot: uint64_be - || root: bytes32` — `RECENT_ROOT_TUPLE_BYTES = 72` — with no selector or length prefix. - The spec adds **no** special intrinsic gas, warming rule, or block-gas exemption: the - frame is priced as ordinary EIP-8141 frame data plus one more `FRAME_TX_PER_FRAME_COST`. - This changes the *envelope*, not only the gas, so `'head'` cannot share the `'chain'` - encoder. `divergence.toHeadShape` applies the change as a transformation of the - transaction: the field is emptied and its contents prepended as one VERIFY frame with zero - limits, which the existing pricer then prices. `compareRuleSets` routes whichever side names `'head'` - through it. `gas` fabricates nothing and `frameTxGas(tx, 'head')` still refuses a - reference-carrying transaction, so the floor that zero `limits.execution` produces — the - one figure the spec does not pin, since it falls out of the `STATICCALL` and the per-tuple - `SLOAD`s — cannot be mistaken for a budget. No second serializer: the transform yields a - `FrameTransaction`, never bytes. +The hermetic suite pins: + +- Ethrex v23's golden frame transaction bytes +- Ethrex v23's canonical signature hash +- strict canonical-RLP rejection and byte offsets +- signature normalization and recovery +- scalar nonce retrieval for EOAs +- gas-accounting terms +- RPC transaction and receipt formatting ## 5. Modules diff --git a/docs/OPEN-ITEMS.md b/docs/OPEN-ITEMS.md index 459f9b3..4a97fdf 100644 --- a/docs/OPEN-ITEMS.md +++ b/docs/OPEN-ITEMS.md @@ -1,4 +1,4 @@ -# frametx-kit — open items +# frametx-kit open items Known gaps and unfinished work, current as of ethrex `hegota-testnet` @ `19c065fa8`. The invariants and wire-format traps are in `../CONTRIBUTING.md`; `DESIGN.md` is the diff --git a/package.json b/package.json index f28636f..a09a2cc 100644 --- a/package.json +++ b/package.json @@ -59,10 +59,8 @@ "keywords": [ "ethereum", "eip-8141", - "eip-8250", - "eip-8272", "frame-transaction", - "hegota-testnet", + "frames-devnet", "ethrex", "viem", "rlp" @@ -70,8 +68,8 @@ "scripts": { "build": "tsup", "test": "vitest run", - "test:watch": "vitest", "test:live": "FRAMES_LIVE=1 vitest run test/live", + "test:watch": "vitest", "typecheck": "tsc --noEmit" }, "peerDependencies": { diff --git a/scripts/capture-fixtures.ts b/scripts/capture-fixtures.ts index e20aa32..b403060 100644 --- a/scripts/capture-fixtures.ts +++ b/scripts/capture-fixtures.ts @@ -1,5 +1,8 @@ /** - * Capture live type-0x06 transactions as test fixtures. + * Legacy fixture capture script for the retired chain-8141 envelope. + * + * Do not use this to produce current wire-format fixtures. The frames-devnet RPC + * does not expose the simulation method this historical capture flow requires. * * The public endpoint serves no raw transaction bytes — ethrex has no * `eth_getRawTransactionByHash`, and `debug_getRawTransaction` is refused by diff --git a/scripts/send-eth-eoa.ts b/scripts/send-eth-eoa.ts new file mode 100644 index 0000000..7504919 --- /dev/null +++ b/scripts/send-eth-eoa.ts @@ -0,0 +1,145 @@ +/** + * Send native ETH from a code-less EOA using an EIP-8141 frame transaction. + * + * Required: + * PRIVATE_KEY=0x... RECIPIENT=0x... bun run scripts/send-eth-eoa.ts + * + * Optional: + * AMOUNT_ETH=0.001 + * RPC_URL=https://rpc1.frames.ethrex.xyz + * DRY_RUN=1 + */ +import { + createPublicClient, + defineChain, + formatEther, + getAddress, + http, + keccak256, + parseEther, + type Address, + type Hex, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { + assertValidFrameTx, + encodeFrameTx, + frameTxGas, + frameTxMaxCost, + parseRpcFrameReceipt, + prepareFrameTransaction, + signFrameTransaction, + toEoaFrameAccount, + type FrameRpcClient, + type RpcFrameReceiptJson, +} from '../src/index.js' + +const CHAIN_ID = 81410n +const DEFAULT_RPC_URL = 'https://rpc1.frames.ethrex.xyz' +const RECEIPT_TIMEOUT_MS = 120_000 +const framesDevnet = defineChain({ + id: Number(CHAIN_ID), + name: 'Ethrex Frames Devnet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [DEFAULT_RPC_URL] } }, +}) + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) throw new Error(`${name} is required`) + return value +} + +function privateKeyFromEnv(): Hex { + const value = requiredEnv('PRIVATE_KEY') + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) + throw new Error('PRIVATE_KEY must be a 32-byte 0x-prefixed hex value') + return value as Hex +} + +async function waitForFrameReceipt( + client: FrameRpcClient, + hash: Hex, +): Promise { + const deadline = Date.now() + RECEIPT_TIMEOUT_MS + + while (Date.now() < deadline) { + const receipt = (await client.request({ + method: 'eth_getTransactionReceipt', + params: [hash], + })) as RpcFrameReceiptJson | null + + if (receipt !== null) return receipt + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + + throw new Error( + `transaction ${hash} was not mined within ${RECEIPT_TIMEOUT_MS / 1_000}s`, + ) +} + +async function main(): Promise { + const rpcUrl = process.env.RPC_URL ?? DEFAULT_RPC_URL + const owner = privateKeyToAccount(privateKeyFromEnv()) + const recipient: Address = getAddress(requiredEnv('RECIPIENT')) + const amount = parseEther(process.env.AMOUNT_ETH ?? '0.001') + const dryRun = process.env.DRY_RUN === '1' + + const client = createPublicClient({ + chain: framesDevnet, + transport: http(rpcUrl), + }) + const account = await toEoaFrameAccount({ client, owner }) + + const unsigned = await prepareFrameTransaction( + account, + [{ to: recipient, value: amount, data: '0x' }], + { + validation: { execution: 80_000n, state: 0n }, + calls: [{ execution: 30_000n, state: 200_000n }], + }, + ) + + const signed = await signFrameTransaction(account, unsigned) + assertValidFrameTx(signed) + + const raw = encodeFrameTx(signed) + const transactionHash = keccak256(raw) + const gas = frameTxGas(signed, 'chain') + const maxGasCost = frameTxMaxCost(signed, 0n, 'chain') + + console.log(`raw tx: ${raw}`) + console.log(`sender: ${account.address}`) + console.log(`recipient: ${recipient}`) + console.log(`amount: ${formatEther(amount)} ETH`) + console.log(`nonce: ${signed.nonce}`) + console.log(`max gas: ${gas.maxGas}`) + console.log(`max gas cost: ${formatEther(maxGasCost)} ETH`) + console.log(`local hash: ${transactionHash}`) + + if (dryRun) { + console.log('DRY_RUN=1, so the valid transaction was not broadcast') + return + } + + const submittedHash = (await client.request({ + method: 'eth_sendRawTransaction', + params: [raw], + })) as Hex + + if (submittedHash.toLowerCase() !== transactionHash.toLowerCase()) + throw new Error( + `node returned hash ${submittedHash}, expected ${transactionHash}`, + ) + + console.log(`submitted: ${submittedHash}`) + const rpcReceipt = await waitForFrameReceipt(client, submittedHash) + const receipt = parseRpcFrameReceipt(rpcReceipt) + console.log('payer:', receipt.payer) + console.log('frames:', receipt.frameReceipts) +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/src/accounts/toEoaFrameAccount.ts b/src/accounts/toEoaFrameAccount.ts new file mode 100644 index 0000000..4499a3e --- /dev/null +++ b/src/accounts/toEoaFrameAccount.ts @@ -0,0 +1,119 @@ +import { + type Account, + type Address, + type Chain, + type Client, + type Hash, + type Hex, + type JsonRpcAccount, + type LocalAccount, + type Transport, + isAddressEqual, +} from 'viem' +import { signFrameTx } from '../signatures.js' +import { FrameEncodeError } from '../errors.js' +import type { FrameTransaction } from '../types.js' +import { toFrameAccount } from './toFrameAccount.js' +import type { + FrameAccount, + FrameAccountImplementation, + GetFrameNonceParameters, +} from './types.js' + +export type EoaFrameOwner = Account & { + address: Address + sign: (parameters: { hash: Hash }) => Promise +} + +export type EoaFrameAccountImplementation< + chain extends Chain | undefined = Chain | undefined, + owner extends EoaFrameOwner = EoaFrameOwner, + extend extends object = object, +> = FrameAccountImplementation + +export type ToEoaFrameAccountParameters< + chain extends Chain | undefined = Chain | undefined, + owner extends EoaFrameOwner = EoaFrameOwner, + extend extends object = object, +> = { + client: Client< + Transport, + chain, + JsonRpcAccount | LocalAccount | undefined + > + owner: owner + extend?: extend | undefined +} + +export type ToEoaFrameAccountReturnType< + chain extends Chain | undefined = Chain | undefined, + owner extends EoaFrameOwner = EoaFrameOwner, + extend extends object = object, +> = FrameAccount> + +async function getEoaFrameNonce< + chain extends Chain | undefined, + owner extends EoaFrameOwner, +>( + client: Client< + Transport, + chain, + JsonRpcAccount | LocalAccount | undefined + >, + sender: Address, + parameters: GetFrameNonceParameters, +): Promise { + const nonce = await client.request({ + method: 'eth_getTransactionCount', + params: [sender, parameters.blockTag ?? 'pending'], + }) + return BigInt(nonce) +} + +/** Create a direct-execution frame account backed by an ordinary EOA. */ +export async function toEoaFrameAccount< + chain extends Chain | undefined, + owner extends EoaFrameOwner, + extend extends object = object, +>( + parameters: ToEoaFrameAccountParameters, +): Promise> { + const { client, owner } = parameters + + return toFrameAccount({ + client, + owner, + execution: { type: 'direct' }, + ...(parameters.extend === undefined ? {} : { extend: parameters.extend }), + async getAddress() { + return owner.address + }, + async getNonce(nonceParameters = {}) { + return getEoaFrameNonce(client, owner.address, nonceParameters) + }, + async getValidationData() { + return '0x' + }, + async signFrameTransaction(transaction: FrameTransaction) { + if (!isAddressEqual(transaction.sender, owner.address)) + throw new FrameEncodeError( + `transaction sender ${transaction.sender} does not match EOA ${owner.address}`, + ) + + return signFrameTx( + { + ...transaction, + signatures: [ + { + scheme: 1, + signer: null, + msg: '0x', + signature: '0x', + }, + ], + }, + owner, + ) + }, + }) +} diff --git a/src/accounts/toFrameAccount.ts b/src/accounts/toFrameAccount.ts new file mode 100644 index 0000000..db8dc58 --- /dev/null +++ b/src/accounts/toFrameAccount.ts @@ -0,0 +1,31 @@ +import type { + FrameAccount, + FrameAccountImplementation, +} from './types.js' + +export type ToFrameAccountParameters< + implementation extends + FrameAccountImplementation = FrameAccountImplementation, +> = implementation + +export type ToFrameAccountReturnType< + implementation extends + FrameAccountImplementation = FrameAccountImplementation, +> = FrameAccount + +/** Resolve an account implementation into the object consumed by frame actions. */ +export async function toFrameAccount< + const implementation extends FrameAccountImplementation, +>( + implementation: ToFrameAccountParameters, +): Promise> { + const { extend, ...rest } = implementation + const address = await implementation.getAddress() + + return { + ...extend, + ...rest, + address, + type: 'frame', + } as ToFrameAccountReturnType +} diff --git a/src/accounts/types.ts b/src/accounts/types.ts new file mode 100644 index 0000000..33be036 --- /dev/null +++ b/src/accounts/types.ts @@ -0,0 +1,92 @@ +import type { + Account, + Address, + BlockTag, + Chain, + Client, + Hex, + JsonRpcAccount, + LocalAccount, + Transport, +} from 'viem' +import type { FrameTransaction } from '../types.js' + +export type FrameCall = { + to: Address + value: bigint + data: Hex +} + +export type GetFrameNonceParameters = { + blockTag?: BlockTag | undefined +} + +export type GetValidationDataParameters = { + calls: readonly FrameCall[] + chainId: bigint + nonce: bigint +} + +export type DirectFrameExecution = { + type: 'direct' +} + +/** Account-specific behavior required to construct and sign frame transactions. */ +export type FrameAccountImplementation< + chain extends Chain | undefined = Chain | undefined, + owner extends Account | undefined = Account | undefined, + extend extends object = object, +> = { + /** Client used to read account and nonce state. */ + client: Client< + Transport, + chain, + JsonRpcAccount | LocalAccount | undefined + > + /** Account or signer that controls the frame account. */ + owner: owner + /** How calls are represented by execution frames. */ + execution: DirectFrameExecution + /** Add implementation-specific properties to the resolved account. */ + extend?: extend | undefined + /** Resolve the sender address represented by this account. */ + getAddress: () => Promise
+ /** Resolve the sender's scalar account nonce. */ + getNonce: (parameters?: GetFrameNonceParameters) => Promise + /** Produce calldata for the account's EIP-8141 validation frame. */ + getValidationData: (parameters: GetValidationDataParameters) => Promise + /** Apply the account's signature scheme and ordering to a frame transaction. */ + signFrameTransaction: ( + transaction: FrameTransaction, + ) => Promise +} + +type Assign = Omit< + base, + keyof overrides +> & + overrides + +type Simplify = { [key in keyof type]: type[key] } & {} + +type AccountExtension = + NonNullable + +/** A resolved frame account, analogous to viem's resolved SmartAccount type. */ +export type FrameAccount< + implementation extends + FrameAccountImplementation = FrameAccountImplementation, +> = Simplify< + Assign< + AccountExtension, + Assign< + Omit, + { + /** Resolved sender address. */ + address: Address + /** Discriminator for frame-account-aware APIs. */ + type: 'frame' + } + > + > +> diff --git a/src/divergence.ts b/src/divergence.ts index a991663..68d3612 100644 --- a/src/divergence.ts +++ b/src/divergence.ts @@ -1,71 +1,5 @@ -import { type Address, type Hex, concatHex, numberToHex } from 'viem' import { type FrameGas, frameTxGas } from './gas.js' -import type { Frame, FrameTransaction, RecentRootReference, RuleSet } from './types.js' - -/** - * EIP-8250 at head: the first use of a keyed nonce moved from 20,000 execution - * gas, deducted from the frame's remaining gas, to 97,920 STATE gas charged - * during the payment APPROVE and attributed to the frame that calls it. - * - * A frame consuming two fresh nullifier keys needs 195,840 of limits.state under - * the head draft, and none on this chain. Adopting it is a re-genesis. - */ -export const HEAD_KEYED_NONCE_STATE_GAS = 97_920n - -/** - * EIP-8272 at head: the envelope field, `TXPARAM 0x11` and `RECENTROOTREFLOAD` are - * gone. References travel instead as a "recent root verifier frame" against this - * address (upstream `eip-8272.md` @ `824cbc0b0`, §Recent root verifier frame). - */ -export const HEAD_RECENT_ROOT_VERIFIER: Address = - '0x0000000000000000000000000000000000008272' - -/** - * `RECENT_ROOT_TUPLE_BYTES` from upstream `eip-8272.md`: one packed reference is - * `source_id: bytes32 || slot: uint64_be || root: bytes32`, 32 + 8 + 32. - */ -export const HEAD_REFERENCE_BYTES = 72 - -// Field order and widths per upstream `eip-8272.md` @ `824cbc0b0` -// (§"Validation operation"): the tuples are concatenated with no selector or -// length prefix. `slot` is a big-endian u64 — the same width `validateFrameTx` -// already holds it to (`assertUint(ref.slot, 64, …)`). -function packReference(ref: RecentRootReference): Hex { - return concatHex([ref.sourceId, numberToHex(ref.slot, { size: 8 }), ref.root]) -} - -/** - * The head-shaped equivalent of a reference-carrying transaction: the envelope - * field emptied, its contents prepended as one VERIFY frame. - * - * The frame's `mode`, `target`, `flags`, `value` and `limits.state` are the ones - * upstream `eip-8272.md` (§Recent root verifier frame) makes normative for the - * shape. Its `limits.execution` is set to zero because that is the one figure the - * spec does not pin — it falls out of executing the `STATICCALL` and one `SLOAD` - * per tuple — so every limit-derived gas term of the result is a floor. That is - * why this is the divergence survey's entry point and `frameTxGas(tx, 'head')` - * still refuses a reference-carrying transaction: a floor is fine to compare - * against and wrong to budget with. - * - * The frame is prepended at index 0. The spec places it after an optional - * `expiry_verify` frame, but frame order does not affect the gas terms priced - * here, so the survey does not model the offset. - * - * Identity for a transaction that carries no reference. - */ -export function toHeadShape(tx: FrameTransaction): FrameTransaction { - if (tx.recentRootReferences.length === 0) return tx - - const verify: Frame = { - mode: 1, - flags: 0, - target: HEAD_RECENT_ROOT_VERIFIER, - limits: { execution: 0n, state: 0n }, - value: 0n, - data: concatHex(tx.recentRootReferences.map(packReference)), - } - return { ...tx, frames: [verify, ...tx.frames], recentRootReferences: [] } -} +import type { FrameTransaction, RuleSet } from './types.js' export type GasDivergence = { term: keyof FrameGas @@ -83,18 +17,16 @@ export type GasDivergence = { * on live chain data that the divergence ledger does not already explain is a * finding worth reporting. * - * Whichever side names `'head'` is priced over `toHeadShape(tx)`, so a - * reference-carrying transaction compares against the head envelope rather than - * being refused. Both sides are transformed independently, so argument order - * never changes the outcome. + * The rule-set names are compatibility aliases now that the current envelope + * has removed the keyed-nonce and recent-root extensions. */ export function compareRuleSets( tx: FrameTransaction, a: RuleSet, b: RuleSet, ): GasDivergence[] { - const gasA = frameTxGas(a === 'head' ? toHeadShape(tx) : tx, a) - const gasB = frameTxGas(b === 'head' ? toHeadShape(tx) : tx, b) + const gasA = frameTxGas(tx, a) + const gasB = frameTxGas(tx, b) const divergences: GasDivergence[] = [] for (const term of Object.keys(gasA) as (keyof FrameGas)[]) { diff --git a/src/envelope.ts b/src/envelope.ts index 111cdad..0fd4886 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -3,7 +3,6 @@ import type { Frame, FrameSignature, FrameTransaction, - RecentRootReference, FrameLimits, FrameMode, SigScheme, @@ -31,17 +30,12 @@ function encodeSignature(sig: FrameSignature): RlpTree { return [rlpUint(BigInt(sig.scheme)), sig.signer ?? '0x', sig.msg, sig.signature] } -function encodeRecentRootReference(ref: RecentRootReference): RlpTree { - return [ref.sourceId, rlpUint(ref.slot), ref.root] -} - /** The RLP body, without the `0x06` type prefix. */ export function encodeFrameTxBody(tx: FrameTransaction): Hex { return toRlp( [ rlpUint(tx.chainId), - tx.nonceKeys.map(rlpUint), - rlpUint(tx.nonceSeq), + rlpUint(tx.nonce), tx.sender, tx.frames.map(encodeFrame), tx.signatures.map(encodeSignature), @@ -51,7 +45,6 @@ export function encodeFrameTxBody(tx: FrameTransaction): Hex { rlpUint(tx.maxFeePerBlobGas), ], tx.blobVersionedHashes, - tx.recentRootReferences.map(encodeRecentRootReference), ] satisfies RlpTree, 'hex', ) @@ -143,20 +136,6 @@ function decodeSignature(node: RlpNode): FrameSignature { } } -function decodeRecentRootReference(node: RlpNode): RecentRootReference { - const r = asList(node, 'recentRootReference') - if (r.length !== 3) - throw new FrameDecodeError( - `recentRootReference must have 3 fields, got ${r.length}`, - node.start, - ) - return { - sourceId: asHex(r[0]!, 'recentRootReference.sourceId'), - slot: asUint(r[1]!, 'recentRootReference.slot'), - root: asHex(r[2]!, 'recentRootReference.root'), - } -} - function decodeFields( fields: readonly RlpNode[], fees: readonly RlpNode[], @@ -164,20 +143,16 @@ function decodeFields( ): FrameTransaction { return { chainId: asUint(fields[0]!, 'chainId'), - nonceKeys: asList(fields[1]!, 'nonceKeys').map((k) => asUint(k, 'nonceKeys[]')), - nonceSeq: asUint(fields[2]!, 'nonceSeq'), + nonce: asUint(fields[1]!, 'nonce'), sender, - frames: asList(fields[4]!, 'frames').map(decodeFrame), - signatures: asList(fields[5]!, 'signatures').map(decodeSignature), + frames: asList(fields[3]!, 'frames').map(decodeFrame), + signatures: asList(fields[4]!, 'signatures').map(decodeSignature), maxPriorityFeePerGas: asUint(fees[0]!, 'maxPriorityFeePerGas'), maxFeePerGas: asUint(fees[1]!, 'maxFeePerGas'), maxFeePerBlobGas: asUint(fees[2]!, 'maxFeePerBlobGas'), - blobVersionedHashes: asList(fields[7]!, 'blobVersionedHashes').map((h) => + blobVersionedHashes: asList(fields[6]!, 'blobVersionedHashes').map((h) => asHex(h, 'blobVersionedHashes[]'), ), - recentRootReferences: asList(fields[8]!, 'recentRootReferences').map( - decodeRecentRootReference, - ), } } @@ -218,22 +193,22 @@ export function decodeFrameTx(raw: Hex): FrameTransaction { } const fields = asList(tree, 'envelope') - if (fields.length !== 9) + if (fields.length !== 7) throw new FrameDecodeError( - `envelope must have 9 fields, got ${fields.length}`, + `envelope must have 7 fields, got ${fields.length}`, tree.start, ) - const fees = asList(fields[6]!, 'fees') + const fees = asList(fields[5]!, 'fees') if (fees.length !== 3) throw new FrameDecodeError( `fees must have 3 fields, got ${fees.length}`, - fields[6]!.start, + fields[5]!.start, ) - const sender = asAddressOrNull(fields[3]!, 'sender') + const sender = asAddressOrNull(fields[2]!, 'sender') if (sender === null) - throw new FrameDecodeError('sender may not be empty', fields[3]!.start) + throw new FrameDecodeError('sender may not be empty', fields[2]!.start) const tx = decodeFields(fields, fees, sender) @@ -251,8 +226,6 @@ export function decodeFrameTx(raw: Hex): FrameTransaction { } export const MAX_FRAMES = 64 -export const MAX_NONCE_KEYS = 16 -export const MAX_RECENT_ROOT_REFERENCES = 16 /** EIP-7594 per-transaction blob limit; frame transactions inherit it unchanged. */ export const MAX_BLOBS_PER_TX = 6 /** EIP-8141 expiry-verifier predeploy. A VERIFY frame targeting it is an expiry frame. */ @@ -328,13 +301,11 @@ function isExpiryVerifier(frame: Frame): boolean { } function assertFieldWidths(tx: FrameTransaction): void { - // These are the widths ethrex's decoder reads the fields at (`chain_id`, - // `nonce_seq`, both non-blob fees and `RecentRootReference::slot` are u64; - // `max_fee_per_blob_gas`, the nonce keys and `Frame::value` are U256). + // These are the widths ethrex's decoder reads the fields at. assertUint(tx.chainId, 64, 'chainId') - assertUint(tx.nonceSeq, 64, 'nonceSeq') - assertUint(tx.maxPriorityFeePerGas, 64, 'maxPriorityFeePerGas') - assertUint(tx.maxFeePerGas, 64, 'maxFeePerGas') + assertUint(tx.nonce, 64, 'nonce') + assertUint(tx.maxPriorityFeePerGas, 256, 'maxPriorityFeePerGas') + assertUint(tx.maxFeePerGas, 256, 'maxFeePerGas') assertUint(tx.maxFeePerBlobGas, 256, 'maxFeePerBlobGas') } @@ -353,36 +324,8 @@ function assertFrameCount(tx: FrameTransaction): void { } function assertNonce(tx: FrameTransaction): void { - if (tx.nonceKeys.length < 1 || tx.nonceKeys.length > MAX_NONCE_KEYS) - throw new FrameEncodeError( - `nonceKeys must hold between 1 and ${MAX_NONCE_KEYS} entries, got ${tx.nonceKeys.length}`, - ) - for (const [i, key] of tx.nonceKeys.entries()) assertUint(key, 256, `nonceKeys[${i}]`) - for (let i = 1; i < tx.nonceKeys.length; i++) - if (tx.nonceKeys[i - 1]! >= tx.nonceKeys[i]!) - throw new FrameEncodeError('nonceKeys must be strictly increasing') - if (tx.nonceKeys.length > 1 && tx.nonceKeys[0] === 0n) - throw new FrameEncodeError( - 'the first nonce key may only be zero when it is the only key', - ) - if (tx.nonceSeq >= U64_MAX) - throw new FrameEncodeError('nonceSeq must be below 2**64 - 1') -} - -function assertRecentRootReferences(tx: FrameTransaction): void { - if (tx.recentRootReferences.length > MAX_RECENT_ROOT_REFERENCES) - throw new FrameEncodeError( - `at most ${MAX_RECENT_ROOT_REFERENCES} recent-root references, got ${tx.recentRootReferences.length}`, - ) - // `source_id` and `root` are H256 in ethrex, decoded through the fixed - // `[u8; 32]` impl: any other length fails RLP decode with InvalidLength. - for (const [i, ref] of tx.recentRootReferences.entries()) { - if (checkedByteLength(ref.sourceId, `recentRootReference ${i}: sourceId`) !== 32) - throw new FrameEncodeError(`recentRootReference ${i}: sourceId must be 32 bytes`) - if (checkedByteLength(ref.root, `recentRootReference ${i}: root`) !== 32) - throw new FrameEncodeError(`recentRootReference ${i}: root must be 32 bytes`) - assertUint(ref.slot, 64, `recentRootReference ${i}: slot`) - } + if (tx.nonce >= U64_MAX) + throw new FrameEncodeError('nonce must be below 2**64 - 1') } function assertBlobs(tx: FrameTransaction): void { @@ -510,7 +453,6 @@ export function validateFrameTx(tx: FrameTransaction): void { assertSender(tx) assertFrameCount(tx) assertNonce(tx) - assertRecentRootReferences(tx) assertBlobs(tx) assertSignatures(tx) assertFrames(tx) diff --git a/src/gas.ts b/src/gas.ts index 68878b5..998ccd2 100644 --- a/src/gas.ts +++ b/src/gas.ts @@ -1,6 +1,5 @@ -import { type Address, type Hex, concatHex, toRlp } from 'viem' -import { FrameEncodeError } from './errors.js' -import { byteLength, rlpUint } from './rlp.js' +import { type Address, type Hex } from 'viem' +import { byteLength } from './rlp.js' import type { FrameTransaction, RuleSet, SigScheme } from './types.js' // EIP-8141 Constants table. Written as published figures, never derived: ethrex's @@ -13,11 +12,6 @@ export const STANDARD_TOKEN_COST = 4n export const TOTAL_COST_FLOOR_PER_TOKEN = 16n export const GAS_PER_BLOB = 131_072n -/** EIP-8272: ACCESS_LIST_ADDRESS_COST, and - * ACCESS_LIST_STORAGE_KEY_COST + 2*KECCAK256_BASE_GAS + 7*KECCAK256_WORD_GAS. */ -export const RECENT_ROOT_REFERENCE_ADDRESS_GAS = 2_400n -export const RECENT_ROOT_REFERENCE_GAS = 2_002n - export const SIG_VERIFY_COST: Record = { 0: 100n, 1: 2_800n, @@ -31,7 +25,6 @@ export type FrameGas = { /** Total length in bytes of every billed field. */ billedBytes: bigint dataCost: bigint - recentRootReferenceGas: bigint intrinsicGas: bigint stateGasLimit: bigint standardGasLimit: bigint @@ -42,37 +35,15 @@ export type FrameGas = { maxGas: bigint } -/** EIP-8250 `nonce_calldata`: rlp(nonce_keys) || rlp(nonce_seq). */ -export function nonceCalldata(tx: FrameTransaction): Hex { - return concatHex([toRlp(tx.nonceKeys.map(rlpUint), 'hex'), toRlp(rlpUint(tx.nonceSeq), 'hex')]) -} - -/** - * EIP-8272 `recent_root_calldata`: rlp(recent_root_references), or EMPTY when no - * reference is declared — not `toRlp([])`, which would add a billed byte to every - * transaction on the chain. - */ -export function recentRootCalldata(tx: FrameTransaction): Hex { - if (tx.recentRootReferences.length === 0) return '0x' - return toRlp( - tx.recentRootReferences.map((r) => [r.sourceId, rlpUint(r.slot), r.root]), - 'hex', - ) -} - /** * The fields the calldata cost is charged over: each frame's `data`, each - * signature's `signer`, `msg` and `signature`, the nonce calldata and the - * recent-root calldata. The envelope's RLP framing and its scalar fields are - * NOT billed — but the two calldata blobs above are themselves RLP, and their - * framing bytes are billed. + * signature's `signer`, `msg` and `signature`. The envelope's RLP framing and + * its scalar fields are not billed. */ function billedFields(tx: FrameTransaction): Hex[] { return [ ...tx.frames.map((f) => f.data), ...tx.signatures.flatMap((s) => [s.signer ?? '0x', s.msg, s.signature] as Hex[]), - nonceCalldata(tx), - recentRootCalldata(tx), ] } @@ -94,23 +65,13 @@ function sameAddress(a: Address, b: Address): boolean { /** * EIP-8141 `value_transfer_cost`. * - * The rule sets differ here, and this is the divergence you can observe on the - * live chain: ethrex 31b532266 charges every value-carrying frame, while the - * pinned text charges only a frame whose target is present and is not tx.sender. - * A targetless or self-targeted value frame is therefore overcharged 6000 on the - * deployed chain. Divergence-ledger row 3.8. + * Charged only when a frame moves value to an explicit target other than the + * transaction sender. */ -function valueTransferCost(tx: FrameTransaction, rules: RuleSet): bigint { +function valueTransferCost(tx: FrameTransaction): bigint { let total = 0n for (const frame of tx.frames) { if (frame.value === 0n) continue - if (rules === 'chain') { - total += FRAME_TX_VALUE_COST - continue - } - // Compare case-insensitively: `getAddress` returns EIP-55 mixed case while a - // hand-written fixture is lowercase, and a `!==` between the two would charge - // TX_VALUE_COST on a self-targeted frame that the pinned rule exempts. if (frame.target !== null && !sameAddress(frame.target, tx.sender)) total += FRAME_TX_VALUE_COST } @@ -118,31 +79,11 @@ function valueTransferCost(tx: FrameTransaction, rules: RuleSet): bigint { } /** - * Price a frame transaction under a rule set. - * - * Only `valueTransferCost` depends on the rule set, and only between `'chain'` - * and `'pins'`. `'head'` prices identically to `'pins'` here: the head EIP-8250 - * change (97,920 state gas on the first use of a keyed nonce) is an - * execution-time charge against `limits.state`, not an intrinsic term, so it - * moves what a builder must budget, not what this function computes. The figure - * lives in `divergence.ts` as `HEAD_KEYED_NONCE_STATE_GAS`. - * - * `'head'` is refused, not modeled, for a transaction that carries any - * recent-root reference: the head EIP-8272 draft removes the envelope's - * recent-root field entirely (references become a leading VERIFY frame - * carrying 72 bytes of data each), so the `2400 + 2002·n` intrinsic term and - * the billed RLP reference bytes computed here have no head-shaped equivalent - * to compare against — this function does not model the head envelope shape. + * Price a frame transaction. The rule-set argument remains as a compatibility + * alias; all names use the current EIP-8141 accounting rules. */ -export function frameTxGas(tx: FrameTransaction, rules: RuleSet = 'chain'): FrameGas { - if (rules === 'head' && tx.recentRootReferences.length > 0) - throw new FrameEncodeError( - 'the head EIP-8272 draft removes the recent-root envelope field entirely ' + - '(references become a leading VERIFY frame carrying 72 bytes of data each), ' + - 'so this transaction has no head-shaped equivalent and frameTxGas does not model it', - ) - - const valueCost = valueTransferCost(tx, rules) +export function frameTxGas(tx: FrameTransaction, _rules: RuleSet = 'chain'): FrameGas { + const valueCost = valueTransferCost(tx) const sigCost = tx.signatures.reduce( (acc, s) => acc + SIG_VERIFY_COST[s.scheme], 0n, @@ -157,13 +98,7 @@ export function frameTxGas(tx: FrameTransaction, rules: RuleSet = 'chain'): Fram const billedBytes = fields.reduce((acc, f) => acc + BigInt(byteLength(f)), 0n) const dataCost = calldataCost(fields) - const recentRootReferenceGas = - tx.recentRootReferences.length === 0 - ? 0n - : RECENT_ROOT_REFERENCE_ADDRESS_GAS + - BigInt(tx.recentRootReferences.length) * RECENT_ROOT_REFERENCE_GAS - - const intrinsicGas = mandatoryGas + dataCost + recentRootReferenceGas + const intrinsicGas = mandatoryGas + dataCost const stateGasLimit = tx.frames.reduce((acc, f) => acc + f.limits.state, 0n) const executionGasLimit = tx.frames.reduce((acc, f) => acc + f.limits.execution, 0n) @@ -171,7 +106,7 @@ export function frameTxGas(tx: FrameTransaction, rules: RuleSet = 'chain'): Fram const calldataTokens = billedBytes * STANDARD_TOKEN_COST const calldataFloorGas = calldataTokens * TOTAL_COST_FLOOR_PER_TOKEN - const calldataFloorTotal = mandatoryGas + recentRootReferenceGas + calldataFloorGas + const calldataFloorTotal = mandatoryGas + calldataFloorGas // State gas is added ON TOP of the floor: both bound an execution-dimension // resource, so the floor branch cannot absorb the state sum. @@ -186,7 +121,6 @@ export function frameTxGas(tx: FrameTransaction, rules: RuleSet = 'chain'): Fram mandatoryGas, billedBytes, dataCost, - recentRootReferenceGas, intrinsicGas, stateGasLimit, standardGasLimit, diff --git a/src/index.ts b/src/index.ts index 8c389d4..aa013af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,31 @@ export * from './errors.js' export * from './types.js' +export { + type DirectFrameExecution, + type FrameAccount, + type FrameAccountImplementation, + type FrameCall, + type GetFrameNonceParameters, + type GetValidationDataParameters, +} from './accounts/types.js' +export { + type ToFrameAccountParameters, + type ToFrameAccountReturnType, + toFrameAccount, +} from './accounts/toFrameAccount.js' +export { + type EoaFrameAccountImplementation, + type EoaFrameOwner, + type ToEoaFrameAccountParameters, + type ToEoaFrameAccountReturnType, + toEoaFrameAccount, +} from './accounts/toEoaFrameAccount.js' +export { + type PrepareFrameLimits, + type PrepareFrameTransactionOptions, + prepareFrameTransaction, +} from './prepareFrameTransaction.js' +export { signFrameTransaction } from './signFrameTransaction.js' export { rlpUint, parseRlpUint, byteLength } from './rlp.js' export { encodeFrameTx, @@ -7,8 +33,6 @@ export { decodeFrameTx, validateFrameTx, MAX_FRAMES, - MAX_NONCE_KEYS, - MAX_RECENT_ROOT_REFERENCES, MAX_BLOBS_PER_TX, EXPIRY_VERIFIER, } from './envelope.js' @@ -16,7 +40,7 @@ export { frameTxSigHash } from './sighash.js' export { SECP256K1_N, SECP256R1_N, - type FrameAccount, + type FrameSigner, assertCanonicalSignature, assertValidFrameTx, recoverFrameSigner, @@ -28,24 +52,16 @@ export { FRAME_TX_PER_FRAME_COST, FRAME_TX_VALUE_COST, GAS_PER_BLOB, - RECENT_ROOT_REFERENCE_ADDRESS_GAS, - RECENT_ROOT_REFERENCE_GAS, SIG_VERIFY_COST, STANDARD_TOKEN_COST, TOTAL_COST_FLOOR_PER_TOKEN, type FrameGas, frameTxGas, frameTxMaxCost, - nonceCalldata, - recentRootCalldata, } from './gas.js' export { - HEAD_KEYED_NONCE_STATE_GAS, - HEAD_RECENT_ROOT_VERIFIER, - HEAD_REFERENCE_BYTES, type GasDivergence, compareRuleSets, - toHeadShape, } from './divergence.js' export { type FrameReceipt, diff --git a/src/prepareFrameTransaction.ts b/src/prepareFrameTransaction.ts new file mode 100644 index 0000000..4cbe3f9 --- /dev/null +++ b/src/prepareFrameTransaction.ts @@ -0,0 +1,126 @@ +import type { BlockTag, Chain, Hex } from 'viem' +import { MAX_FRAMES } from './envelope.js' +import { FrameEncodeError } from './errors.js' +import type { FrameAccount, FrameAccountImplementation, FrameCall } from './accounts/types.js' +import type { FrameLimits, FrameTransaction } from './types.js' + +export type PrepareFrameLimits = { + /** Limits for the leading VERIFY frame. */ + validation: FrameLimits + /** One limit entry for each call, in the same order as `calls`. */ + calls: readonly FrameLimits[] +} + +export type PrepareFrameTransactionOptions = { + /** State used for nonce and fee reads. */ + blockTag?: BlockTag | undefined +} + +type RpcBlock = { + baseFeePerGas?: Hex | null | undefined +} + +function assertLimits(limits: FrameLimits, label: string): void { + if (limits.execution < 0n) + throw new FrameEncodeError(`${label}.execution must not be negative`) + if (limits.state < 0n) + throw new FrameEncodeError(`${label}.state must not be negative`) +} + +async function getFees( + account: FrameAccount, + blockTag: BlockTag, +): Promise<{ maxPriorityFeePerGas: bigint; maxFeePerGas: bigint }> { + const [priorityHex, block, gasPriceHex] = await Promise.all([ + account.client.request({ method: 'eth_maxPriorityFeePerGas' }), + account.client.request({ + method: 'eth_getBlockByNumber', + params: [blockTag, false], + }) as Promise, + account.client.request({ method: 'eth_gasPrice' }), + ]) + + const suggestedPriorityFee = BigInt(priorityHex) + const gasPrice = BigInt(gasPriceHex) + const baseFeePerGas = block?.baseFeePerGas + + if (baseFeePerGas === undefined || baseFeePerGas === null) { + return { + maxPriorityFeePerGas: + suggestedPriorityFee < gasPrice ? suggestedPriorityFee : gasPrice, + maxFeePerGas: gasPrice, + } + } + + return { + maxPriorityFeePerGas: suggestedPriorityFee, + maxFeePerGas: BigInt(baseFeePerGas) * 2n + suggestedPriorityFee, + } +} + +/** + * Build an unsigned direct-execution frame transaction. + * + * The returned transaction contains one leading VERIFY frame followed by one + * SENDER frame per call. Pass it to `account.signFrameTransaction` to produce + * the transaction that can be validated and encoded. + */ +export async function prepareFrameTransaction< + implementation extends FrameAccountImplementation, +>( + account: FrameAccount, + calls: readonly FrameCall[], + frameLimits: PrepareFrameLimits, + options: PrepareFrameTransactionOptions = {}, +): Promise { + if (calls.length === 0) + throw new FrameEncodeError('prepareFrameTransaction requires at least one call') + if (calls.length + 1 > MAX_FRAMES) + throw new FrameEncodeError( + `prepareFrameTransaction supports at most ${MAX_FRAMES - 1} calls because the validation frame also counts toward the ${MAX_FRAMES}-frame limit`, + ) + if (frameLimits.calls.length !== calls.length) + throw new FrameEncodeError( + `frameLimits.calls must contain one entry per call: expected ${calls.length}, got ${frameLimits.calls.length}`, + ) + + assertLimits(frameLimits.validation, 'frameLimits.validation') + for (const [index, limits] of frameLimits.calls.entries()) + assertLimits(limits, `frameLimits.calls[${index}]`) + + const blockTag = options.blockTag ?? 'pending' + const [nonce, fees] = await Promise.all([ + account.getNonce({ blockTag }), + getFees(account, blockTag), + ]) + const chainId = BigInt(account.client.chain.id) + const validationData = await account.getValidationData({ calls, chainId, nonce }) + + return { + chainId, + nonce, + sender: account.address, + frames: [ + { + mode: 1, + flags: 0x3, + target: null, + limits: frameLimits.validation, + value: 0n, + data: validationData, + }, + ...calls.map((call, index) => ({ + mode: 2 as const, + flags: 0, + target: call.to, + limits: frameLimits.calls[index]!, + value: call.value, + data: call.data, + })), + ], + signatures: [], + ...fees, + maxFeePerBlobGas: 0n, + blobVersionedHashes: [], + } +} diff --git a/src/rpc.ts b/src/rpc.ts index 8644ab5..7674a68 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -5,7 +5,6 @@ import type { FrameMode, FrameSignature, FrameTransaction, - RecentRootReference, SigScheme, } from './types.js' @@ -15,8 +14,7 @@ export type RpcFrameTransaction = { /** Present on `eth_getTransactionByHash` and hydrated blocks; absent on hand-written fixtures. */ hash?: Hex chainId: Hex - nonceKeys: readonly Hex[] - nonceSeq: Hex + nonce: Hex sender: Address frames: readonly { mode: Hex @@ -24,7 +22,7 @@ export type RpcFrameTransaction = { /** The node calls this `to`, not `target`. */ to: Address | null gasLimit: Hex - stateLimit: Hex + stateGasLimit: Hex value: Hex data: Hex }[] @@ -38,7 +36,6 @@ export type RpcFrameTransaction = { maxFeePerGas: Hex maxFeePerBlobGas: Hex blobVersionedHashes: readonly Hex[] - recentRootReferences: readonly { sourceId: Hex; slot: Hex; root: Hex }[] } /** EIP-8141 frame receipt status: 0 failure, 1 success, 2 skipped (atomic-batch failure). */ @@ -115,7 +112,7 @@ export function parseRpcFrameTransaction(json: RpcFrameTransaction): FrameTransa mode: toMode(f.mode), flags: Number(BigInt(f.flags)), target: f.to === null ? null : getAddress(f.to), - limits: { execution: BigInt(f.gasLimit), state: BigInt(f.stateLimit) }, + limits: { execution: BigInt(f.gasLimit), state: BigInt(f.stateGasLimit) }, value: BigInt(f.value), data: f.data, })) @@ -127,14 +124,9 @@ export function parseRpcFrameTransaction(json: RpcFrameTransaction): FrameTransa signature: s.signature, })) - const recentRootReferences: RecentRootReference[] = json.recentRootReferences.map( - (r) => ({ sourceId: r.sourceId, slot: BigInt(r.slot), root: r.root }), - ) - return { chainId: BigInt(json.chainId), - nonceKeys: json.nonceKeys.map((k) => BigInt(k)), - nonceSeq: BigInt(json.nonceSeq), + nonce: BigInt(json.nonce), sender: getAddress(json.sender), frames, signatures, @@ -142,7 +134,6 @@ export function parseRpcFrameTransaction(json: RpcFrameTransaction): FrameTransa maxFeePerGas: BigInt(json.maxFeePerGas), maxFeePerBlobGas: BigInt(json.maxFeePerBlobGas), blobVersionedHashes: [...json.blobVersionedHashes], - recentRootReferences, } } diff --git a/src/signFrameTransaction.ts b/src/signFrameTransaction.ts new file mode 100644 index 0000000..c8df940 --- /dev/null +++ b/src/signFrameTransaction.ts @@ -0,0 +1,19 @@ +import { isAddressEqual } from 'viem' +import type { FrameAccount, FrameAccountImplementation } from './accounts/types.js' +import { FrameEncodeError } from './errors.js' +import type { FrameTransaction } from './types.js' + +/** Sign a prepared transaction using its frame account's signing strategy. */ +export async function signFrameTransaction< + implementation extends FrameAccountImplementation, +>( + account: FrameAccount, + transaction: FrameTransaction, +): Promise { + if (!isAddressEqual(transaction.sender, account.address)) + throw new FrameEncodeError( + `transaction sender ${transaction.sender} does not match frame account ${account.address}`, + ) + + return account.signFrameTransaction(transaction) +} diff --git a/src/signatures.ts b/src/signatures.ts index 6171bb2..4c77d90 100644 --- a/src/signatures.ts +++ b/src/signatures.ts @@ -142,7 +142,7 @@ function bareRecoveryId(v: bigint, index: number): bigint { * `signMessage` is deliberately not an accepted substitute: it prefixes its * argument per EIP-191, so it cannot produce a signature over a sig-hash. */ -export type FrameAccount = { +export type FrameSigner = { address: Address sign?: ((parameters: { hash: Hex }) => Promise) | undefined } @@ -150,7 +150,7 @@ export type FrameAccount = { /** * Sign every empty-`msg` SECP256K1 entry over the transaction's sig-hash. * - * Takes either a raw private key or a `FrameAccount` — a hardware wallet, a + * Takes either a raw private key or a `FrameSigner` — a hardware wallet, a * KMS, an HD account, anything that signs a digest for a known address. * * Idempotent: the sig-hash elides empty-`msg` signature bytes, so re-signing an @@ -158,7 +158,7 @@ export type FrameAccount = { */ export async function signFrameTx( tx: FrameTransaction, - signer: Hex | FrameAccount, + signer: Hex | FrameSigner, ): Promise { const account = typeof signer === 'string' ? privateKeyToAccount(signer) : signer if (typeof account.sign !== 'function') diff --git a/src/types.ts b/src/types.ts index a2073d9..c9626cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,19 +35,10 @@ export type FrameSignature = { signature: Hex } -export type RecentRootReference = { - /** 32 bytes. */ - sourceId: Hex - slot: bigint - /** 32 bytes. */ - root: Hex -} - export type FrameTransaction = { chainId: bigint - /** 1..16 entries, strictly increasing; if more than one, the first is non-zero. */ - nonceKeys: bigint[] - nonceSeq: bigint + /** Sender account nonce, encoded as a scalar uint64. */ + nonce: bigint sender: Address frames: Frame[] signatures: FrameSignature[] @@ -55,18 +46,11 @@ export type FrameTransaction = { maxFeePerGas: bigint maxFeePerBlobGas: bigint blobVersionedHashes: Hex[] - recentRootReferences: RecentRootReference[] } /** * Which rule set to price under. - * - 'chain' — ethrex 31b532266, the deployed binary, with its two live divergences. - * - 'pins' — the pinned EIP text: 8141 7d1c8bfb94, 8250 e5cf246ff1, 8272 0231fb05f5. - * - 'head' — current drafts, for anticipating the next re-genesis. Models only - * the EIP-8250 gas change; the EIP-8272 head draft changes the *envelope* - * (recent-root references become a leading VERIFY frame), not just the - * price, so `frameTxGas` refuses rather than guess at a reference-carrying - * transaction under `'head'`. Price one through `compareRuleSets`, which - * routes it via `toHeadShape`, or call that transform directly. + * Kept for API compatibility with the kit's gas helpers. The current frame + * transaction envelope has no keyed-nonce or recent-root extensions. */ export type RuleSet = 'chain' | 'pins' | 'head' diff --git a/test/accounts.test.ts b/test/accounts.test.ts new file mode 100644 index 0000000..cec6557 --- /dev/null +++ b/test/accounts.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'vitest' +import { createClient, custom, getAddress } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { mainnet } from 'viem/chains' +import { toFrameAccount } from '../src/accounts/toFrameAccount.js' +import { toEoaFrameAccount } from '../src/accounts/toEoaFrameAccount.js' +import { recoverFrameSigner } from '../src/signatures.js' +import type { FrameTransaction } from '../src/types.js' +import { GOLDEN_TX } from './fixtures/golden.js' + +const OWNER_KEY = `0x${'11'.repeat(32)}` as const + +describe('toFrameAccount', () => { + test('resolves methods and extensions', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ async request() { throw new Error('unexpected RPC request') } }), + }) + const address = getAddress('0x0000000000000000000000000000000000001234') + const account = await toFrameAccount({ + client, + owner, + execution: { type: 'direct' }, + extend: { implementationName: 'test-account' as const }, + async getAddress() { return address }, + async getNonce() { return 1n }, + async getValidationData({ nonce }) { return nonce === 1n ? '0xabcd' : '0x' }, + async signFrameTransaction(transaction) { return transaction }, + }) + + expect(account.address).toBe(address) + expect(account.type).toBe('frame') + expect(account.implementationName).toBe('test-account') + expect(await account.getNonce()).toBe(1n) + expect(await account.getValidationData({ calls: [], chainId: 1n, nonce: 1n })).toBe('0xabcd') + expect(await account.signFrameTransaction(GOLDEN_TX as FrameTransaction)).toBe(GOLDEN_TX) + }) + + test('resolved fields override extension properties', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toFrameAccount({ + client, + owner, + execution: { type: 'direct' }, + extend: { address: 'extension-value', type: 'extension-value' }, + getAddress: async () => owner.address, + getNonce: async () => 0n, + getValidationData: async () => '0x', + signFrameTransaction: async (transaction) => transaction, + }) + + expect(account.address).toBe(owner.address) + expect(account.type).toBe('frame') + }) +}) + +describe('toEoaFrameAccount', () => { + test('uses default-code validation and signs as the EOA', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toEoaFrameAccount({ client, owner }) + const unsigned = { ...GOLDEN_TX, sender: owner.address, signatures: [] } + + expect(await account.getValidationData({ calls: [], chainId: 1n, nonce: 0n })).toBe('0x') + const signed = await account.signFrameTransaction(unsigned) + expect(signed.signatures[0]).toMatchObject({ scheme: 1, signer: null, msg: '0x' }) + expect(await recoverFrameSigner(signed, 0)).toBe(owner.address) + }) + + test('reads the pending scalar account nonce', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const requests: { method: string; params?: unknown }[] = [] + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ + async request(request) { + requests.push(request) + return '0x7' + }, + }), + }) + const account = await toEoaFrameAccount({ client, owner }) + + expect(await account.getNonce()).toBe(7n) + expect(requests).toEqual([ + { method: 'eth_getTransactionCount', params: [owner.address, 'pending'] }, + ]) + }) + + test('rejects a transaction whose sender is not the EOA', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toEoaFrameAccount({ client, owner }) + await expect(account.signFrameTransaction(GOLDEN_TX)).rejects.toThrow(/does not match EOA/) + }) +}) diff --git a/test/divergence.test.ts b/test/divergence.test.ts index dfcbd51..bebb913 100644 --- a/test/divergence.test.ts +++ b/test/divergence.test.ts @@ -1,164 +1,25 @@ import { describe, expect, test } from 'vitest' -import { size, sliceHex } from 'viem' -import { - HEAD_KEYED_NONCE_STATE_GAS, - HEAD_RECENT_ROOT_VERIFIER, - HEAD_REFERENCE_BYTES, - compareRuleSets, - toHeadShape, -} from '../src/divergence.js' +import { compareRuleSets } from '../src/divergence.js' import { frameTxGas } from '../src/gas.js' import { GOLDEN_TX } from './fixtures/golden.js' -const REF_A = { - sourceId: `0x${'11'.repeat(32)}`, - slot: 0xaf1n, - root: `0x${'22'.repeat(32)}`, -} as const - -const REF_B = { - sourceId: `0x${'33'.repeat(32)}`, - slot: 0xaf6n, - root: `0x${'44'.repeat(32)}`, -} as const - -const TWO_REFERENCE_TX = { ...GOLDEN_TX, recentRootReferences: [REF_A, REF_B] } - -/** A SENDER frame carrying value with no target — the shape the chain overcharges. */ -const TARGETLESS_VALUE_TX = { - ...GOLDEN_TX, - frames: [ - { - mode: 2 as const, - flags: 0, - target: null, - limits: { execution: 21_000n, state: 0n }, - value: 1n, - data: '0x' as const, - }, - ], -} - -describe('compareRuleSets', () => { - test('the golden vector prices identically under chain and pins', () => { +describe('compatibility rule-set aliases', () => { + test('all rule-set names use the current accounting rules', () => { expect(compareRuleSets(GOLDEN_TX, 'chain', 'pins')).toEqual([]) + expect(compareRuleSets(GOLDEN_TX, 'pins', 'head')).toEqual([]) }) - test('a targetless value frame diverges by exactly TX_VALUE_COST', () => { - const divergences = compareRuleSets(TARGETLESS_VALUE_TX, 'chain', 'pins') - const valueCost = divergences.find((d) => d.term === 'valueTransferCost') - expect(valueCost).toBeDefined() - expect(valueCost!.delta).toBe(6_000n) - }) - - test('a self-targeted value frame diverges by exactly TX_VALUE_COST', () => { - const tx = { - ...TARGETLESS_VALUE_TX, - frames: [{ ...TARGETLESS_VALUE_TX.frames[0]!, target: GOLDEN_TX.sender }], - } - const valueCost = compareRuleSets(tx, 'chain', 'pins').find( - (d) => d.term === 'valueTransferCost', - ) - expect(valueCost!.delta).toBe(6_000n) - }) - - test('a self-targeted frame is exempt regardless of address casing', () => { - // Regression guard: getAddress returns EIP-55 mixed case, the fixture is - // lowercase. A case-sensitive comparison charges 6000 here and is wrong. + test('a targetless self-transfer has no value-transfer charge', () => { const tx = { - ...TARGETLESS_VALUE_TX, - sender: '0x000000000000000000000000000000000000ABCD' as const, + ...GOLDEN_TX, frames: [ { - ...TARGETLESS_VALUE_TX.frames[0]!, - target: '0x000000000000000000000000000000000000abcd' as const, + ...GOLDEN_TX.frames[1]!, + target: null, + value: 1n, }, ], } - expect(compareRuleSets(tx, 'chain', 'pins')[0]!.delta).toBe(6_000n) - expect(frameTxGas(tx, 'pins').valueTransferCost).toBe(0n) - }) - - test('a value frame with a third-party target does not diverge', () => { - const tx = { - ...TARGETLESS_VALUE_TX, - frames: [ - { - ...TARGETLESS_VALUE_TX.frames[0]!, - target: '0x0000000000000000000000000000000000009999' as const, - }, - ], - } - expect(compareRuleSets(tx, 'chain', 'pins')).toEqual([]) - }) - - test('the chain overcharges relative to the pins, never the reverse', () => { - expect(frameTxGas(TARGETLESS_VALUE_TX, 'chain').maxGas).toBeGreaterThan( - frameTxGas(TARGETLESS_VALUE_TX, 'pins').maxGas, - ) - }) - - test("'pins' and 'head' never diverge on intrinsic gas", () => { - expect(compareRuleSets(TARGETLESS_VALUE_TX, 'pins', 'head')).toEqual([]) - }) -}) - -describe('toHeadShape', () => { - test('leaves a transaction that carries no reference untouched', () => { - expect(toHeadShape(GOLDEN_TX)).toBe(GOLDEN_TX) - }) - - test('empties the envelope field and prepends one VERIFY frame', () => { - const head = toHeadShape(TWO_REFERENCE_TX) - expect(head.recentRootReferences).toEqual([]) - expect(head.frames.length).toBe(GOLDEN_TX.frames.length + 1) - expect(head.frames.slice(1)).toEqual(GOLDEN_TX.frames) - }) - - // upstream eip-8272.md @ 824cbc0b0, §"Recent root verifier frame": the shape - // is normative — mode VERIFY, target RECENT_ROOT_ADDRESS, flags 0, value 0, - // limits.state 0 — and the data is n tuples with no selector or length prefix. - test('the synthetic frame matches the EIP-8272 recent-root-verifier-frame shape', () => { - const verify = toHeadShape(TWO_REFERENCE_TX).frames[0]! - expect(verify.mode).toBe(1) // VERIFY - expect(verify.target).toBe(HEAD_RECENT_ROOT_VERIFIER) - expect(verify.flags).toBe(0) - expect(verify.value).toBe(0n) - expect(verify.limits.state).toBe(0n) - // RECENT_ROOT_TUPLE_BYTES <= len(data), len(data) % RECENT_ROOT_TUPLE_BYTES == 0 - expect(size(verify.data)).toBeGreaterThanOrEqual(HEAD_REFERENCE_BYTES) - expect(size(verify.data) % HEAD_REFERENCE_BYTES).toBe(0) - }) - - // limits.execution is the one field the spec does NOT pin — it falls out of - // the STATICCALL and one SLOAD per tuple — so zero is a deliberate floor. - test('the synthetic frame claims no execution budget: a floor, not a figure', () => { - expect(toHeadShape(TWO_REFERENCE_TX).frames[0]!.limits.execution).toBe(0n) - }) - - test('both references pack into one frame, 72 bytes each', () => { - const data = toHeadShape(TWO_REFERENCE_TX).frames[0]!.data - expect(size(data)).toBe(2 * HEAD_REFERENCE_BYTES) - }) - - // eip-8272.md §"Validation operation": source_id: bytes32, slot: uint64_be, - // root: bytes32 — concatenated, big-endian slot, in that order. - test('each reference packs as source_id || slot || root', () => { - const data = toHeadShape(TWO_REFERENCE_TX).frames[0]!.data - for (const [i, ref] of [REF_A, REF_B].entries()) { - const at = i * HEAD_REFERENCE_BYTES - expect(sliceHex(data, at, at + 32)).toBe(ref.sourceId) - expect(BigInt(sliceHex(data, at + 32, at + 40))).toBe(ref.slot) - expect(sliceHex(data, at + 40, at + 72)).toBe(ref.root) - } - }) -}) - -describe('the head draft', () => { - test('publishes the keyed-nonce state-gas figure', () => { - // EIP-8250 moved the first use of a keyed nonce from 20,000 execution gas to - // 97,920 state gas. Two fresh keys therefore need 195,840 of limits.state. - expect(HEAD_KEYED_NONCE_STATE_GAS).toBe(97_920n) - expect(HEAD_KEYED_NONCE_STATE_GAS * 2n).toBe(195_840n) + expect(frameTxGas(tx).valueTransferCost).toBe(0n) }) }) diff --git a/test/envelope.decode.test.ts b/test/envelope.decode.test.ts index 53b7160..f12d83a 100644 --- a/test/envelope.decode.test.ts +++ b/test/envelope.decode.test.ts @@ -55,22 +55,22 @@ describe('decodeFrameTx: FrameDecodeError.offset', () => { }) test('a non-canonical scalar points at the offending byte', () => { - // nonceSeq 7 written long-form as `81 07`: chainId `01` at 3, nonceKeys - // `c1 80` at 4-5, so the bad `81` byte sits at offset 6. - const longFormNonceSeq = GOLDEN_RLP.replace('c18007', 'c1808107').replace( + // nonce 7 written long-form as `81 07`: chainId is at byte 3, so the bad + // `81` byte sits at offset 4. + const longFormNonce = GOLDEN_RLP.replace('0107', '018107').replace( '0x06f8b3', '0x06f8b4', ) as `0x${string}` - expect(offsetOf(longFormNonceSeq)).toBe(6) + expect(offsetOf(longFormNonce)).toBe(4) }) test('a non-minimal scalar field points at the field', () => { - // nonceSeq 7 written as the two-byte string `82 00 07`. - const leadingZero = GOLDEN_RLP.replace('c18007', 'c180820007').replace( + // nonce 7 written as the two-byte string `82 00 07`. + const leadingZero = GOLDEN_RLP.replace('0107', '01820007').replace( '0x06f8b3', '0x06f8b5', ) as `0x${string}` - expect(offsetOf(leadingZero)).toBe(6) + expect(offsetOf(leadingZero)).toBe(4) }) }) @@ -95,38 +95,38 @@ describe('decodeFrameTx: malformed bodies stay typed', () => { }) describe('decodeFrameTx: non-canonical RLP', () => { - // `nonceSeq` is 7, canonically the single byte 0x07. Written long-form as + // `nonce` is 7, canonically the single byte 0x07. Written long-form as // 0x81 0x07 it is one byte longer, so the outer list header grows from // 0xf8 0xb3 to 0xf8 0xb4. viem's `fromRlp` would accept the long form and // return 7; ethrex's decoder rejects it ("the 0x81 0x01 form is now // rejected"), and so does `walkRlp` — decoding it here would otherwise hand // back a transaction whose bytes the chain will not take. - const LONG_FORM_NONCE_SEQ = GOLDEN_RLP.replace('c18007', 'c1808107').replace( + const LONG_FORM_NONCE = GOLDEN_RLP.replace('0107', '018107').replace( '0x06f8b3', '0x06f8b4', ) test('the mutation is a real one', () => { - expect(LONG_FORM_NONCE_SEQ).not.toBe(GOLDEN_RLP) - expect(LONG_FORM_NONCE_SEQ.length).toBe(GOLDEN_RLP.length + 2) + expect(LONG_FORM_NONCE).not.toBe(GOLDEN_RLP) + expect(LONG_FORM_NONCE.length).toBe(GOLDEN_RLP.length + 2) }) test('rejects a long-form encoding of a single-byte scalar', () => { - expect(() => decodeFrameTx(LONG_FORM_NONCE_SEQ as `0x${string}`)).toThrow( + expect(() => decodeFrameTx(LONG_FORM_NONCE as `0x${string}`)).toThrow( FrameDecodeError, ) - expect(() => decodeFrameTx(LONG_FORM_NONCE_SEQ as `0x${string}`)).toThrow( + expect(() => decodeFrameTx(LONG_FORM_NONCE as `0x${string}`)).toThrow( /non-canonical/, ) }) test('rejects a non-minimal scalar (leading zero byte) as a decode error', () => { - // nonceSeq 7 written as the two-byte string 0x00 0x07: `82 00 07` replaces + // nonce 7 written as the two-byte string 0x00 0x07: `82 00 07` replaces // `07`, and the outer list grows by two. `walkRlp` returns the bytes // verbatim (a 2-byte string is well-formed RLP), `parseRlpUint` rejects the // leading zero, and the caller must see that as a FrameDecodeError like // every other malformed-body case, not the internal FrameRlpError. - const leadingZero = GOLDEN_RLP.replace('c18007', 'c180820007').replace( + const leadingZero = GOLDEN_RLP.replace('0107', '01820007').replace( '0x06f8b3', '0x06f8b5', ) as `0x${string}` diff --git a/test/envelope.encode.test.ts b/test/envelope.encode.test.ts index bee2957..a85b90b 100644 --- a/test/envelope.encode.test.ts +++ b/test/envelope.encode.test.ts @@ -7,28 +7,23 @@ describe('encodeFrameTx', () => { expect(encodeFrameTx(GOLDEN_TX)).toBe(GOLDEN_RLP) }) - test('encodes nonceSeq zero as empty string, not 0x00', () => { - const encoded = encodeFrameTx({ ...GOLDEN_TX, nonceSeq: 0n }) + test('encodes nonce zero as an empty RLP string, not 0x00', () => { + const encoded = encodeFrameTx({ ...GOLDEN_TX, nonce: 0n }) expect(encoded).not.toBe(GOLDEN_RLP) - // nonceSeq: 7 encodes as 07, nonceSeq: 0 encodes as 80 (empty string in RLP) - // Sequence c180 (nonceKeys) should be followed by 80 (nonceSeq zero) - // then 94 (sender), instead of 07 - expect(encoded).toContain('c1808094') + expect(encoded).toContain('018094') }) test('a targetless frame encodes an empty target, not 20 zero bytes', () => { const encoded = encodeFrameTx(GOLDEN_TX) - // frame 1 is `cc 01 03 80 c4825208 80 80 821122`: mode, flags, empty target - expect(encoded).toContain('cc010380c4825208') + expect(encoded).toContain('cf010380c7825208831e8480') }) test('encodeFrameTxBody is the wire encoding minus the 0x06 type byte', () => { expect(encodeFrameTx(GOLDEN_TX)).toBe(`0x06${encodeFrameTxBody(GOLDEN_TX).slice(2)}`) }) - test('limits always encode as a two-element list, even when state is zero', () => { - // frame 1 limits are { execution: 0x5208, state: 0 } -> c4 (825208)(80) - expect(encodeFrameTx(GOLDEN_TX)).toContain('c482520880') + test('limits always encode as a two-element list', () => { + expect(encodeFrameTx(GOLDEN_TX)).toContain('c7825208831e8480') }) test('the fees list always has three entries, even when maxFeePerBlobGas is zero', () => { @@ -36,8 +31,8 @@ describe('encodeFrameTx', () => { expect(encodeFrameTx(GOLDEN_TX)).toContain('cc843b9aca008506fc23ac0080') }) - test('an empty blob list and an empty reference list each encode as 0xc0', () => { - expect(encodeFrameTx(GOLDEN_TX).endsWith('c0c0')).toBe(true) + test('an empty blob list is the final 0xc0 field', () => { + expect(encodeFrameTx(GOLDEN_TX).endsWith('c0')).toBe(true) }) }) diff --git a/test/envelope.validate.test.ts b/test/envelope.validate.test.ts index 2adc8ce..cdf7ea9 100644 --- a/test/envelope.validate.test.ts +++ b/test/envelope.validate.test.ts @@ -60,28 +60,8 @@ describe('validateFrameTx', () => { expect(() => validateFrameTx(tx)).toThrow(/reserved/) }) - test('rejects an empty nonceKeys list', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, nonceKeys: [] })).toThrow(/between 1 and 16/) - }) - - test('rejects non-increasing nonce keys', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, nonceKeys: [2n, 2n] })).toThrow( - /strictly increasing/, - ) - }) - - test('rejects a leading zero key when there is more than one', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, nonceKeys: [0n, 1n] })).toThrow( - /first nonce key/, - ) - }) - - test('accepts a lone zero key', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, nonceKeys: [0n] })).not.toThrow() - }) - - test('rejects nonceSeq at 2**64 - 1', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, nonceSeq: 2n ** 64n - 1n })).toThrow(/nonceSeq/) + test('rejects nonce at 2**64 - 1', () => { + expect(() => validateFrameTx({ ...GOLDEN_TX, nonce: 2n ** 64n - 1n })).toThrow(/nonce/) }) test('rejects more than 64 frames', () => { @@ -173,55 +153,18 @@ describe('validateFrameTx: single-byte and 32-byte wire fields', () => { expect(() => validateFrameTx(tx)).toThrow(/flags.*one byte/) }) - // `source_id` and `root` are H256 in ethrex, decoded through the fixed - // `[u8; 32]` impl, which fails with InvalidLength for any other size. - test('rejects a recent-root sourceId that is not 32 bytes', () => { - const tx = { - ...GOLDEN_TX, - recentRootReferences: [ - { sourceId: `0x${'11'.repeat(31)}` as const, slot: 1n, root: `0x${'22'.repeat(32)}` as const }, - ], - } - expect(() => validateFrameTx(tx)).toThrow(/sourceId must be 32 bytes/) - }) - - test('rejects a recent-root root that is not 32 bytes', () => { - const tx = { - ...GOLDEN_TX, - recentRootReferences: [ - { sourceId: `0x${'11'.repeat(32)}` as const, slot: 1n, root: `0x${'22'.repeat(33)}` as const }, - ], - } - expect(() => validateFrameTx(tx)).toThrow(/root must be 32 bytes/) - }) - - test('accepts a well-formed recent-root reference', () => { - const tx = { - ...GOLDEN_TX, - recentRootReferences: [ - { sourceId: `0x${'11'.repeat(32)}` as const, slot: 1n, root: `0x${'22'.repeat(32)}` as const }, - ], - } - expect(() => validateFrameTx(tx)).not.toThrow() - }) }) /** * ethrex's `FrameTransaction` decodes each numeric field at a fixed width — - * `chain_id`, `nonce_seq`, `max_priority_fee_per_gas`, `max_fee_per_gas` and - * `RecentRootReference::slot` as u64; `max_fee_per_blob_gas`, `nonce_keys[]` - * and `Frame::value` as U256 — and `static_left_pad` returns `InvalidLength` + * `chain_id` and `nonce` as u64; all three fees and `Frame::value` as U256. + * `static_left_pad` returns `InvalidLength` * for anything wider. A wider value encodes to well-formed RLP here and then * cannot be decoded by the node at all, so it has to be caught on this side. */ describe('validateFrameTx: numeric field widths', () => { const U64_MAX = 2n ** 64n - 1n const U256_MAX = 2n ** 256n - 1n - const REF = { - sourceId: `0x${'11'.repeat(32)}`, - root: `0x${'22'.repeat(32)}`, - } as const - test('accepts chainId at 2**64 - 1', () => { expect(() => validateFrameTx({ ...GOLDEN_TX, chainId: U64_MAX })).not.toThrow() }) @@ -232,47 +175,25 @@ describe('validateFrameTx: numeric field widths', () => { ) }) - test('accepts maxPriorityFeePerGas at 2**64 - 1', () => { + test('accepts maxPriorityFeePerGas at 2**256 - 1', () => { expect(() => - validateFrameTx({ ...GOLDEN_TX, maxPriorityFeePerGas: U64_MAX }), + validateFrameTx({ ...GOLDEN_TX, maxPriorityFeePerGas: U256_MAX }), ).not.toThrow() }) - test('rejects maxPriorityFeePerGas at 2**64', () => { + test('rejects maxPriorityFeePerGas at 2**256', () => { expect(() => - validateFrameTx({ ...GOLDEN_TX, maxPriorityFeePerGas: 2n ** 64n }), - ).toThrow(/maxPriorityFeePerGas must fit in 64 bits/) - }) - - test('accepts maxFeePerGas at 2**64 - 1', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, maxFeePerGas: U64_MAX })).not.toThrow() - }) - - test('rejects maxFeePerGas at 2**64', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, maxFeePerGas: 2n ** 64n })).toThrow( - /maxFeePerGas must fit in 64 bits/, - ) - }) - - test('accepts a recent-root slot at 2**64 - 1', () => { - const tx = { ...GOLDEN_TX, recentRootReferences: [{ ...REF, slot: U64_MAX }] } - expect(() => validateFrameTx(tx)).not.toThrow() + validateFrameTx({ ...GOLDEN_TX, maxPriorityFeePerGas: 2n ** 256n }), + ).toThrow(/maxPriorityFeePerGas must fit in 256 bits/) }) - test('rejects a recent-root slot at 2**64', () => { - const tx = { ...GOLDEN_TX, recentRootReferences: [{ ...REF, slot: 2n ** 64n }] } - expect(() => validateFrameTx(tx)).toThrow( - /recentRootReference 0: slot must fit in 64 bits/, - ) + test('accepts maxFeePerGas at 2**256 - 1', () => { + expect(() => validateFrameTx({ ...GOLDEN_TX, maxFeePerGas: U256_MAX })).not.toThrow() }) - test('accepts a nonce key at 2**256 - 1', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, nonceKeys: [U256_MAX] })).not.toThrow() - }) - - test('rejects a nonce key at 2**256', () => { - expect(() => validateFrameTx({ ...GOLDEN_TX, nonceKeys: [2n ** 256n] })).toThrow( - /nonceKeys\[0\] must fit in 256 bits/, + test('rejects maxFeePerGas at 2**256', () => { + expect(() => validateFrameTx({ ...GOLDEN_TX, maxFeePerGas: 2n ** 256n })).toThrow( + /maxFeePerGas must fit in 256 bits/, ) }) @@ -327,28 +248,6 @@ describe('validateFrameTx: malformed hex fields throw FrameEncodeError', () => { expect(() => validateFrameTx(tx)).toThrow(/signature 0: msg/) }) - test('an odd-length recent-root sourceId', () => { - const tx = { - ...GOLDEN_TX, - recentRootReferences: [ - { sourceId: `0x${'11'.repeat(31)}1` as const, slot: 1n, root: `0x${'22'.repeat(32)}` as const }, - ], - } - expect(() => validateFrameTx(tx)).toThrow(FrameEncodeError) - expect(() => validateFrameTx(tx)).toThrow(/recentRootReference 0: sourceId/) - }) - - test('a non-hex recent-root root', () => { - const tx = { - ...GOLDEN_TX, - recentRootReferences: [ - { sourceId: `0x${'11'.repeat(32)}` as const, slot: 1n, root: `0x${'zz'.repeat(32)}` as const }, - ], - } - expect(() => validateFrameTx(tx)).toThrow(FrameEncodeError) - expect(() => validateFrameTx(tx)).toThrow(/recentRootReference 0: root/) - }) - test('an odd-length frame data field', () => { const tx = { ...GOLDEN_TX, diff --git a/test/fixtures/golden.ts b/test/fixtures/golden.ts index 71c0192..a07376e 100644 --- a/test/fixtures/golden.ts +++ b/test/fixtures/golden.ts @@ -1,8 +1,8 @@ import type { FrameTransaction } from '../../src/types.js' /** Two frames — a targetless VERIFY carrying data, then a SENDER frame with a - * target — one SECP256K1 signature with an empty msg, no state budget on - * either frame. The shape ethrex's `scripts/hegota-testnet/frametx.py` __main__ builds. */ + * target — one SECP256K1 signature with an empty msg and a non-zero state + * budget on the VERIFY frame. This is Ethrex v23's wire-format golden vector. */ /** EIP-55 form of `0x…abcd`. viem's `getAddress` — and therefore `decodeFrameTx` * and `parseRpcFrameTransaction` — returns checksummed addresses, so the fixture * is written checksummed and `toEqual` against decoder output holds without case @@ -12,15 +12,14 @@ const ABCD = '0x000000000000000000000000000000000000ABcD' as const export const GOLDEN_TX: FrameTransaction = { chainId: 1n, - nonceKeys: [0n], - nonceSeq: 7n, + nonce: 7n, sender: ABCD, frames: [ { mode: 1, flags: 3, target: null, - limits: { execution: 0x5208n, state: 0n }, + limits: { execution: 0x5208n, state: 0x1e8480n }, value: 0n, data: '0x1122', }, @@ -45,18 +44,17 @@ export const GOLDEN_TX: FrameTransaction = { maxFeePerGas: 0x6fc23ac00n, maxFeePerBlobGas: 0n, blobVersionedHashes: [], - recentRootReferences: [], } /** Copy VERBATIM from frame_tx_wire_tests.rs:67. Do not reflow or re-wrap: * a hand-wrapped transcription of this string was already caught being * 10 characters short. */ export const GOLDEN_RLP = - '0x06f8b301c1800794000000000000000000000000000000000000abcdeccc010380c48252088080821122de0280940000000000000000000000000000000000001234c4829c40808080f85cf85a0194000000000000000000000000000000000000abcd80b8410101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101cc843b9aca008506fc23ac0080c0c0' as const + '0x06f8b3010794000000000000000000000000000000000000abcdefcf010380c7825208831e848080821122de0280940000000000000000000000000000000000001234c4829c40808080f85cf85a0194000000000000000000000000000000000000abcd80b8410101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101cc843b9aca008506fc23ac0080c0' as const /** From frame_tx_wire_tests.rs:69. */ export const GOLDEN_SIG_HASH = - '0xd4df51143828c0338882dbd10c3308f3569972fe1928a7b5040ee18057920510' as const + '0x2518df13bab80fe6bcc7a7e462dea6d617ad6992d53411ddbaff26a8cbc22341' as const /** The golden transaction as the node would serve it from * `eth_getTransactionByHash`, minus the block fields. Field names and formats @@ -66,14 +64,13 @@ export const GOLDEN_SIG_HASH = export const GOLDEN_RPC_JSON = { type: '0x6', chainId: '0x1', - nonceKeys: ['0x0'], - nonceSeq: '0x7', + nonce: '0x7', sender: '0x000000000000000000000000000000000000abcd', frames: [ - { mode: '0x1', flags: '0x3', to: null, gasLimit: '0x5208', stateLimit: '0x0', + { mode: '0x1', flags: '0x3', to: null, gasLimit: '0x5208', stateGasLimit: '0x1e8480', value: '0x0', data: '0x1122' }, { mode: '0x2', flags: '0x0', to: '0x0000000000000000000000000000000000001234', - gasLimit: '0x9c40', stateLimit: '0x0', value: '0x0', data: '0x' }, + gasLimit: '0x9c40', stateGasLimit: '0x0', value: '0x0', data: '0x' }, ], signatures: [ { scheme: '0x1', signer: '0x000000000000000000000000000000000000abcd', msg: '0x', @@ -83,5 +80,4 @@ export const GOLDEN_RPC_JSON = { maxFeePerGas: '0x6fc23ac00', maxFeePerBlobGas: '0x0', blobVersionedHashes: [], - recentRootReferences: [], } as const diff --git a/test/gas.test.ts b/test/gas.test.ts index adbe991..9ee91ac 100644 --- a/test/gas.test.ts +++ b/test/gas.test.ts @@ -6,21 +6,19 @@ describe('frameTxGas on the golden vector', () => { const gas = frameTxGas(GOLDEN_TX, 'chain') test('mandatory gas', () => expect(gas.mandatoryGas).toBe(15_750n)) - test('billed byte count', () => expect(gas.billedBytes).toBe(90n)) - test('data cost', () => expect(gas.dataCost).toBe(1_224n)) - test('recent-root reference gas is zero with no references', () => - expect(gas.recentRootReferenceGas).toBe(0n)) - test('intrinsic gas', () => expect(gas.intrinsicGas).toBe(16_974n)) - test('state gas limit', () => expect(gas.stateGasLimit).toBe(0n)) - test('standard gas limit', () => expect(gas.standardGasLimit).toBe(77_974n)) - test('calldata tokens', () => expect(gas.calldataTokens).toBe(360n)) - test('calldata floor total', () => expect(gas.calldataFloorTotal).toBe(21_510n)) - test('max gas takes the standard branch', () => expect(gas.maxGas).toBe(77_974n)) + test('billed byte count', () => expect(gas.billedBytes).toBe(87n)) + test('data cost', () => expect(gas.dataCost).toBe(1_176n)) + test('intrinsic gas', () => expect(gas.intrinsicGas).toBe(16_926n)) + test('state gas limit', () => expect(gas.stateGasLimit).toBe(2_000_000n)) + test('standard gas limit', () => expect(gas.standardGasLimit).toBe(2_077_926n)) + test('calldata tokens', () => expect(gas.calldataTokens).toBe(348n)) + test('calldata floor total', () => expect(gas.calldataFloorTotal).toBe(21_318n)) + test('max gas takes the standard branch', () => expect(gas.maxGas).toBe(2_077_926n)) }) describe('frameTxMaxCost', () => { test('is maxGas * maxFeePerGas with no blobs', () => { - expect(frameTxMaxCost(GOLDEN_TX, 0n, 'chain')).toBe(2_339_220_000_000_000n) + expect(frameTxMaxCost(GOLDEN_TX, 0n, 'chain')).toBe(62_337_780_000_000_000n) }) test('adds the blob term at the base rate', () => { @@ -66,25 +64,6 @@ describe('the calldata floor branch', () => { }) }) -describe('recent-root references', () => { - test('charge 2400 + 2002 per reference and bill their RLP bytes', () => { - const tx = { - ...GOLDEN_TX, - recentRootReferences: [ - { sourceId: `0x${'11'.repeat(32)}` as const, slot: 100n, root: `0x${'22'.repeat(32)}` as const }, - ], - } - const gas = frameTxGas(tx, 'chain') - expect(gas.recentRootReferenceGas).toBe(2_400n + 2_002n) - expect(gas.billedBytes).toBeGreaterThan(90n) - }) - - test('an empty reference list bills no bytes at all', () => { - // recentRootCalldata must be '0x', NOT toRlp([]) === '0xc0' - expect(frameTxGas(GOLDEN_TX, 'chain').billedBytes).toBe(90n) - }) -}) - describe('signature verification cost by scheme', () => { // EIP-8141 Constants table (DESIGN.md §4): ARBITRARY 100, SECP256K1 2800, // P256 6700. Hand-written figures, not read back from SIG_VERIFY_COST. No @@ -131,17 +110,8 @@ describe('signature verification cost by scheme', () => { }) describe('rule sets', () => { - test("'head' prices identically to 'pins' — the head change is execution-time state gas", () => { + test('compatibility rule-set names price identically', () => { + expect(frameTxGas(GOLDEN_TX, 'chain')).toEqual(frameTxGas(GOLDEN_TX, 'pins')) expect(frameTxGas(GOLDEN_TX, 'head')).toEqual(frameTxGas(GOLDEN_TX, 'pins')) }) - - test("'head' throws for a transaction carrying a recent-root reference", () => { - const tx = { - ...GOLDEN_TX, - recentRootReferences: [ - { sourceId: `0x${'11'.repeat(32)}` as const, slot: 100n, root: `0x${'22'.repeat(32)}` as const }, - ], - } - expect(() => frameTxGas(tx, 'head')).toThrow(/no head-shaped equivalent/) - }) }) diff --git a/test/live/simulate.test.ts b/test/live/simulate.test.ts index 23e432a..ed392ab 100644 --- a/test/live/simulate.test.ts +++ b/test/live/simulate.test.ts @@ -1,88 +1,16 @@ +import { createPublicClient, http } from 'viem' import { describe, expect, test } from 'vitest' -import { http, createPublicClient } from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import { encodeFrameTx } from '../../src/envelope.js' -import { frameTxMaxCost } from '../../src/gas.js' -import { parseRpcFrameTransaction, simulateFrameTransaction } from '../../src/rpc.js' -import { signFrameTx } from '../../src/signatures.js' -import type { FrameTransaction } from '../../src/types.js' -import { loadChainFixtures } from '../helpers/fixtures.js' - -const RPC_URL = 'https://rpc1.privacy.ethrex.xyz' -const CHAIN_ID = 8141n -const GENESIS_HASH = '0x7ca0f7358d127dc4a68983050eb88837a5f384225254d1b009fa87fbcd0f2332' - -// A throwaway key. It holds no funds and never will; simulation does not spend. -const THROWAWAY_KEY = `0x${'42'.repeat(32)}` as const +const RPC_URL = 'https://rpc1.frames.ethrex.xyz' +const CHAIN_ID = 81410 const live = process.env.FRAMES_LIVE === '1' -describe.skipIf(!live)('ethrex_simulateFrameTransaction', () => { +describe.skipIf(!live)('Ethrex frames devnet', () => { const client = createPublicClient({ transport: http(RPC_URL) }) - const account = privateKeyToAccount(THROWAWAY_KEY) - - test('the node reports our chain id and the third genesis', async () => { - expect(BigInt(await client.getChainId())).toBe(CHAIN_ID) - const genesis = await client.getBlock({ blockNumber: 0n }) - expect(genesis.hash).toBe(GENESIS_HASH) - }) - - test('a throwaway-signed SelfVerify transaction is decoded, authenticated and priced', async () => { - const tx: FrameTransaction = { - chainId: CHAIN_ID, - nonceKeys: [0n], - nonceSeq: 0n, - sender: account.address, - frames: [ - { - mode: 1, // VERIFY: the only mode that can open a recognized prefix - flags: 0x3, // APPROVE_EXECUTION | APPROVE_PAYMENT → SelfVerify - target: null, - limits: { execution: 21_000n, state: 0n }, - value: 0n, - data: '0x', - }, - ], - signatures: [{ scheme: 1, signer: null, msg: '0x', signature: '0x' }], - maxPriorityFeePerGas: 1_000_000_000n, - maxFeePerGas: 30_000_000_000n, - maxFeePerBlobGas: 0n, - blobVersionedHashes: [], - recentRootReferences: [], - } - - const signed = await signFrameTx(tx, THROWAWAY_KEY) - const result = await simulateFrameTransaction(client, encodeFrameTx(signed)) - - // maxCost is a pure function of the fields and is present on every path. - // Proves the node computed the same max_gas from our bytes as we did. - expect(result.maxCost).toBe(frameTxMaxCost(signed, 0n, 'chain')) - - // The prefix shape is derived AFTER signature authentication, so a non-null - // shape proves the node decoded our envelope AND accepted our signature. - expect(result.prefixShape).toBe('SelfVerify') - - // A throwaway EOA has no code to call APPROVE, so the prefix cannot pass. - // Observed 2026-09-06: `valid: false`, `violation: "validation prefix frame reverted"`. - expect(result.valid).toBe(false) - expect(result.violation).toMatch(/prefix/) - }) - - test('a captured transaction re-encoded from its JSON is authenticated by the node', async () => { - const fixture = loadChainFixtures()[0] - if (fixture === undefined) return - const tx = parseRpcFrameTransaction(fixture.tx) - const result = await simulateFrameTransaction(client, encodeFrameTx(tx)) - // It cannot be valid any more (its nonce is spent or its root expired), but it - // must get past signature authentication, and the node's maxCost is stable. - expect(result.violation ?? '').not.toMatch(/does not authenticate/) - expect(result.maxCost).toBe(BigInt(fixture.simulate.maxCost)) - }) - test('a malformed envelope is rejected by the node, not silently accepted', async () => { - // Observed: JSON-RPC error -32000 "Invalid params: Error decoding field 'chain_id'". - await expect(simulateFrameTransaction(client, '0x06c0')).rejects.toThrow( - /decoding|Invalid params/, - ) + test('reports the expected chain and an Ethrex client', async () => { + expect(await client.getChainId()).toBe(CHAIN_ID) + const version = await client.request({ method: 'web3_clientVersion' }) + expect(version).toMatch(/^ethrex\//) }) }) diff --git a/test/oracles.test.ts b/test/oracles.test.ts index f634597..acfb2a6 100644 --- a/test/oracles.test.ts +++ b/test/oracles.test.ts @@ -1,151 +1,22 @@ import { describe, expect, test } from 'vitest' -import { keccak256 } from 'viem' import { decodeFrameTx, encodeFrameTx } from '../src/envelope.js' +import { frameTxSigHash } from '../src/sighash.js' import { - FRAME_TX_PER_FRAME_COST, - RECENT_ROOT_REFERENCE_ADDRESS_GAS, - RECENT_ROOT_REFERENCE_GAS, - frameTxGas, - frameTxMaxCost, -} from '../src/gas.js' -import { type GasDivergence, compareRuleSets } from '../src/divergence.js' -import { parseRpcFrameReceipt, parseRpcFrameTransaction } from '../src/rpc.js' -import { recoverFrameSigner, resolveSigner } from '../src/signatures.js' -import { loadChainFixtures } from './helpers/fixtures.js' + GOLDEN_RLP, + GOLDEN_SIG_HASH, + GOLDEN_TX, +} from './fixtures/golden.js' -const fixtures = loadChainFixtures() - -/** - * Every term the head envelope change can move: the dropped reference charge, the - * extra frame, the calldata that moves from `rlp(refs)` into frame data, and the - * totals derived from those. A term outside this set is a finding. - */ -const HEAD_SHAPE_TERMS = new Set([ - 'mandatoryGas', - 'recentRootReferenceGas', - 'billedBytes', - 'dataCost', - 'intrinsicGas', - 'calldataTokens', - 'calldataFloorGas', - 'calldataFloorTotal', - 'standardGasLimit', - 'maxGas', -]) - -describe('captured fixtures', () => { - test('at least one fixture is present', () => { - expect(fixtures.length).toBeGreaterThan(0) - }) - - test('every fixture was captured against the third genesis', () => { - for (const f of fixtures) - expect(f.meta.genesisHash).toBe( - '0x7ca0f7358d127dc4a68983050eb88837a5f384225254d1b009fa87fbcd0f2332', - ) - }) -}) - -describe.each(fixtures)('$name', (fixture) => { - const tx = parseRpcFrameTransaction(fixture.tx) - const raw = encodeFrameTx(tx) - - // Oracle 2: absolute layout. The transaction hash is keccak256 of the canonical - // bytes, so reproducing it from the node's decoded JSON pins every byte of the - // envelope — nesting included. A field-by-field diff of two decoders could not - // see a mis-nesting they both made; the hash can. - test('re-encoding the node JSON reproduces the transaction hash', () => { - expect(keccak256(raw)).toBe(fixture.tx.hash) - }) - - // Oracle 2, decoder side: our decoder over those bytes agrees with the node's - // decoder over the same bytes. - test('decodes to the same transaction the node reports', () => { - expect(decodeFrameTx(raw)).toEqual(tx) - }) - - // The node ran signature authentication over our bytes and got past it, i.e. - // our sig_hash over a REAL signature equals the node's. Recorded at capture. - test('the node authenticated our re-encoding', () => { - expect(fixture.simulate.violation ?? '').not.toMatch(/does not authenticate/) +describe('Ethrex v23 wire oracle', () => { + test('encodes the seven-field transaction byte-for-byte', () => { + expect(encodeFrameTx(GOLDEN_TX)).toBe(GOLDEN_RLP) }) - // Oracle 3: recovery. The signer is often NOT the sender on this chain — the - // shielded pool's spends have the pool as sender and an EOA as signer — so the - // comparison is against the resolved signer, not `sender`. - test('every empty-msg secp256k1 signature recovers to its resolved signer', async () => { - for (const [i, sig] of tx.signatures.entries()) { - if (sig.scheme !== 1 || sig.msg !== '0x') continue - expect(await recoverFrameSigner(tx, i)).toBe(resolveSigner(tx, i)) - } - }) - - // Oracle 3: the receipt parses, one entry per frame, three-valued status intact. - test('the receipt parses with one entry per frame', () => { - const receipt = parseRpcFrameReceipt(fixture.receipt) - expect(receipt.frameReceipts.length).toBe(tx.frames.length) - }) - - // Oracle 3: max gas. The node computed `maxCost` from OUR bytes; 'chain' must - // reproduce it exactly. The blob term needs the block's blob base fee, which - // the fixture does not carry, so blob-carrying transactions are skipped here. - test("'chain' reproduces the node's maxCost", () => { - if (tx.blobVersionedHashes.length > 0) return - expect(frameTxMaxCost(tx, 0n, 'chain')).toBe(BigInt(fixture.simulate.maxCost)) - }) - - // Oracle 3: the receipt reconciles. Observed with a zero delta on every live - // transaction checked: gasUsed = intrinsic + Σ execution + Σ state. Four of - // the five captured fixtures (block 2782, 2787, 2792, 42241) take the - // standard branch; the fifth, 0xe936e39d…25fb1 at block 42086, takes the - // EIP-7623 calldata-floor branch — standard 26246, floored 27382, actual - // receipt gasUsed 27382 — so both arms are exercised on live data. - test('receipt.gasUsed = intrinsic + Σ frame gasUsed + Σ frame stateGasUsed', () => { - const receipt = parseRpcFrameReceipt(fixture.receipt) - const gas = frameTxGas(tx, 'chain') - const execution = receipt.frameReceipts.reduce((acc, f) => acc + f.gasUsed, 0n) - const state = receipt.frameReceipts.reduce((acc, f) => acc + f.stateGasUsed, 0n) - const standard = gas.intrinsicGas + execution + state - const floored = gas.calldataFloorTotal + state - expect(BigInt(fixture.receipt.gasUsed)).toBe(standard > floored ? standard : floored) - }) - - // The divergence survey. Any divergence must be one the ledger explains. - test('chain-vs-pins divergences are only ever valueTransferCost-rooted', () => { - const divergences = compareRuleSets(tx, 'chain', 'pins') - if (divergences.length === 0) return - const root = divergences.find((d) => d.term === 'valueTransferCost') - expect(root, `unexplained divergence in ${fixture.name}: ${JSON.stringify( - divergences.map((d) => d.term), - )}`).toBeDefined() - expect(root!.delta % 6_000n).toBe(0n) - }) - - // The same survey against the head draft. Two of the captured fixtures carry - // recent-root references, the shape `frameTxGas(tx, 'head')` refuses, so this - // runs over `toHeadShape` and is the only place head pricing meets live data. - const refs = tx.recentRootReferences.length - - test('pins-vs-head divergences are rooted in the dropped envelope field', () => { - const divergences = compareRuleSets(tx, 'pins', 'head') - if (refs === 0) { - expect(divergences).toEqual([]) - return - } - - const byTerm = new Map(divergences.map((d) => [d.term, d.delta])) - // The envelope field is gone: 2400 for the address, 2002 per reference. - expect(byTerm.get('recentRootReferenceGas')).toBe( - RECENT_ROOT_REFERENCE_ADDRESS_GAS + BigInt(refs) * RECENT_ROOT_REFERENCE_GAS, - ) - // One frame more than the envelope shape, and no other mandatory term moves. - expect(byTerm.get('mandatoryGas')).toBe(FRAME_TX_PER_FRAME_COST) + test('decodes the exact bytes without a dialect transform', () => { + expect(decodeFrameTx(GOLDEN_RLP)).toEqual(GOLDEN_TX) }) - test('pins-vs-head moves no term outside the two roots', () => { - const unexpected = compareRuleSets(tx, 'pins', 'head') - .map((d) => d.term) - .filter((term) => !HEAD_SHAPE_TERMS.has(term)) - expect(unexpected, `${fixture.name} diverges on an unaccounted term`).toEqual([]) + test('reproduces the canonical signature hash', () => { + expect(frameTxSigHash(GOLDEN_TX)).toBe(GOLDEN_SIG_HASH) }) }) diff --git a/test/prepareFrameTransaction.test.ts b/test/prepareFrameTransaction.test.ts new file mode 100644 index 0000000..5843147 --- /dev/null +++ b/test/prepareFrameTransaction.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'vitest' +import { createClient, custom, getAddress } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { mainnet } from 'viem/chains' +import { toEoaFrameAccount } from '../src/accounts/toEoaFrameAccount.js' +import { prepareFrameTransaction } from '../src/prepareFrameTransaction.js' +import { assertValidFrameTx } from '../src/signatures.js' + +const OWNER_KEY = `0x${'11'.repeat(32)}` as const + +function testClient() { + const requests: { method: string; params?: unknown }[] = [] + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + account: owner, + chain: { ...mainnet, id: 81_410 }, + transport: custom({ + async request(request) { + requests.push(request) + switch (request.method) { + case 'eth_getTransactionCount': return '0x7' + case 'eth_maxPriorityFeePerGas': return '0x3b9aca00' + case 'eth_gasPrice': return '0x77359400' + case 'eth_getBlockByNumber': return { baseFeePerGas: '0x77359400' } + default: throw new Error(`unexpected RPC method ${request.method}`) + } + }, + }), + }) + return { client, owner, requests } +} + +describe('prepareFrameTransaction', () => { + test('builds a VERIFY frame and one SENDER frame per call', async () => { + const { client, owner, requests } = testClient() + const account = await toEoaFrameAccount({ client, owner }) + const first = getAddress('0x0000000000000000000000000000000000001234') + const second = getAddress('0x0000000000000000000000000000000000005678') + + const transaction = await prepareFrameTransaction( + account, + [ + { to: first, value: 1n, data: '0x' }, + { to: second, value: 2n, data: '0x1234' }, + ], + { + validation: { execution: 80_000n, state: 0n }, + calls: [ + { execution: 30_000n, state: 100_000n }, + { execution: 40_000n, state: 200_000n }, + ], + }, + ) + + expect(transaction).toMatchObject({ + chainId: 81_410n, + nonce: 7n, + sender: owner.address, + signatures: [], + maxPriorityFeePerGas: 1_000_000_000n, + maxFeePerGas: 5_000_000_000n, + maxFeePerBlobGas: 0n, + blobVersionedHashes: [], + }) + expect(transaction.frames).toEqual([ + { + mode: 1, + flags: 3, + target: null, + limits: { execution: 80_000n, state: 0n }, + value: 0n, + data: '0x', + }, + { + mode: 2, + flags: 0, + target: first, + limits: { execution: 30_000n, state: 100_000n }, + value: 1n, + data: '0x', + }, + { + mode: 2, + flags: 0, + target: second, + limits: { execution: 40_000n, state: 200_000n }, + value: 2n, + data: '0x1234', + }, + ]) + + const signed = await account.signFrameTransaction(transaction) + expect(() => assertValidFrameTx(signed)).not.toThrow() + expect(requests.some(({ method }) => method === 'eth_chainId')).toBe(false) + }) + + test('requires one call-limit entry per call', async () => { + const { client, owner, requests } = testClient() + const account = await toEoaFrameAccount({ client, owner }) + + await expect( + prepareFrameTransaction( + account, + [{ to: owner.address, value: 0n, data: '0x' }], + { validation: { execution: 1n, state: 0n }, calls: [] }, + ), + ).rejects.toThrow(/one entry per call/) + expect(requests).toEqual([]) + }) + + test('rejects negative frame limits before making RPC requests', async () => { + const { client, owner, requests } = testClient() + const account = await toEoaFrameAccount({ client, owner }) + + await expect( + prepareFrameTransaction( + account, + [{ to: owner.address, value: 0n, data: '0x' }], + { + validation: { execution: 1n, state: 0n }, + calls: [{ execution: -1n, state: 0n }], + }, + ), + ).rejects.toThrow(/execution must not be negative/) + expect(requests).toEqual([]) + }) +}) diff --git a/test/signFrameTransaction.test.ts b/test/signFrameTransaction.test.ts new file mode 100644 index 0000000..8489ace --- /dev/null +++ b/test/signFrameTransaction.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test, vi } from 'vitest' +import { createClient, custom, getAddress } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { mainnet } from 'viem/chains' +import { toEoaFrameAccount } from '../src/accounts/toEoaFrameAccount.js' +import { signFrameTransaction } from '../src/signFrameTransaction.js' +import { recoverFrameSigner } from '../src/signatures.js' +import { GOLDEN_TX } from './fixtures/golden.js' + +const OWNER_KEY = `0x${'11'.repeat(32)}` as const + +describe('signFrameTransaction', () => { + test('delegates signing to the frame account and returns the signed transaction', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toEoaFrameAccount({ client, owner }) + const unsigned = { ...GOLDEN_TX, sender: owner.address, signatures: [] } + const spy = vi.spyOn(account, 'signFrameTransaction') + + const signed = await signFrameTransaction(account, unsigned) + + expect(spy).toHaveBeenCalledOnce() + expect(spy).toHaveBeenCalledWith(unsigned) + expect(await recoverFrameSigner(signed, 0)).toBe(owner.address) + }) + + test('rejects a transaction for a different sender before signing', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toEoaFrameAccount({ client, owner }) + const spy = vi.spyOn(account, 'signFrameTransaction') + + await expect( + signFrameTransaction(account, { + ...GOLDEN_TX, + sender: getAddress('0x0000000000000000000000000000000000001234'), + }), + ).rejects.toThrow(/does not match frame account/) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/test/viem.test.ts b/test/viem.test.ts index 3008b54..b0ec1bc 100644 --- a/test/viem.test.ts +++ b/test/viem.test.ts @@ -76,7 +76,7 @@ describe('frameActions', () => { test('estimateFrameGas prices under the requested rule set', () => { const actions = frameActions(stubClient({})) - expect(actions.estimateFrameGas({ transaction: GOLDEN_TX }).maxGas).toBe(77_974n) + expect(actions.estimateFrameGas({ transaction: GOLDEN_TX }).maxGas).toBe(2_077_926n) }) test('composes with client.extend on a real viem client', () => { @@ -87,7 +87,7 @@ describe('frameActions', () => { frameActions, ) expect(typeof client.getFrameTransaction).toBe('function') - expect(client.estimateFrameGas({ transaction: GOLDEN_TX }).maxGas).toBe(77_974n) + expect(client.estimateFrameGas({ transaction: GOLDEN_TX }).maxGas).toBe(2_077_926n) }) test('waitForFrameTransactionReceipt polls until the receipt is present', async () => {