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_signsignature on an EIP-4361 (SIWE) message or an ed25519 signature on a SIWS message; and - that the caller is already signed in, through the
Authport, 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 || vthat 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 aContractSignatureVerifier— the EIP-1271isValidSignaturehook — 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
- The signed-in caller posts
{"chain": "evm"}(or"solana") to/v1/wallets/nonceand gets back a single-use nonce plus everything the message will contain — domain, URI, statement, chain id, expiry. - 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_signfor EVM, the exact message bytes for Solana. - 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 ownExpiration TimeandNot 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
| Route | Who calls it | What it answers |
|---|---|---|
POST /v1/wallets/nonce | the signed-in caller | the nonce and the message fields |
POST /v1/wallets/verify | the signed-in caller | the linked wallet, or a refusal |
GET /v1/wallets | the signed-in caller | that caller’s linked wallets, and only theirs |
DELETE /v1/wallets/{id} | the signed-in caller | unlinks 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
| Port | How |
|---|---|
Database | required — nonces and links live here |
Auth | required — every route answers only to a signed-in caller |
Clock | optional — what makes nonce and message expiry deterministic |
IdGen | optional — 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§
- Static
Contract Verifier - A no-network
ContractSignatureVerifierthat 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 behindcfg(test)so an integration test can compose one; it is deliberately not wired into any builder default. - Unknown
Chain - Why a chain name was not understood.
- Wallets
- Linking a person’s own EVM or Solana wallet to their signed-in account.
- Wallets
Builder - 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§
- Address
Error - Why an address could not be normalised.
- Chain
- Which chain a wallet lives on.
- Contract
Verifier Error - Why a contract signature check could not complete.
- Message
Error - Why a message could not be parsed.
- Verify
Error - 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
Versionfield, 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§
- Contract
Signature Verifier - Verifies an EIP-1271
isValidSignature(bytes32, bytes)call against a smart-contract wallet. Backed by the#759EvmChainport when it lands; until then a venture wires its own implementation (e.g. aneth_calladapter) or the test-onlyStaticContractVerifier.