ERC-8265: Prepared Transaction Envelope

This draft defines an off-chain envelope that carries a prepared-but-unsigned transaction, batch, or signature request from the producer that prepared it to the consumer that signs it. Posting it here for community review first.

A producer is anything that prepares a transaction without holding keys - a relayer, DeFi UI, intent solver, multisig coordinator, hardware-wallet companion, or AI agent. A consumer is a wallet, signer, or policy engine. The envelope wraps one of three normative payload kinds - evm-tx, evm-batch, signature - with semantic metadata, producer provenance, origin verification, and a consumer-injected risk slot. Nine further kinds are reserved for forward compatibility.

The envelope composes with EIP-712, ERC-5792, and ERC-6492 (all Final) and integrates non-normatively with ERC-7730, ERC-7715, and ERC-8004 (all Draft). It redefines none of them. The wallet remains the source of truth for keys and signing. evm-batch aligns 1:1 with ERC-5792 calls, atomicRequired, and status codes, with a one-way projection to wallet_sendCalls (§8.4).

Why now

Production stacks that prepare transactions for users already ship at scale - Alchemy’s scoped CLI sessions, Coinbase AgentKit, Crossmint’s GOAT SDK, and agent-commerce extensions from Google, Visa, and Mastercard each define their own ad-hoc producer-to-wallet shape. ERC-5792 standardized calls, but not the surrounding metadata - validity windows, decoder references, counterparty labels, risk hints - that a wallet needs to render a preview a user can actually trust.

The same gap shows up in a recurring class of losses: multisig approvals whose UI diverged from on-chain effect, dormant signed transactions executing long after preparation, blind Permit signing, EIP-7702 authorization-tuple drainers, and address poisoning. Each shares one structural cause - the upstream context that would let a wallet refuse never reached the wallet. This proposal standardizes that context.

Draft

Full RFC plus four worked examples - a single transaction, a Permit2 swap, a Safe delegatecall warning, and an ERC-5792 batch:

Reference implementation - TypeScript types and zod validators, zero runtime dependencies - is published as @txkit/tx-protocol on npm. Source and the runnable examples: mono/packages/tx-protocol at main · txkit/mono · GitHub

Acknowledged input from

This draft benefited from a public exchange on the ERC-8004 thread with Tiago Merlini (@TMerlini), author of a companion proposal on on-chain input trust boundaries for ERC-8004 agents. His production deployment surfaced the consumer-to-producer resolution question in ask 3 below, and the two proposals compose cleanly at that boundary. Beyond that, this builds directly on EIP-712, ERC-5792, ERC-6492, ERC-7730, and CAIP-2 - credit to their authors.

Three things I’d value feedback on

  1. Kind taxonomy. Three kinds are normative (evm-tx, evm-batch, signature); nine are reserved (evm-userop, evm-7702, intent, psbt, and others). Is that the right cut, or should evm-userop / evm-7702 ship normative in v0.1 rather than reserved?

  2. Decoder integration. Each evm-tx carries either a decoderRef URI to an ERC-7730 descriptor or an inline clearSigning block for offline wallets that cannot resolve URIs (§5.7). Ledger and the ERC-7730 authors especially - is that the integration shape you want?

  3. Scope boundary. The envelope is deliberately one-directional: producer to consumer. The reverse path - a consumer reporting an outcome back to a producer (approved / rejected / expired, retry vs abandon) - came up in the exchange above and is currently out of scope. Should v0.1 reserve an appendix note for it, or stay silent and leave it to a companion spec?

I’ll be active in the thread this week and would rather hear hard objections now than after the PR. The ERCs PR follows once this thread can be cited as discussions-to.

3 Likes

Coming at this from the ERC-8004 agent side — we are exactly the “producer”
class you’re describing. We have this handoff running in production on Pixel
Goblins and Goblinarinos agents at gateway.ensub.org. Reading your spec, we
went ahead and aligned our implementation with your design decisions so the
feedback below comes from production runs done this morning.


What we tested today

