feat(wallet): eip712 variant - #346
Draft
frol-ai wants to merge 2 commits into
Draft
Conversation
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
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-641OffchainMessage/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:
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.payForGassits at the end of the member list and MAY be omitted from the JSON representation (defaults tofalse); EIP-712 itself has no optional members, so it always remains part of the signed type.WalletAuthmirrors the NEP-641OffchainMessagefield-for-field — including the bottom-up resolutionpathas a native EIP-712string[], 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. Leafpayloads 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.receiverIdandrefundToalone),payForGas(and the auth analogueschainId,signerId,path— replaced/extended/cleared,timestamp,payload) makes the proof fail.payForGasmatters 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 withpayForGas: falsewould 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 allOffchainMessagefields (chain_id,signer_id,path,timestamp,payload) through the typed data instead — same replay protections (cross-network, cross-account, cross-context), different encoding.Identity:
0xaddress in stateThe contract stores the signer's Ethereum address (
EthAddress=keccak256(public_key)[12..32], rendered as0x<hex>byw_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 deterministicAccountIdcommits 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
proofis a JSON-serializedSignedEip712: the typed datamessageplus a recoverable 65-byte secp256k1signature(r ‖ s ‖ v,v∈ {0,1}) assecp256k1:<base58>— the same signature encoding used elsewhere in this repo. Ethereum wallets returnvas 27/28, so clients normalize by subtracting 27. Verification is a singleecrecoverhost call (malleable signatures rejected), compared against the address in state.Changes
defuse-eip712(crates/signatures/eip712): EIP-712 hashing primitives (type_hash,hash_struct,encode_bytes,encode_uint,encode_bool,encode_arrayfor array members likestring[],0x19 0x01prehash, secp256k1recover), followingdefuse-erc191/defuse-tip191. Domain isname+versiononly (NEAR has no EVM chain id and account ids don't fitaddress; network and account are bound by the message itself).defuse-wallet-eip712(contracts/wallet/signatures/eip712):WalletEip712schema,wallet-eip712contract standard, typed-data types,EthAddress, andWalletEip712Signerimplementing the SDK'sWalletSigner(bothsign_request_msg()andsign_offchain_msg(), so it plugs into the existing NEP-641RpcResolverflow unchanged).MakefileCONTRACT_CRATES, wallet README.Tests
defuse-eip712: domain-separator, type-hash, uint/bool/array-encoding known-answer vectors (cross-checked againstethers), sign/recover round-trip.defuse-wallet-eip712: type-hash vectors for both primary types,EthAddressderivation/parsing vectors, account id derivable from the address alone, sign→verify round-trips forw_execute_signedandw_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.tests/fixtures/eip712-wallet-message.json(4 request + 3 auth vectors, each with the canonical message, theeth_signTypedData_v4message, its prehash and the on-chain proof) pinning the wire format for cross-implementation clients. All prehashes are byte-identical toethers.TypedDataEncoder.hash()and all proof signatures byte-identical toethers.Wallet.signTypedData()with the same key (v normalized 27/28 → 0/1) — including the nested struct-array encoding (WalletOp[]/NearPromise[]/NearAction[]) and thestring[] pathencoding.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 sharedw_execute_signed/w_resolve_authpaths are already covered by the existing suite, and this variant only swaps signature verification.🤖 Generated with Claude Code
https://claude.ai/code/session_01B9K9KYrZXSmX69QxHRBqzi