feat(wallet): eip712 variant - #325
Conversation
Implement the NEP-641 `w_resolve_auth(purpose, recipient, authorization)`
view method on all wallet contract variants:
- New `AuthMessage` envelope (domain `NEAR_WALLET_CONTRACT_AUTH/V1`,
SHA3-256, no nonce — replay protection is layered via dApp payload
freshness + recipient/purpose/chain_id/signer bindings + validity window)
- `AuthSignerBinding`: conventional `SignerId` binding, plus a `Code`
binding to the account's NEP-616 `StateInit`. The envelope commits only
to the *initial* state config: the contract reconstructs
`StateInit { code: env::current_global_contract_id(), data: State {
..config, public_key } }` — taking the code identity from the code it
currently runs under and the public key from its own storage — and
verifies the derived deterministic account id equals
`env::current_account_id()`. A match proves the envelope was intended
for this exact account. The binding is constructible client-side before
a WebAuthn ceremony reveals which passkey (or even which curve / wallet
variant) answers, and survives post-creation config mutations — single
ceremony on re-login, always
- `SignatureSchema::verify_hash()` refactor: schemas verify a 32-byte
domain-separated digest; `verify(RequestMessage)` is now a provided
method (existing mainnet test vectors unchanged)
- near-sdk 5.28.3 -> 5.29.0: required for the StateInit reconstruction —
5.29.0 fixes `current_contract_code()` returning the current account's
own id (instead of the global contract's account id) for
GlobalByAccount deployments
- SDK: `WalletSigner::sign_auth_msg()` (incl. `WalletEd25519Signer` and
`MockWalletWebauthnSigner`), `Wallet::sign_auth()` +
`auth_message()`/`auth_message_code_binding()` builders,
`w_resolve_auth` client bindings
- Tests: unit vectors (both bindings, both WebAuthn curves via the mock
webauthn signer), sandbox suite (initial-state binding survives
mutations, mutated-config envelopes rejected, subwallet isolation,
CodeHash and AccountId code identities, same-key sibling accounts under
different code accept the same blob by design, signature-disabled,
no-sign), and shared JSON fixtures (tests/fixtures/nep641-auth.json)
consumed by the near-connect-passkey executor to lock the wire format
cross-repo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ETHQdJcc4Rz4VTzhMaC3C
…ontract_code fix
near-sdk 5.29.0's generated wasm imports three gas-key host functions
that are not yet live on mainnet:
- promise_batch_action_add_gas_key_with_full_access
- promise_batch_action_add_gas_key_with_function_call
- promise_batch_action_transfer_to_gas_key
A 5.29.0-built wallet contract therefore fails to link at call time:
`LinkError { msg: "unknown or invalid import" }` (reproduced on
near-sandbox 2.12 / protocol 84; the 5.28.x-built wasm links and runs).
Stay on near-sdk 5.28.x until that protocol change ships, and vendor the
`current_contract_code` fix that 5.29.0 would otherwise provide:
- `contracts/wallet/src/auth.rs`: `current_global_contract_id()` — a local
copy of near/near-sdk-rs#1601. near-sdk 5.28.x reads the current account
id (not the host-filled register) for the `GlobalByAccount` case, so it
returns the wallet's own account id instead of the global contract's.
The vendored version imports only `current_contract_code` (live on
mainnet) via `near-sys` and reads the register directly.
- `contracts/wallet/src/contract.rs`: `w_resolve_auth` calls the vendored
function instead of `env::current_global_contract_id()`.
- lockfile pinned to origin's known-good near-* set (near-global-contracts
0.2.2, near-sys 0.2.12, near-sdk-env 0.1.4); newer versions pull a second
near-primitives-core (0.37) that breaks near-sdk 5.28.x's unit-testing mock.
REVERT this commit — drop the vendored function, switch back to
`env::current_global_contract_id()`, and move to near-sdk 5.29.x — once the
gas-key host functions are live on mainnet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ETHQdJcc4Rz4VTzhMaC3C
Both deployed WebAuthn wallet variants (p256, ed25519) used IgnoreUserVerification, so w_execute_signed / w_resolve_auth accepted assertions with only the UP (user-present) flag — a bare touch, no biometric/PIN. The passkey is the sole key over funds; the client requests userVerification:"required" and re-checks the UV flag, but a proof submitted directly to the relayer bypasses that. The contract must not accept a user-presence-only assertion. Switch both variants to RequireUserVerification: check_flags now requires the UV bit. Trade-off: PIN-less FIDO U2F/CTAP1 keys can no longer sign; platform passkeys (Apple/Google/Windows) always perform UV. Account ids are unchanged (global-by-account code identity is stable across wasm upgrades), so this needs no migration — redeploying the factory wasm applies it to all existing instances. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMqbwtkoA9PwXo9iVsEDbG
The `Code` binding committed only to config, so a single signed AuthMessage was accepted by any account keyed by the passkey under any code identity — the sibling-account ambiguity. It could not commit the code id because the executor builds the envelope before the discovery ceremony reveals the curve (and thus which per-curve factory answers). Fix: commit BOTH canonical factory ids (curve-independent constants known before the ceremony) as `allowed_factory_ids`, and have each per-curve `resolve_auth` require that its own running code is in the set. Because a signature only verifies under its own curve's code, the accepting set collapses to exactly one account per curve — the code-id binding restored without unifying the contracts or a second ceremony. `allowed_factory_ids` MUST list at most one factory per curve, else the same signed message resolves against two accounts (cross-account replay); documented on the field and pinned by test_resolve_auth_factory_allow_list. Code binding now requires a by-account (canonical factory) deployment; code-hash deployments must use the SignerId binding. Wire format change: new field in AuthSignerBinding::Code; hash vectors and the shared nep641-auth.json fixture recomputed (Rust and the executor encoder agree). SDK `auth_message_code_binding` takes the allow-list. Verified: wallet lib + fixture tests pass, contract wasm builds, SDK and integration tests compile. The wallet auth integration tests need Docker (near-kit sandbox), unavailable here, so were not executed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMqbwtkoA9PwXo9iVsEDbG
Make AuthSignerBinding deserialization strict. This lets a future wallet-contract variant of the SAME signature curve be added to a Code binding's allowed_factory_ids without re-introducing cross-account replay, provided its Code binding carries a different (added, required) field: a message shaped for the new variant fails to parse on the old contract (unknown field), and a message for the old variant fails on the new one (missing required field), so no single signed message resolves under both. Without deny_unknown_fields serde silently ignores the extra field and the old contract would accept the new variant's message — the replay the per-curve invariant guards against. Verified serde actually enforces it here (it has historically ignored deny_unknown_fields on internally-tagged enums): binding_denies_unknown_field rejects an extra field on both variants. Serialization/borsh/hashes are unchanged, so the wire format and the executor are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMqbwtkoA9PwXo9iVsEDbG
…urrent_contract_code fix" This reverts commit f199b81.
…_SIGNATURE test_resolve_auth_no_sign expected INVALID_SIGNATURE, but the no-sign wallet sets signature_enabled=false and holds self as its only extension, so is_signature_allowed() (signature_enabled || extensions.is_empty()) is false. w_resolve_auth short-circuits at SignatureDisabled → INVALID_INPUT before ever reaching signature verification — the same result as a SetSignatureMode(false) wallet (test_resolve_auth_signature_disabled). That short-circuit is required for safety: a signature-disabled wallet must be rejected even if the signature would verify. So the contract is correct and the test expectation was stale. Align it to INVALID_INPUT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMqbwtkoA9PwXo9iVsEDbG
origin/main refactored the wallet SDK (renamed WalletSigner::sign_request_msg -> sign_wallet_msg, moved the relayer into the SDK crate, changed the Wallet struct, added MPC support, switched to env::chain_id()/p256_verify()) and removed the wallet integration tests (tests/src/tests/wallet/*). Conflict resolution: - Contract-side NEP-641 (auth.rs, contract.rs) auto-merged and is preserved in full: w_resolve_auth, AuthSignerBinding with allowed_factory_ids + deny_unknown_fields, the resolve_auth factory allow-list check, and the p256/ ed25519 RequireUserVerification variants. Fixed one auto-merge artifact: resolve_auth now uses env::chain_id() (utils::chain_id() was removed on main). - Took main's refactored SDK (signer.rs, lib.rs, Cargo.toml) and dropped the SDK-side NEP-641 *convenience* helpers that collided with the refactor (sign_auth_msg on the signers, auth_message/auth_message_code_binding/sign_auth on Wallet, WResolveAuthArgs::from_signed). These were only consumed by the now-deleted wallet integration tests; the deployed contract feature is unaffected. They should be re-added on the new signer/MPC/relayer API as a follow-up. - Accepted main's deletion of tests/src/tests/wallet/*. Unrelated conflicts (root Cargo.toml versions, defuse core public_key.rs, Cargo.lock) took main. Verified: full `cargo check --workspace` passes; defuse-wallet lib + NEP-641 fixture tests pass; clippy -D warnings clean on the wallet crates; p256 wasm builds. Wallet integration tests need Docker (not run here).
📝 WalkthroughWalkthroughThe wallet gains NEP-641 authorization resolution and digest-based signature verification. New EIP-712 hashing, recovery, wallet contract, signer, and fixture crates are added. Existing WebAuthn schemas require user verification, and the wallet SDK exposes the authorization-resolution view call. ChangesWallet authorization and signature support
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
contracts/wallet/src/contract.rs (1)
292-298: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated validity-window formula.
This re-implements the same
now - effective_timeout <= created_at <= nowwindow check asNonces::commit()(per the comment, "sans bitmap"). Consider factoring the window check itself into a shared helper (e.g. onNoncesorTimestamp) that bothexecute_signed's nonce commit andresolve_authcall, so the two authorization paths can't silently diverge if the window semantics change later.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@contracts/wallet/src/contract.rs` around lines 292 - 298, Factor the validity-window condition from resolve_auth into a shared helper on the appropriate nonce or timestamp type, then reuse that helper from both resolve_auth and Nonces::commit(). Preserve the existing effective-timeout calculation and ExpiredOrFuture behavior while ensuring both authorization paths use identical window semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@contracts/wallet/src/contract.rs`:
- Around line 292-298: Factor the validity-window condition from resolve_auth
into a shared helper on the appropriate nonce or timestamp type, then reuse that
helper from both resolve_auth and Nonces::commit(). Preserve the existing
effective-timeout calculation and ExpiredOrFuture behavior while ensuring both
authorization paths use identical window semantics.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 32782069-86f3-47d7-b5d7-6bf36bc462a9
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (24)
Cargo.tomlMakefilecontracts/wallet/Cargo.tomlcontracts/wallet/README.mdcontracts/wallet/signatures/ed25519/src/lib.rscontracts/wallet/signatures/eip712/Cargo.tomlcontracts/wallet/signatures/eip712/src/contract.rscontracts/wallet/signatures/eip712/src/lib.rscontracts/wallet/signatures/eip712/src/signer.rscontracts/wallet/signatures/eip712/tests/fixtures.rscontracts/wallet/signatures/eip712/tests/fixtures/eip712-wallet-message.jsoncontracts/wallet/signatures/no-sign/src/lib.rscontracts/wallet/signatures/webauthn/ed25519/src/lib.rscontracts/wallet/signatures/webauthn/p256/src/lib.rscontracts/wallet/signatures/webauthn/src/lib.rscontracts/wallet/src/auth.rscontracts/wallet/src/contract.rscontracts/wallet/src/lib.rscontracts/wallet/src/schema.rscontracts/wallet/tests/fixtures/nep641-auth.jsoncontracts/wallet/tests/nep641_fixtures.rscrates/signatures/eip712/Cargo.tomlcrates/signatures/eip712/src/lib.rscrates/wallet/sdk/src/client.rs
d822edc to
32ab972
Compare
Add an EIP-712 (`eth_signTypedData_v4`) wallet-contract variant, so that any Ethereum wallet (MetaMask, WalletConnect, Ledger) can be used as the sole key of a NEAR wallet-contract account, with **clear signing**: the wallet displays the structure being authorized instead of an opaque digest. Re-implementation of near#256 on top of the current architecture (per-variant crates + the `verify_hash()` signature schema refactor), extended to cover NEP-641 authorizations as well. New crates: * `defuse-eip712` (`crates/signatures/eip712`): EIP-712 hashing primitives (`typeHash`, `hashStruct`, `encodeData` for dynamic/uint members, the `0x19 0x01` prehash and secp256k1 recovery), following the existing `defuse-erc191`/`defuse-tip191` crates. * `defuse-wallet-eip712` (`contracts/wallet/signatures/eip712`): the `WalletEip712` signature schema, the contract variant (`wallet-eip712` standard) and a `WalletEip712Signer` for the SDK. Typed data (both under domain `EIP712Domain(string name,string version)` with name = "NEAR Wallet Contract", version = "1", separated by primary type): WalletRequest(string chainId,string signerId,uint32 nonce,string createdAt, uint32 timeoutSecs,string internal,string external) WalletAuth(string chainId,string signer,string purpose,string recipient, string payload,string createdAt,uint32 timeoutSecs) The contract does not trust the signed display: it checks every member of the typed data against the message it is about to act upon, so a proof whose typed data says anything other than the message is rejected. Members carrying nested structures (`internal`, `external`, `signer`) are compared semantically, so clients may format that JSON as they like. `proof` is a JSON-serialized `SignedEip712`: the typed data `message` plus a recoverable 65-byte secp256k1 `signature` as `secp256k1:<base58>`. The contract stores the signer's `0x` **Ethereum address** (`EthAddress`, keccak256(public_key)[12..32]) rather than the public key, and derives it from the public key it recovers from each proof. Ethereum wallets expose the address without a signing ceremony (the public key can only be recovered from a signature), so a client knows the wallet's NEP-616 deterministic `AccountId` — which commits to the initial state — as soon as the user connects, saving a roundtrip. It also frees 44 bytes of the ZBA budget. To let a schema bind the proof to the contents of an authorization message (and not only to its digest), `SignatureSchema` gains a `verify_auth()` provided method (defaults to `verify_hash(msg.hash())`, so all existing variants are unaffected), which `w_resolve_auth()` now calls. Tests: domain separator / type hash / uint encoding vectors, address derivation vectors (cross-checked against an independent secp256k1 + keccak256 implementation), account id derivable from the address alone, sign-verify round-trips for both entrypoints, per-field tamper cases (a proof must not verify against any other message), cross-type replay, wrong address and malformed proofs, plus shared JSON fixtures (`tests/fixtures/eip712-wallet-message.json`) that lock the typed data and proof wire format for cross-implementation clients. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKxr8pX5kL97LHNZjHvPXR
32ab972 to
61c59f4
Compare
`RequestMessage` gained a `pay_for_gas` flag (near#320), which the wallet uses to decide whether it pays for the whole transaction out of its own balance. Hash-based schemas bind it for free via the canonical digest, but a clear-signing schema compares fields explicitly, so the flag has to be part of the typed data — otherwise a proof signed with `payForGas: false` would verify against a request that flips it on, making the wallet pay for gas it never authorized (and, the other way around, the signer would not see the flag at all). `WalletRequest` becomes: WalletRequest(bool payForGas,string chainId,string signerId,uint32 nonce, string createdAt,uint32 timeoutSecs,string internal, string external) `defuse-eip712` gains `Eip712::encode_bool()` (`uint256` 0/1, per EIP-712) for that member. Fixtures are regenerated (incl. a new `pay_for_gas` vector) and a tamper case for the flag is added.
Re-implementation of #256 on top of #319, adapted to the current wallet-contract architecture (per-variant crates + the
SignatureSchemarefactor) and extended to cover NEP-641 authorizations.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
Both types live under the same domain and are separated by their primary type:
internal/externalare the JSON-serialized wallet operations and promises of the request;signeris the JSON-serialized NEP-641 signer binding.{ "primaryType": "WalletRequest", "domain": { "name": "NEAR Wallet Contract", "version": "1" }, "message": { "payForGas": false, "chainId": "mainnet", "signerId": "0se5eba21e8f191e1880e453794bc551dfa50a3419", "nonce": 42, "createdAt": "2026-07-16T12:34:56.789Z", "timeoutSecs": 300, "internal": "[{\"op\":\"add_extension\",\"payload\":{\"account_id\":\"extension.near\"}}]", "external": "[]" } }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. Members carrying nested structures (internal,external,signer) are compared semantically — parsed, then compared as values — so clients are free to pretty-print that JSON for display without breaking verification.Tests cover this per field: tampering with any one of
payForGas,chainId,signerId,nonce,createdAt,timeoutSecs,internal,external(and the auth analogues) makes the proof fail.payForGas(#320) 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 withpayForGas: falsewould verify against a request that flips it on, and the wallet would pay for gas the signer never authorized.verify_auth()onSignatureSchema#319 hands schemas only the 32-byte digest (
verify_hash), which is enough for blind-signing schemas but leaves nothing to display forw_resolve_auth(). This PR adds averify_auth(public_key, msg: &AuthMessage, proof)provided method that defaults toverify_hash(&msg.hash(), proof)— every existing variant is unaffected — andw_resolve_auth()now calls it.WalletEip712overrides bothverify()andverify_auth(); itsverify_hash()always returnsfalse, since an opaque digest carries no message to show the signer.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 public key in state.Changes
defuse-eip712(crates/signatures/eip712): EIP-712 hashing primitives (type_hash,hash_struct,encode_bytes,encode_uint,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, andWalletEip712Signer(incl.sign_auth_msg()) for the SDK.defuse-wallet:SignatureSchema::verify_auth()+w_resolve_auth()wiring.MakefileCONTRACT_CRATES, wallet README.Tests
defuse-eip712: domain-separator, type-hash and uint-encoding known-answer vectors, 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, 14 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, andverify_hash()failing closed.tests/fixtures/eip712-wallet-message.json(3 request + 2 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.Checked:
cargo clippy --workspace --all-targets --all-features,cargo fmt --all --check,taplo format --check,make check-contracts/defuse-wallet-eip712/all,cargo test -p defuse-eip712 -p defuse-wallet -p defuse-wallet-eip712. No sandbox test added: the sharedw_execute_signed/w_resolve_authpaths are already covered by #319's suite, and this variant only swaps signature verification.🤖 Generated with Claude Code
https://claude.ai/code/session_01PKxr8pX5kL97LHNZjHvPXR