Test 1 — decline flow. Agent proposed a swap (0.005 ETH → 10.608 USDC).
Approval card appeared in the chat UI. User declined via wallet signature
(ens-approval:declined:{id} sign message in MetaMask). Agent received:

ScreenRecording2026-05-18at10.27.32-ezgif.com-video-to-gif-converter

{ "status": "declined", "intent": "abandon", "reason": "user_rejected",
  "message": "Transaction declined by user. Do not re-offer without asking." }


Agent stopped cleanly. Did not re-prompt.

Test 2 — full approval. Agent proposed the same swap. User approved via
wallet signature. Gateway re-fetched fresh calldata (prevents stale-quote
reverts), opened MetaMask with fresh tx params. User signed. Transaction
confirmed on-chain:

ScreenRecording2026-05-18at10.30.07-ezgif.com-video-to-gif-converter

0x35023acb306b2fd8b6ebe1f7530d05d225628b70d25f7f88e76efd1b7020b3c4

0.005 ETH → 10.583175 USDC. Agent received { status: "submitted", txHash },
summarised the result and stopped.


Our current approval gate — aligned shape

// Created when an ERC-8004 agent calls send_transaction
ApprovalRecord {
  id:             string          // envelopeId
  agent_registry: string          // ERC-8004 registry address
  agent_id:       string          // token ID  →  producer_id = `${registry}:${agentId}`
  owner_address:  string          // wallet that must sign
  tx_data: {
    tool:  string                 // MCP tool name
    input: Record<string, unknown> // decoded tool args from agent reasoning
  }
  expires_at:     number          // unixepoch + 300 (5 min window)
  status:         "pending" | "approved" | "declined" | "expired"
  intent:         "retry" | "abandon" | null
  reason:         string | null
  note:           string | null   // tx hash on approval
}


Fresh calldata at signing — we do NOT sign the calldata computed during
reasoning. At approval time, we re-fetch from the MCP tool with the original
arguments. Same “re-prepare at consumption time” principle your spec names.
This is what prevented a stale-quote revert in test 2.

Simulation at approval time — after fetching fresh calldata we run
eth_estimateGas({ from, to, data, value }) against current chain state
before MetaMask opens. If the tx would revert (slippage moved, allowance
missing, deadline expired in calldata), the revert reason is surfaced in the
UI and MetaMask never opens — the user loses no gas. On success, the fresh
gas estimate plus a 10% buffer replaces the provider’s stale limit (taking
whichever is higher).

Balance pre-check — using the fresh gas limit from simulation, we call
eth_getBalance + eth_getFeeData to verify the wallet can cover
txValue + gasLimit * maxFeePerGas. If not, we surface the ETH shortfall
before MetaMask opens. Both checks are non-blocking on RPC failure — if the
public node is slow or unavailable, MetaMask opens with the provider’s
original params rather than blocking the user.


On feedback topic 3 — outcome reporting (the crux)

Before today our gate returned:

// Old — broke the tool loop
{ status: "approved" | "declined", note: string | null }


Timeout and explicit decline both returned status: "declined". Agent retried
on hard nos. We’ve shipped the fix — the tool loop now receives:

{
  status:  "approved" | "declined" | "expired"
  intent:  "retry" | "abandon"
  reason?: string
  txHash?: string
}


Resolution matrix (from the agent’s perspective):

approved                      → resume with txHash
declined + intent "abandon"   → acknowledge and stop  (explicit wallet signature)
declined + intent "retry"     → can re-offer or ask why
expired  + intent "retry"     → re-emit, wait for new gate (5 min window elapsed)


intent is set at resolve time: explicit wallet-signature decline → "abandon";
expiry → "expired" + "retry"; admin override → "abandon".

The intent field is the minimum the spec needs to reserve space for. Without
it, producer tool loops cannot be well-behaved. Whether it lives in this spec
or a thin companion is open — but the distinction needs to exist somewhere.


Gap table — current state vs your spec

Feature Our gate (today) PreparedTransaction v0.1
Validity window :white_check_mark: 5 min expires_at validity.notAfter required
Expiry status :white_check_mark: expired + intent: "retry" Feedback topic 3
Intent on decline :white_check_mark: "abandon" on wallet-sig decline Feedback topic 3
Balance pre-check :white_check_mark: eth_getBalance + gas estimate before MetaMask Not in spec — UI concern
Decoded calldata Pre-simulation tool args Post-simulation decoded
Simulation :white_check_mark: eth_estimateGas at approval time — revert reason surfaced before MetaMask Consumer-injected
Risk assessment Empty field Consumer-injected (correct — not producer-attested)

On Producer.id and ERC-8004

{registry}:{agentId} is the natural value for Producer.id when the
producer is an ERC-8004 agent — it’s the canonical identifier in our
attestation logs and endpoint paths. Worth a note in the spec: any consumer
can take this value, call eth_call to the registry contract, and verify the
agent’s declared capabilities on-chain. The envelope carries a verifiable
producer reference for free.


On validity.notAfter bounded by on-chain expiry

Our flat 5-minute window works for general transactions. Wiring expires_at
to the Permit2 deadline or ERC-4337 validUntil when present — so the
envelope is always a strict subset of the transaction’s own validity — is the
obvious next step and the right design.


Happy to align the resolution shape once your draft settles.

2 Likes

This is the most useful reply a draft can get - thank you for running it against a live gateway instead of reading it cold.

The part that matters most: your gate re-fetches calldata at approval time rather than signing what was computed during reasoning. That is the principle the draft is built on - the Re-preparation at consumption time subsection in Rationale: a consumer treats an envelope as a claim to re-verify at the moment of use, not as producer state to trust. Your gate is a producer and a consumer in one process already applying it, and the gap table reads as an implementation report - the strongest validation the shape could get.

Feedback topic 3. You have convinced me. v0.1 will reserve outcome reporting as a non-normative appendix: the reverse path stays out of scope normatively, but the draft explicitly reserves the design space so a future version or a companion spec can define it without colliding with the envelope namespace. The minimal shape you outlined - a terminal status, a producer-facing field separating “treat as final” from “re-prepare against” (your intent), an optional reason, and a tx identifier on success - is the right starting direction. I will not fix field names at the reserved stage. Your point that timeout and explicit decline must not collapse to one value is exactly why the space needs reserving.

producer.id. {registry}:{agentId} is the ERC-8004 registry-record form that §3.1 already lists as a valid producer.id, so an agent producer needs no new identifier shape.

validity.notAfter. §5.4 already requires notAfter not to exceed the earliest on-chain expiry (Permit2 deadline, ERC-4337 validUntil). A flat window is a fine default; tightening it to those bounds is exactly the implementer work §5.4 calls for.

The ERCs PR is open (Add ERC: Prepared Transaction Envelope by mike-diamond · Pull Request #1753 · ethereum/ERCs · GitHub); the reserved appendix lands there. Glad to align the reverse-path shape when it firms up.

2 Likes

Thanks Mike, glad the production perspective is useful. This is exactly the kind of spec where the edge cases only show up when you run it.

The “treat as final / re-prepare against” framing maps exactly to what we shipped as intent: “abandon” | “retry”. Our gate has been live with this shape since this morning:

expired → status: “expired”, intent: “retry” // re-prepare against

declined → status: “declined”, intent: “abandon” // treat as final (wallet-sig explicit)

approved → status: “approved”, txHash // terminal

The distinct value for timeout vs. explicit decline was the fix that mattered most in practice, before we had both returning “declined” and the agent couldn’t tell a hard no from a window that closed. Separate top-level values (expired vs declined) with intent as the producer-facing signal worked cleanly. The agent loop is well-behaved now.

On producer.id — {registry}:{agentId} fits the appendix naturally. Any consumer can take that value, call eth_call to the registry, and verify the producer’s declared capabilities on-chain. The envelope carries a verifiable producer reference for free.

Looking forward to the ERCs PR

1 Like

The reserved appendix is already in the PR - §11, “Appendix B: Outcome Reporting (non-normative)” in #1753. It reserves the shape we converged on: a terminal status (acted on / rejected / expired), a producer-facing disposition separating an outcome to treat as final from one to re-prepare against, an optional reason, and a transaction identifier where one was produced. That describes, field for field, what your gate ships as status / intent / reason / txHash - reserved without fixing names, so your naming stays a live option for whoever specifies the reverse path normatively.

If you read §11 against your production loop and the reserved description misses anything that loop depends on, that is worth raising now, before an editor picks up the PR. The appendix is the cheapest place to widen the reserved space.

1 Like

Confirmed on our end, Appendix B matches production exactly.

Our live shape at gateway.ensub.org — mapped against §11 Appendix B:

// awaitApproval() return — §11 Appendix B reserved fields
{
  status:  "approved" | "declined" | "expired",  // §11 terminal status
  intent:  "retry"   | "abandon",                // §11 producer-facing disposition
  reason?: string,                               // §11 optional reason
  note?:   string,                               // §11 transaction identifier (txHash on approved)
}


Zero delta from what you reserved. Field names, semantics, and the
terminal/disposition split are all aligned.


One thing worth pinning before editorial pickup:

The intent field resolves ambiguity that status alone cannot express.
Two concrete cases from production this morning:

  • status: "expired" → agent re-emits (5-min window elapsed, no user action)

  • status: "declined" + intent: "abandon" → agent stops (explicit wallet-signature rejection)

Without intent, a producer loop has to guess. With it, the behavior is
deterministic. If the appendix can note that intent is the field driving
producer loop decisions, not just labelling the outcome, it will prevent
future implementers from treating it as optional metadata.


No blocking concerns from the producer side. Balance pre-check and
simulation timing are consumer-injected and correctly out of scope for this
spec. The {registry}:{agentId} form in §3.1 is sufficient for on-chain
capability verification via the registry contract, no changes needed there.

Ready from our end.

1 Like

Confirmed - thank you for mapping it against a live shape. Zero delta is the strongest signal a reserved appendix can get.

Agreed on pinning the disposition’s role, and §11 now says it. The added sentence makes explicit that the producer-facing disposition is the field an automated producer loop branches on, and that a terminal status alone cannot carry that decision - an expired-unacted envelope and an explicitly rejected one are both terminal but call for opposite producer behavior. It stays descriptive and non-normative, so the reserved space still fixes no field names, but no implementer can read the disposition as optional metadata.

That is the last open item from the producer side. The draft is otherwise ready for editorial review.

2 Likes

The ERCs PR is now open, submitted as a single file with CI green:

Discussion stays in this thread - I’ll fold any feedback from here into the PR.

1 Like

Hello @mike-diamond, thanks for pointing out this proposal.

As I am starting to learn more about this proposal, it seems to provide some of the solutions I felt the ERC-7730 could benefit from adopting.

I assume the ERC was designed targeting mostly around AI-related workflows, and I am wondering how well does it align with ERC-7730 in its original context of hardware wallets and web-based GUI dApps.

Presumably, the dApp running in a browser submits an ERC-5792 wallet_sendCalls to the injected EIP-1193 provider.
The software wallet companion then produces the fully resolved ERC-8265 “prepared transaction envelope” payload, attests to its authenticity somehow, and submits it to the actual hardware wallet device for display and signing.
Is this the right flow description?

In this case we would need the hardware wallet devices to adopt ERC-8265 as the container for ERC-7730 payload, it seems.

I am also wondering, if parts of ERC-8265 can be expressed as an ERC-5792 extension “capability” ERC to avoid creating yet another RPC API standard in case a dApp is willing to produce the “prepared envelope” on its own.

Or, maybe I have missed something in my understanding of ERC-8265?

Thank you!

1 Like

@alex-forshtat-tbk - glad you pulled this across to this thread.

Scope. The envelope is producer-agnostic by design. The spec’s Abstract lists “dApps, CLIs, hardware-wallet companions, multisig coordinators, automated payment terminals, and AI agents” - AI is one producer kind among many, not the primary target. The hardware-wallet and browser-dApp flows you describe are first-class.

Flow. The envelope sits at the producer → consumer (wallet) seam, not at the wallet → hardware seam. In your sketch: the dApp produces the envelope; the wallet (software side) consumes it; what the wallet then ships to its hardware device is the wallet’s internal protocol. Your “envelope as hardware payload” direction is a valid extension - the envelope shape already carries decoded calldata, risk slot, clearSigning, capabilities, and producer.signature for integrity, which is exactly the surface a hardware display needs. Adopting it as the hardware container is a separate spec discussion (wallet/device vendors would coordinate), but the envelope is sized for it.

ERC-5792. The envelope is a data shape, not an RPC API. §8.4 (“Projection to wallet_sendCalls”) spells out the field-level translation: envelope is the producer-to-consumer shape; ERC-5792 request is the consumer-to-wallet shape. evm-batch carries calls[] + atomicRequired + status codes 1:1 with wallet_sendCalls; capabilities mirrors the ERC-5792 capability extensibility model. A dApp that wants to produce the envelope itself can project it into wallet_sendCalls body directly.

ERC-7730 container. Already in §5.7: each evm-tx content slot carries either an ERC-7730 decoderRef URI or an inline clearSigning block conforming to ERC-7730 schema. The inline variant specifically targets offline / hardware contexts that cannot resolve registry URIs. The envelope is one path for an ERC-7730 descriptor to reach a wallet at sign time, including the hardware-display path you raise.

Happy to go deeper on any of these.

1 Like

I see, thank you! Doesn’t this architecture require a creation of a new RPC API method for the injected EIP-1193 provider? Something like wallet_sendPreparedTxEnvelope?

1 Like

@alex-forshtat-tbk - no, v0.1 doesn’t require a new RPC method, and the Rationale actually addresses this directly. A per-kind-RPC model was considered and rejected because “it pushes the discriminator into the transport layer and forces every transport (HTTP, MCP, postMessage, WalletConnect session) to enumerate every kind.” The envelope’s kind discriminator instead allows a single transport surface to carry every payload type; new transports adopt the envelope without coordinating with every consumer.

The envelope is transport-agnostic by design. §Backwards Compatibility states it “does not modify any on-chain encoding, transaction-pool rule, or wallet RPC surface defined elsewhere.” Where envelope and EIP-1193 meet, the §8.4 projection happens before the request crosses EIP-1193 - what crosses is a standard ERC-5792 request, not the envelope itself.

Two practical configurations:

(A) dApp as producer. dApp prepares the envelope internally, projects to wallet_sendCalls for RPC dispatch. Wallet sees ordinary 5792.

(B) Wallet companion as producer (your sketch in #9). Companion synthesizes the envelope from incoming wallet_sendCalls raw input. Envelope crosses the wallet’s internal boundary (software → hardware), not the EIP-1193 boundary.

A wallet vendor that wants to expose envelope-aware RPC natively (richer preview, policy, signing hints) could add such a method as an extension on top of v0.1, but the spec’s deliberate direction is the other way: keep the transport layer thin, let the kind discriminator do the routing.

Net: existing wallet RPC suffices via §8.4 projection; per-kind RPC methods would push complexity into transports without offsetting benefit.

1 Like

@TMerlini - one composition seam I want to nail down before this rests, and you run a production ERC-8004 registry, so you’re the person who’d actually know.

§3.1 lists “an ERC-8004 Identity Registry record reference” as one valid form of producer.id, but the spec deliberately leaves resolution out of scope. That’s a deliberate normative boundary, not a defect - but it leaves an implementation seam for any consumer that wants to resolve an ERC-8004 producer.id back to a registered agent.

Concretely, when an envelope arrives with producer.id pointing at one of your registry’s agents: what does a consumer need to deterministically go from the agentId to a verification key it can check producer.signature against? Is that a single registry read (agentId → registration record → key), or does it require resolving the ENS binding first? I want to make sure §3.1’s wording doesn’t imply a resolution path your production registry doesn’t actually expose.

1 Like

Good catch Mike. Section 3.1 lists four valid forms for producer.id:

  1. W3C DID — e.g. "did:web:agent.example.com"

  2. CAIP-10 account — e.g. "eip155:1:0x1234..."

  3. ERC-8004 Identity Registry record reference

  4. Absolute URL

Forms 1, 2 and 4 are self-describing, a consumer can derive or fetch the verification key without a separate resolution algorithm. Form 3 (ERC-8004) is the one that needs clarification, which is what you’re pointing at.


How resolution works in the production ERC-8004 registry

In our production implementation, agentId is the signer address left-padded to bytes32:

agentId = bytes32(uint256(uint160(signerAddress)))


So for a consumer verifying producer.signature, the path is deterministic and requires at most one registry lookup:

Step 1 — Derive the signer address from producer.id

// producer.id is the agentId (bytes32)
const signerAddress = "0x" + producerId.slice(-40)  // last 20 bytes = address


Production example (our Railway node):

{
  "producer": {
    "id": "0x0000000000000000000000002048eadf6b99549dab0c536bf52b3a7b9e540f76",
    "name": "ccip-router / Railway"
  }
}


signerAddress = 0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76


Step 2 — Verify producer.signature directly against that address

Standard EIP-191 / EIP-712 signature recovery, no network call needed.

Step 3 (optional) Confirm the agent is registered

// NodeRegistry on mainnet: 0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D
(string memory url, uint256 registeredAt) = registry.getNode(signerAddress);


This confirms the agent is a known/trusted registrant but is not required for signature validity, the key is already in the agentId itself.

Production example (our NAS node):

{
  "producer": {
    "id": "0x00000000000000000000000058766f90ede2419feafd97c28bb0f0ddf951dc54",
    "name": "ccip-router / NAS"
  }
}


signerAddress  = 0x58766f90eDe2419fEaFd97c28bb0f0dDf951Dc54
registry       = 0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D  (chainId: 1)
registered URL = https://gateway.gen-plasma.com



The spec gap

Because section 3.1 leaves resolution out of scope for the ERC-8004 form, a consumer seeing a producer.id in this format has no normative path to a verification key without prior knowledge of the registry address and chain. Two options to close this:

Option A - Require a companion field when using the ERC-8004 form:

{
  "producer": {
    "id": "0x0000000000000000000000002048eadf6b99549dab0c536bf52b3a7b9e540f76",
    "registry": "eip155:1:0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D"
  }
}


Option B - Encourage using the CAIP-10 form instead for cases where the agentId encodes the signer address, since CAIP-10 is already self-resolving:

{
  "producer": {
    "id": "eip155:1:0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76"
  }
}


Option B requires no spec change, consumers can resolve CAIP-10 natively, and the ERC-8004 registry lookup becomes an optional trust signal rather than a required resolution step.

This is already live, the Railway and NAS endpoints above are running on mainnet now. The reference implementation is open source at Echo-Merlini/ccip-router and genuinely happy to contribute to whatever validation tooling or test suite makes sense here, just say the word.

1 Like

Thanks Tiago, this is exactly the kind of concrete answer that helps, and the live mainnet endpoints make it real.

One precision on my own earlier wording: ERC-8004 binds an agentId to a registered address, not an on-chain signing key (the agent is an ERC-721 token; the registry holds the owner/registered address), so the consumer path is agentId → address, checked like any address-form id. Your agentId = bytes32(padded signer) makes that read free; a bare uint256 agentId from another registry doesn’t, though a consumer that trusts that registry can still read the registered address for it.

For the envelope itself, the signature verifies against producer.publicKey (§3.2 keeps it SHOULD-present for any non-DID id), so resolving the id isn’t on the verification path. A consumer that independently trusts a registry can then confirm the signing address matches the agent’s registered one, as an identity check layered on top. What the envelope avoids is treating a producer-supplied registry pointer as authoritative, since a producer choosing both the registry and the id is self-certifying. So I read this as a clarification (publicKey carries verification; the ERC-8004 binding is consumer-side identity), with resolution mechanics staying in ERC-8004 rather than a new envelope field.

Does that match how a ccip-router consumer actually treats one of your envelopes? And the tooling offer is very welcome, once the wording settles a couple of Form-3 test vectors from your nodes would be great to anchor a conformance check.

1 Like

Yes, §3.2 resolves exactly the gap I was pointing at, thank you for surfacing it.

The interpretation matches our implementation. In Ethereum context, producer.publicKey is the signer address (20 bytes / CAIP-10 eip155:1:0x...), which is all ecrecover needs to verify producer.signature. The raw EC public key is never exposed directly in our stack, the address is the verification primitive.

So for Form-3, a ccip-router envelope would look like this:

{
  "version": "erc-8265/1",
  "kind": "evm-tx",
  "producer": {
    "id": "0x0000000000000000000000002048eadf6b99549dab0c536bf52b3a7b9e540f76",
    "publicKey": "eip155:1:0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76",
    "name": "ccip-router / Railway"
  },
  "payload": { ... },
  "producer.signature": "0x<EIP-712 sig over envelope hash>"
}


With publicKey present, verification is fully self-contained, the consumer never needs to touch the registry. The registry lookup (nodeRegistry.getNode(signerAddress)) becomes a trust/authorization signal (“is this a known registered agent?”) not a resolution step, which is the right separation.

One small clarification worth nailing down in the spec: for Ethereum-based producers, is producer.publicKey expected to be:

  • a) the 20-byte signer address as a CAIP-10 string (eip155:1:0x...), or

  • b) the 64-byte uncompressed EC public key (0x04...)?

