Skip to content

feat(wallet): eip712 variant - #346

Draft
frol-ai wants to merge 2 commits into
near:mainfrom
frol-ai:feat/wallet-eip712-nep641
Draft

feat(wallet): eip712 variant#346
frol-ai wants to merge 2 commits into
near:mainfrom
frol-ai:feat/wallet-eip712-nep641

Conversation

@frol-ai

@frol-ai frol-ai commented Aug 11, 2026

Copy link
Copy Markdown

Re-implementation of #325 on top of the NEP-641 reference implementation that landed in #335, adapted to the current wallet-contract architecture (SignatureSchema::verify_request_msg()/verify_offchain_msg(), NEP-641 OffchainMessage/JsonPayload).

Adds an EIP-712 (eth_signTypedData_v4) wallet-contract variant, so any Ethereum wallet (MetaMask, WalletConnect, Ledger) can be the sole key of a NEAR wallet-contract account — with clear signing preserved: the wallet displays the structure being authorized, never an opaque digest.

Typed data

All types live under the same domain; the two signable messages are separated by their primary type:

EIP712Domain(string name,string version)
  name = "NEAR Wallet Contract", version = "1"

WalletRequest(string chainId,string signerId,uint32 nonce,string createdAt,uint32 timeoutSecs,WalletOp[] internal,NearPromise[] external,bool payForGas)
  WalletOp(string op,string payload)
  NearPromise(string receiverId,string refundTo,NearAction[] actions)
  NearAction(string action,string payload)

WalletAuth(string chainId,string signerId,string[] path,string timestamp,string payload)

The request contents are unpacked into typed structures: each wallet operation and each promise (with its actions) is an EIP-712 struct of its own, so the wallet renders them structurally — receivers, refund targets and action kinds are first-class members — instead of one opaque JSON blob. Only the leaf payloads remain JSON strings. payForGas sits at the end of the member list and MAY be omitted from the JSON representation (defaults to false); EIP-712 itself has no optional members, so it always remains part of the signed type.

WalletAuth mirrors the NEP-641 OffchainMessage field-for-field — including the bottom-up resolution path as a native EIP-712 string[], so the wallet renders the delegation chain to the user as a list rather than an opaque blob.

{
  "primaryType": "WalletRequest",
  "domain": { "name": "NEAR Wallet Contract", "version": "1" },
  "message": {
    "chainId": "mainnet",
    "signerId": "0se5eba21e8f191e1880e453794bc551dfa50a3419",
    "nonce": 44,
    "createdAt": "2026-07-16T12:34:56.789Z",
    "timeoutSecs": 300,
    "internal": [
      { "op": "add_extension", "payload": "{\"account_id\":\"extension.near\"}" }
    ],
    "external": [
      {
        "receiverId": "bob.near",
        "refundTo": "alice.near",
        "actions": [
          { "action": "transfer", "payload": "{\"deposit\":\"1000000000000000000000000\"}" }
        ]
      }
    ],
    "payForGas": false
  }
}

How the display is enforced

The contract does not trust what the client claims to have displayed: it checks every member of the signed typed data against the message it is about to act upon (Eip712RequestMessage::matches() / Eip712AuthMessage::matches()), so a proof whose typed data says anything other than the message is rejected. Leaf payloads are compared semantically — parsed, then compared as JSON values — so clients are free to pretty-print them for display without breaking verification. The comparison is strict about the field set though: any extra field the contract would silently ignore invalidates the proof, so the wallet can never display more than what gets executed.

Tests cover this per field: tampering with any one of chainId, signerId, nonce, createdAt, timeoutSecs, internal, external (incl. receiverId and refundTo alone), payForGas (and the auth analogues chainId, signerId, path — replaced/extended/cleared, timestamp, payload) makes the proof fail.

payForGas matters especially here: hash-based schemas bind it for free via the canonical digest, but a clear-signing schema compares fields explicitly, so it has to be a member of the typed data — otherwise a proof signed with payForGas: false would verify against a request that flips it on, and the wallet would pay for gas the signer never authorized.

Since this schema signs the contents rather than the canonical NEP-641 hash, verify_offchain_msg() binds all OffchainMessage fields (chain_id, signer_id, path, timestamp, payload) through the typed data instead — same replay protections (cross-network, cross-account, cross-context), different encoding.

Identity: 0x address in state

The contract stores the signer's Ethereum address (EthAddress = keccak256(public_key)[12..32], rendered as 0x<hex> by w_public_key()) instead of the public key, and derives it from the public key it recovers from each proof — so the proof still binds the key.

