docs / guide

view as .md

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#

  1. Your app issues a challengeGET /challenge on your own deed guard.
  2. 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.
  3. Your app verifies the deed with DeedGuard/DeedVerifier and mints its own session, however it already does. See Verify a deed.

The full walkthrough — the exact derivation (derive_root_seedderive_app_keymint_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#

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.