If the spec intends (b), our stack would need an extra derivation step, we sign and recover against addresses, not raw keys. If (a), we’re already aligned.


On the test vectors: once the wording settles on the publicKey format for Form-3, we’ll produce a full set: signed envelope, agentId, publicKey, and a working ecrecover call against the live Railway signer. Happy to contribute, once the spec is stable.

1 Like

(a) - you’re already aligned. For secp256k1 the consumer verifies by address recovery (ecrecover over the signed bytes), so what producer.publicKey carries is the signer address as a CAIP-10 account (eip155:1:0x...), the recovery address rather than a raw 64-byte key. That assumes the recoverable signature form (r, s, v), which EIP-712 sigs are, so your stack is already there. For ed25519 / p256, where there’s no address, publicKey is the raw public key.

I’ll pin the precise per-scheme wording in the §3.2 pass during the editor review on #1753 rather than nail the exact text here, but the intent is what you described: address for the EVM case, key for the others, registry stays a trust signal.

Test vectors would be great whenever you’re ready - a signed envelope plus agentId, publicKey, and the ecrecover against the live Railway signer is exactly the anchor I’d want.

1 Like

Here’s the Form-3 test vector from the live Railway node. Since §3.2 isn’t locked yet I’ve proposed a minimal EIP-712 type structure below, happy to re-sign once the editor pass on #1753 pins the exact type definitions.