Ethereum wallets expose the address without any signing ceremony (eth_requestAccounts), while the public key can only be recovered from a signature. Since the NEP-616 deterministic AccountId commits to the initial state, a client that only knows the address can already derive which NEAR account it controls — one roundtrip less than a public-key-in-state design (test: account_id_derivable_from_address_alone). It also frees 44 bytes of the ZBA budget.

Address derivation is checked against an independent secp256k1 + keccak256 implementation, so wallets end up controlled by exactly the key behind the user's existing Ethereum address.

Proof

proof is a JSON-serialized SignedEip712: the typed data message plus a recoverable 65-byte secp256k1 signature (r ‖ s ‖ v, v ∈ {0,1}) as secp256k1:<base58> — the same signature encoding used elsewhere in this repo. Ethereum wallets return v as 27/28, so clients normalize by subtracting 27. Verification is a single ecrecover host call (malleable signatures rejected), compared against the address in state.

Changes

  • New crate defuse-eip712 (crates/signatures/eip712): EIP-712 hashing primitives (type_hash, hash_struct, encode_bytes, encode_uint, encode_bool, encode_array for array members like string[], 0x19 0x01 prehash, secp256k1 recover), following defuse-erc191/defuse-tip191. Domain is name+version only (NEAR has no EVM chain id and account ids don't fit address; network and account are bound by the message itself).
  • New contract variant defuse-wallet-eip712 (contracts/wallet/signatures/eip712): WalletEip712 schema, wallet-eip712 contract standard, typed-data types, EthAddress, and WalletEip712Signer implementing the SDK's WalletSigner (both sign_request_msg() and sign_offchain_msg(), so it plugs into the existing NEP-641 RpcResolver flow unchanged).
  • Workspace members/deps, Makefile CONTRACT_CRATES, wallet README.

Tests

  • defuse-eip712: domain-separator, type-hash, uint/bool/array-encoding known-answer vectors (cross-checked against ethers), sign/recover round-trip.
  • defuse-wallet-eip712: type-hash vectors for both primary types, EthAddress derivation/parsing vectors, account id derivable from the address alone, sign→verify round-trips for w_execute_signed and w_resolve_auth, 17 per-field tamper cases, cross-type replay (a request proof must not resolve an authorization and vice versa), wrong address, malformed/bare/other-curve proofs.
  • Shared JSON fixtures tests/fixtures/eip712-wallet-message.json (4 request + 3 auth vectors, each with the canonical message, the eth_signTypedData_v4 message, its prehash and the on-chain proof) pinning the wire format for cross-implementation clients. All prehashes are byte-identical to ethers.TypedDataEncoder.hash() and all proof signatures byte-identical to ethers.Wallet.signTypedData() with the same key (v normalized 27/28 → 0/1) — including the nested struct-array encoding (WalletOp[]/NearPromise[]/NearAction[]) and the string[] path encoding.

Checked: cargo clippy --workspace --all-targets --all-features, cargo fmt --all --check, taplo format --check, cargo near build non-reproducible-wasm --locked --no-default-features --features=contract --abi-features=abi,contract, cargo test -p defuse-eip712 -p defuse-wallet-eip712 --all-features. No sandbox test added: the shared w_execute_signed/w_resolve_auth paths are already covered by the existing suite, and this variant only swaps signature verification.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B9K9KYrZXSmX69QxHRBqzi

Re-implementation of near#325 on top of the NEP-641 reference implementation
(near#335): an EIP-712 (eth_signTypedData_v4) wallet-contract variant, so any
Ethereum wallet can be the sole key of a NEAR wallet-contract account,
with clear signing preserved for both w_execute_signed() and
w_resolve_auth().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9K9KYrZXSmX69QxHRBqzi
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6502dd40-6e27-4f27-b91a-a473da391b4a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Unpack `internal`/`external` into typed EIP-712 structures (`WalletOp[]`,
`NearPromise[]`, `NearAction[]`), so Ethereum wallets render each
operation and promise structurally instead of one opaque JSON blob; only
leaf `payload`s remain JSON, compared semantically on-chain (whitespace
and key order free, extra fields rejected).

Move `payForGas` to the end of `WalletRequest` and make it optional in
the JSON representation (defaults to `false`; EIP-712 itself has no
optional members, so it always stays part of the signed type).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9K9KYrZXSmX69QxHRBqzi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant