Skip to main content

Crate cratefield_module_wallets

Crate cratefield_module_wallets 

Source
Expand description

Linking a person’s own crypto wallet to an account they have already signed in to, over a signature rather than a password (issue #835).

The module proves two things and refuses everything else:

  • that the caller controls the address in the message, by verifying an EIP-191 personal_sign signature on an EIP-4361 (SIWE) message or an ed25519 signature on a SIWS message; and
  • that the caller is already signed in, through the Auth port, because a wallet signature on its own proves nothing about who is holding the browser.

The proof is a single-use nonce the module itself minted and bound to the caller’s account, so a message signed once cannot be replayed and a signature captured on one site cannot be presented to another (the message is also bound to domain).

This module never asks a wallet to move funds. It reads an address and proves ownership of it. No approval, allowance or spending flow of any kind appears anywhere in the crate, and no transaction of any kind is ever requested — a wallet connection that could spend is a different, much larger module, and it is not this one.

§The two chains

  • EVM — the signature is a 65-byte r || s || v that recovers a secp256k1 public key, whose keccak-256 tail is the address. A smart-contract wallet cannot produce such a signature, so anything that does not recover the address in the message falls through to a ContractSignatureVerifier — the EIP-1271 isValidSignature hook — which also carries EIP-6492 counterfactual signatures.
  • Solana — the address is a base58 32-byte ed25519 public key and the signature is verified over the exact message bytes, with no EIP-191 prefix: a SIWS message is signed verbatim.

A contract-wallet verifier is optional. Without one the module serves EOAs only, which is a smaller module, not a broken one — a contract address simply fails the check.

§cratefield-module-wallets

Link a person’s own crypto wallet to a Cratefield account they have already signed in to — EVM or Solana — by proving they control the address with a signature, not a password.

The wallet is never the login. Passkeys, OIDC and the rest of the auth stack own “who is signed in”; this module only answers “and which address does this person also control”. That is why every route here requires a signed-in caller: a signature over a message proves ownership of an address, never of a session.

This module cannot move funds. It reads an address and proves ownership of it. No approval, allowance or spending flow of any kind appears anywhere in the crate, and no transaction of any kind is ever requested. Connecting a wallet that can spend is a different and much larger module; it is not this one.

§The flow

  1. The signed-in caller posts {"chain": "evm"} (or "solana") to /v1/wallets/nonce and gets back a single-use nonce plus everything the message will contain — domain, URI, statement, chain id, expiry.
  2. The wallet builds a SIWE (… wants you to sign in with your Ethereum account:) or SIWS (… Solana account:) message around that nonce and signs it: personal_sign for EVM, the exact message bytes for Solana.
  3. The caller posts the message and the signature to /v1/wallets/verify. The module checks the domain, the chain, the nonce’s ownership and expiry, the message’s own Expiration Time and Not Before, consumes the nonce, verifies the signature, and links the address to the caller’s account.

The nonce is the replay defence and it is consumed atomically, so a message that was signed once cannot be presented twice even if two requests arrive together. The domain in the message is the phishing defence: a message minted for another site is refused rather than verified.

§Composition

use cratefield_core::{RandomBytes, RandomError};
use cratefield_module_wallets::{Wallets, SOLANA_CHAIN_ID};

struct OsEntropy;
impl RandomBytes for OsEntropy {
    fn fill(&self, dest: &mut [u8]) -> Result<(), RandomError> {
        // In a venture this is the platform CSPRNG (e.g. `getrandom`).
        for byte in dest.iter_mut() {
            *byte = 0x2a;
        }
        Ok(())
    }
}

let module = Wallets::builder()
    .domain("example.com")                  // required — the phishing defence
    .statement("Link this wallet to your account.")  // the line a person reads
    .chain_id("1")                          // the EVM chain messages name
    .nonce_ttl(time::Duration::seconds(600))
    .random(OsEntropy)
    .build();

uri defaults to https://<domain>. self_check refuses a composition with no domain (every message would be refused) or no entropy source (no nonce could be minted).

§Contract wallets (EIP-1271)

An EOA signature recovers a secp256k1 public key and the address falls out of it. A smart-contract wallet has no key, so anything that does not recover the address in the message is handed to a ContractSignatureVerifier — the isValidSignature(bytes32, bytes) call EIP-1271 defines. The same path carries EIP-6492 counterfactual signatures, which is the answer to “the wallet does not exist on this chain yet”.

.contract_verifier(..) sets it. Left unset the module serves EOAs only, which is a smaller module rather than a broken one: a contract address then fails with wallets/signature-invalid instead of the server hanging on a chain call. StaticContractVerifier is a no-network adapter that approves one (address, message) pair, for tests and fakes.

§The endpoints

RouteWho calls itWhat it answers
POST /v1/wallets/noncethe signed-in callerthe nonce and the message fields
POST /v1/wallets/verifythe signed-in callerthe linked wallet, or a refusal
GET /v1/walletsthe signed-in callerthat caller’s linked wallets, and only theirs
DELETE /v1/wallets/{id}the signed-in callerunlinks one of that caller’s wallets

All four require a signed-in caller and answer 401 without one. Refusals are RFC 9457 problems with stable slugs: wallets/domain-mismatch, wallets/nonce-invalid, wallets/message-expired, wallets/signature-invalid, wallets/chain-mismatch, wallets/address-already-linked, wallets/not-found.

An EVM address is normalised to its EIP-55 checksummed form and a Solana address to its canonical base58 form, so the same wallet reached two ways is stored once. A wallet already linked to a different account is refused with 409; re-verifying a wallet this account already holds is idempotent.

§Storage

Two tables, wallet_nonces and wallet_links, in the venture’s own database. A nonce is a PRIMARY KEY row with consumed_at set by one guarded conditional UPDATE, which is what makes replay impossible. A link is unique on (chain, address) — one wallet, one account. No signature and no signature hash is ever stored, so a row in either table cannot be replayed as a proof of anything. The migration is crates/module-wallets/migrations/sqlite/0001_init.sql; a scheduled job purges nonces past their expiry, spent or not.

§Ports

PortHow
Databaserequired — nonces and links live here
Authrequired — every route answers only to a signed-in caller
Clockoptional — what makes nonce and message expiry deterministic
IdGenoptional — names each link; without one the handler derives it

§What is deliberately not here

The browser side of a wallet connection — EIP-6963 discovery, the WalletConnect v2 session, Coinbase Smart Wallet, the Wallet Standard registry and the picker component — is a separate piece of work. So is any flow that would let a linked wallet authorise a spend: that is a transaction-signing module and this one is not it.

Structs§

StaticContractVerifier
A no-network ContractSignatureVerifier that approves exactly one (address, message) pair and refuses everything else. It is the test adapter for the EIP-1271 path — a unit test needs a contract wallet that answers without a chain, and this is it. It is compiled into the library rather than kept behind cfg(test) so an integration test can compose one; it is deliberately not wired into any builder default.
UnknownChain
Why a chain name was not understood.
Wallets
Linking a person’s own EVM or Solana wallet to their signed-in account.
WalletsBuilder
Builds a Wallets. Everything the module cannot decide for a venture — which domain its messages are bound to, where its entropy comes from, whether it can check a contract wallet — is set here.

Enums§

AddressError
Why an address could not be normalised.
Chain
Which chain a wallet lives on.
ContractVerifierError
Why a contract signature check could not complete.
MessageError
Why a message could not be parsed.
VerifyError
Why a signature could not be verified.

Constants§

DEFAULT_EVM_CHAIN_ID
The EVM chain id the module asks a wallet to sign against when the composition names none — Ethereum mainnet, the one a message written for “the chain” almost always means.
DEFAULT_NONCE_TTL
How long a nonce stays usable by default: ten minutes. Long enough to find the wallet and read the message, short enough that a nonce captured from a log or a referrer is dead before anyone could use it.
MAX_NONCE_TTL
The longest a nonce may live: one hour. A proof of ownership that is still good after that is a proof somebody kept.
MESSAGE_VERSION
The SIWE/SIWS Version field, fixed by EIP-4361 and its Solana twin.
SOLANA_CHAIN_ID
The Solana chain id SIWS messages carry. Solana has one cluster id and names it this; the module never varies it.

Traits§

ContractSignatureVerifier
Verifies an EIP-1271 isValidSignature(bytes32, bytes) call against a smart-contract wallet. Backed by the #759 EvmChain port when it lands; until then a venture wires its own implementation (e.g. an eth_call adapter) or the test-only StaticContractVerifier.