Signer (Form-3 — ERC-8004 registry reference)

field value
producer.id (agentId) 0x0000000000000000000000002048eadf6b99549dab0c536bf52b3a7b9e540f76
producer.publicKey eip155:1:0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76
producer.name ccip-router / Railway
NodeRegistry (mainnet) 0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D

Address derivation from agentId (bytes32 → address):

signerAddress = "0x" + agentId.slice(-40)
             = 0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76  ✓



EIP-712 type structure (proposed — pending §3.2)

Domain:

{
  "name": "ERC-8265 Prepared Envelope",
  "version": "1"
}


Types:

{
  "PreparedEnvelope": [
    { "name": "version",          "type": "string"  },
    { "name": "kind",             "type": "string"  },
    { "name": "producerId",       "type": "bytes32" },
    { "name": "producerPublicKey","type": "string"  },
    { "name": "payloadHash",      "type": "bytes32" }
  ]
}



Payload

A minimal no-op EVM tx (call to NodeRegistry, mainnet):

{
  "chainId": 1,
  "to": "0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D",
  "value": "0x0",
  "data": "0x",
  "gas": "0x5208",
  "maxFeePerGas": "0x4A817C800",
  "maxPriorityFeePerGas": "0x3B9ACA00",
  "nonce": "0x0"
}


