Wallet login (user-sig)#
A human signs in with their wallet and your app receives a stable, app-scoped pseudonym — not their address. There is no browser redirect, no issuer, and no session-granting server in between: the wallet signs, the SDK mints a deed, and your app verifies it directly against the on-chain registry. If you haven't already, read Concepts for the mental model.
TypeScript: signInWithDeed#
@grantor/sdk exports signInWithDeed, which composes the wallet-signing steps (The flow, below) and the origin-provenance check into one call:
import { signInWithDeed } from "@grantor/sdk";
const deed = await signInWithDeed({
signMessage, // wraps the wallet's personal_sign
tenantId,
audience,
origin, // the origin YOU are actually running on
challenge, // from your app's own /challenge
exp,
vouch, // the RP's published origin_vouch
chainId, // registry defaults to Registry.canonical(), resolved against this chain id
chainReader,
});
registry defaults to Registry.canonical() — the SDK pins it for you; pass registry: Registry.devnet(addr) for local development or Registry.dedicated(addr, license) for a licensed enterprise registry. The old chainRegistry option (a plain address) is deleted — passing it throws a TypeError naming the replacement, never a silent fall-through to the canonical default.
Python, Go and Rust expose the same steps (deriveRootSeed/deriveAppKey/ mintUserDeed, in their language-specific casing) as separate calls rather than one wrapper. The Rust snippet in Sovereign tier § User login is the source of truth; sdk/README.md maps the equivalent calls per language.
The flow#
- Your app issues a challenge —
GET /challengeon your own deed guard. - The wallet signs. The SDK has the wallet sign a human-readable message naming the origin (twice, compared for determinism) to derive a
root_seed, then derives a per-(tenant, origin)app key from it and signs the deed with that key. One wallet popup per origin, not one for the whole web. - Your app verifies the deed with
DeedGuard/DeedVerifierand mints its own session, however it already does. See Verify a deed.
The full walkthrough — the exact derivation (derive_root_seed → derive_app_key → mint_user_deed), what is and is not in the key-derivation scope (tenant + origin, deliberately not aud), and what a sub looks like across audiences/origins/tenants — is documented once, in Sovereign tier § User login (user-sig). Read that rather than a second copy here.
Any wallet, any chain#
user-sig is wallet-chain-agnostic: the wallet only ever signs the origin-binding message (user_root_binding(origin)), and the verifier never sees which chain the wallet belongs to — only the secp256k1 app key deriveAppKey derives from whatever signature comes back. An EVM wallet's personal_sign, or a Solana / Aptos / Sui / Near wallet's signMessage — any wallet that can produce a signature over that one message — mints the same shape of deed and yields the same stable (tenant, origin) pseudonym user-sig gives every other wallet.
use grantor_sdk_core::usersig::{derive_root_seed, derive_app_key, mint_user_deed};
// A non-EVM wallet's `signMessage` in place of `personal_sign` — the SDK
// does not care which. `derive_root_seed` signs the message TWICE and
// compares the results, so the closure just needs to return the wallet's
// raw signature bytes; nothing here assumes an EVM signature's shape.
let seed = derive_root_seed(origin, |msg| wallet.sign_message(msg))?;
let app_key = derive_app_key(&seed, tenant_id, origin)?;
let token = mint_user_deed(&app_key, tenant_id, audience, origin, &challenge, exp)?;
This is the same flow and the same mint API the EVM path uses — there is no separate non-EVM entry point. Pass the wallet's signMessage output where the EVM snippet passes personal_sign's, and everything downstream (deriveAppKey, mintUserDeed, verification) runs unchanged. TypeScript, Python and Go expose the same deriveRootSeed/deriveAppKey/ mintUserDeed calls, in their own casing.
Determinism is required#
The wallet must sign deterministically — the same message must always produce the same signature bytes. EVM personal_sign already is (RFC 6979); a non-EVM wallet must use RFC 8032 pure ed25519 signing, not a randomized ("hedged") variant. deriveRootSeed signs the binding message twice and compares the results before deriving anything from it, so a non-deterministic wallet is refused loudly — SdkError::Crypto("wallet signing is non-deterministic; unsupported") (or the equivalent typed error in your language) — rather than silently minting a new pseudonym on every login.
Origin provenance: required before signing#
signInWithDeed (and authenticate(), the equivalent agent-side helper) refuses to sign before it signs anything — not after — if the origin cannot show a tenant admin's on-chain vouch that it speaks for (tenant, audience). This is what stops a hostile origin that republishes a victim tenant's tenant/audience from harvesting a pseudonym by simply asking a holder to sign in. chainReader is REQUIRED — omitting it is a fail-closed error, not a skipped check. See Sovereign tier § Origin provenance and § Constructing a chainReader.
What this proves — and what it does not#
user-sig proves "I control a key", not "I control a wallet". Permissionless login is open by definition — anyone can generate a key. The wallet's role is portability of identity across devices, not gatekeeping. There is no on-chain user revocation, because there is no on-chain user state to revoke; an RP that wants to ban someone bans the sub on its own side.
personal_sign also means the wallet cannot tell who is asking: a hostile page can request the victim's origin-scoped message, and the only control left is a human reading a prompt that names an origin they are not on. See Sovereign tier § Why the seed is origin-scoped for the full residual-risk statement — do not describe this mode as a hard phishing-resistance guarantee.
If you can require a passkey instead of a wallet, Passkey login (user-passkey) closes exactly this residual via browser-enforced origin binding. It is additive, not a replacement — this page and user-sig remain the compatibility path for a caller who only has a wallet.
See also#
- Verify a deed — the relying-party side.
- Agent tokens — the
agent-zkdeed, for enrolled agents. - Sovereign tier — the full reference.
- Errors — every error code a relying party branches on.