payloadHash = keccak256(utf8(JSON.stringify(payload))):

0xd930cc24b529a904d70198f2ad55f1d288e1861750127e2e0a9a28d3127677ef



EIP-712 message

{
  "version": "erc-8265/1",
  "kind": "evm-tx",
  "producerId": "0x0000000000000000000000002048eadf6b99549dab0c536bf52b3a7b9e540f76",
  "producerPublicKey": "eip155:1:0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76",
  "payloadHash": "0xd930cc24b529a904d70198f2ad55f1d288e1861750127e2e0a9a28d3127677ef"
}


hashTypedData (envelope hash):

0x644505922b88c555d3fe5ae3bac29e3f2b1ca10f5427714a73963b2c8c97c62e



Signature

0x197d36d2408f8bbfe54c08d3f864376ddc5daf4a2aa77349538a72d95204f026
  526129d0f2bbbc6f3e0515844195fa9d1e295478a7c5a26e20806abeb4fd338c
  1b


component value
r 0x197d36d2408f8bbfe54c08d3f864376ddc5daf4a2aa77349538a72d95204f026
s 0x526129d0f2bbbc6f3e0515844195fa9d1e295478a7c5a26e20806abeb4fd338c
v 27 (0x1b)

ecrecover trace

// viem
import { recoverTypedDataAddress } from 'viem'

const recovered = await recoverTypedDataAddress({
  domain: { name: "ERC-8265 Prepared Envelope", version: "1" },
  types: {
    PreparedEnvelope: [
      { name: "version",           type: "string"  },
      { name: "kind",              type: "string"  },
      { name: "producerId",        type: "bytes32" },
      { name: "producerPublicKey", type: "string"  },
      { name: "payloadHash",       type: "bytes32" },
    ]
  },
  primaryType: "PreparedEnvelope",
  message: {
    version: "erc-8265/1",
    kind: "evm-tx",
    producerId: "0x0000000000000000000000002048eadf6b99549dab0c536bf52b3a7b9e540f76",
    producerPublicKey: "eip155:1:0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76",
    payloadHash: "0xd930cc24b529a904d70198f2ad55f1d288e1861750127e2e0a9a28d3127677ef",
  },
  signature: "0x197d36d2408f8bbfe54c08d3f864376ddc5daf4a2aa77349538a72d95204f026526129d0f2bbbc6f3e0515844195fa9d1e295478a7c5a26e20806abeb4fd338c1b",
})

// recovered === "0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76"  ✓



Full signed envelope

{
  "version": "erc-8265/1",
  "kind": "evm-tx",
  "producer": {
    "id": "0x0000000000000000000000002048eadf6b99549dab0c536bf52b3a7b9e540f76",
    "publicKey": "eip155:1:0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76",
    "name": "ccip-router / Railway"
  },
  "payload": {
    "chainId": 1,
    "to": "0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D",
    "value": "0x0",
    "data": "0x",
    "gas": "0x5208",
    "maxFeePerGas": "0x4A817C800",
    "maxPriorityFeePerGas": "0x3B9ACA00",
    "nonce": "0x0"
  },
  "producer.signature": "0x197d36d2408f8bbfe54c08d3f864376ddc5daf4a2aa77349538a72d95204f026526129d0f2bbbc6f3e0515844195fa9d1e295478a7c5a26e20806abeb4fd338c1b"
}



Note on EIP-712 types: The PreparedEnvelope type definition above is proposed based on the current draft — pending the §3.2 editor pass on #1753. Once the type hash is pinned in the spec, happy to produce a canonical re-signed vector that matches exactly.

Signed by the live Railway node (ccip-router / Railway). Registry lookup for trust confirmation:

// NodeRegistry on mainnet: 0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D
(string memory url, uint256 registeredAt) = registry.getNode(
  0x2048eADf6b99549DAB0C536Bf52B3A7B9E540F76
);
// url = "https://ccip-router-production.up.railway.app"
1 Like

Perfect, thank you - exactly the anchor I wanted. I’ll take you up on the canonical re-sign once the §3.2 types are pinned at the editor pass, and fold that into the test vectors.

1 Like