Smart-wallet login & admin (user-1271 + EIP-1271 admin-sig)#
Two additive capabilities for callers whose wallet is a smart contract (a Safe, or any EIP-1271 wallet) rather than an EOA: user-1271 lets a human log in with a smart-contract wallet, the same way user-sig/user-passkey do; smart-wallet admin-sig lets a Safe mint and be verified as an admin-sig deed holder too (Grantor's own dashboard doesn't accept one yet — see below). Both ride the SAME Erc1271Reader.isValidSignature gate rather than ecrecover. See Concepts and Wallet login for the EOA baseline these modes extend.
User login: user-1271#
The assembler is pure, cross-language, does no I/O — it assembles a Deed from an address and already-obtained signature bytes:
import { mintUser1271Deed } from "@grantor/sdk";
// signerAddrHex / walletSigHex: whatever your Safe/wagmi glue produced for
// the exact hash your app's verifier will independently rebuild (see above).
const deed = mintUser1271Deed(
signerAddrHex,
walletSigHex,
tenantId,
audience,
origin, // the origin YOU are actually running on
challenge, // from your app's own /challenge
exp,
);
Ships as mintUser1271Deed/mint_user_1271_deed/MintUser1271Deed/ mint_user_1271_deed (TS/Python/Go/Rust) — the "user-1271 mint" row in sdk/capability_matrix.py. Your app verifies the resulting deed exactly like any other, through the SAME DeedGuard/DeedVerifier.verifyAt call every other mode uses — user-1271 needs no special handling on the verify side beyond having a chain reader wired in (which every sovereign verifier already has). See Verify a deed.
Why this is not "EIP-1271 on the user-sig path"#
An early note described this work as landing "on the user-sig verify path." That was wrong, and worth being explicit about because the reason is structural, not a wording nit: user-sig's wallet signature never reaches the verifier at all. The wallet signs user_root_binding once, holder side, to derive a root_seed; a brand-new local ECDSA keypair is then derived from that seed via HKDF, and that local key signs the deed the verifier checks — pure local math, no chain read, no wallet signature in sight. A smart-contract wallet has no recoverable private key to seed a local keypair from, and there is no single reproducible signature to hash even if it did (a Safe's approval is aggregated on-chain, not a deterministic personal_sign output you could sign twice and compare). So user-1271 and smart-wallet admin-sig are their own verification paths, built on the on-chain Erc1271Reader gate — not a variant of user-sig. Full detail: Sovereign tier § Smart-wallet login.
Obtaining the wallet's signature is app glue#
Neither this SDK nor Grantor talks to a Safe (or any other smart-contract wallet) directly. Producing the opaque wallet_sig_hex bytes a wallet contract will accept for a given hash is exactly the same kind of glue user-sig's wallet-signing callback is for an EOA, one layer further out — a Safe's own SDK (@safe-global/protocol-kit) or wagmi/viem's signMessage/readContract helpers get you there. What this SDK controls and pins exactly is the message a genuine wallet must approve: for user-1271 that is the identical user_binding string user-sig signs (grantor_sdk_core::usersig::user_binding(tenant, aud, origin, challenge, exp)), EIP-191-hashed; for smart-wallet admin-sig it is the identical human-readable admin_binding prose the EOA path signs. The verifier rebuilds the same hash from its own policy and the challenge it issued — never from anything in the token — before ever asking the wallet contract, so your app-glue code cannot influence what the wallet is actually asked to approve.
Read this before adopting user-1271: it is not the private option#
Unlike user-sig/user-passkey, a user-1271 sub is linkable to the wallet's on-chain address by anyone who already knows that address. user-sig's sub comes from a locally-derived key that never touches the chain; user-passkey's comes from a credential public key that lives only in an authenticator. user-1271's sub is a deterministic hash of the wallet's address — and every EIP-1271 wallet's address is inherently public (it's a deployed contract), so anyone holding that address can recompute the same hash for a guessed (tenant, origin) and confirm the link. Pick user-1271 for what a smart-contract wallet actually buys you — social recovery: a Safe on a 2-of-3 threshold survives one compromised signer, so a single stolen owner key is not game-over — not for privacy. user-sig/user-passkey remain the maximally-private options; use them when your users hold EOAs or passkeys and privacy matters more than multisig resilience. Full writeup: Sovereign tier § The pseudonymity tradeoff.
Smart-wallet admin: admin-sig with a Safe#
admin-sig now has a smart-wallet variant (see Sovereign tier § Dashboard login) for an admin whose wallet is a Safe or any other EIP-1271 contract, minted the same shape as the EOA path:
This does not (yet) apply to Grantor's own dashboard. The variant below is a library capability any relying party can adopt — mint + verify, both four-language, both proven end to end against a live
ERC1271WalletMock. Grantor's own control-plane dashboard handler (crates/grantor-controlplane/src/http/deed.rs) still calls the EOA-onlyverify_admin_deed; wiring it to also accept a Safe admin is a follow-up, not part of this work.
# signer_addr_hex / wallet_sig_hex: your Safe glue's output over the exact
# admin_binding(tenant, aud, challenge, exp) hash the verifier rebuilds.
deed = mint_admin_deed_smartwallet(
signer_addr_hex, wallet_sig_hex, tenant_id, audience, challenge, exp,
)
Ships as mintAdminDeedSmartwallet/mint_admin_deed_smartwallet/ MintAdminDeedSmartwallet/mint_admin_deed_smartwallet — the "admin-sig smart-wallet mint" row in sdk/capability_matrix.py. sub is the same 0x-prefixed address format the EOA path produces, so an app's is_admin(tenant, sub) authorization check needs no separate branch for a smart-wallet admin.
Verification is a separate entry point from the login path above — mirroring how EOA admin-sig is already isolated from verify_deed:
// DeedVerifier's verifyAdminSmartwalletAt — a distinct method from
// verifyAt, exactly as EOA admin-sig has its own verify_admin_deed
// separate from verify_deed. Import path: the wasm package DeedVerifier
// ships from (see docs/guide/verify-tokens.md's session-JWT excerpt for
// the same import shape).
const sub = await verifier.verifyAdminSmartwalletAt(deedJson, challenge, nowUnix);
// authenticates ONLY — your app still runs its own is_admin(tenant, sub)
// check before granting anything, same as the EOA path.
Same deliberate choices as EOA admin-sig: no tenant-status check (a lapsed tenant's admin must still be able to reach the page that lets them pay), and it authenticates, not authorizes — a successful verify proves control of the wallet contract at sub, nothing about whether that address administers any particular tenant.
Both sides ship in all four languages#
user-1271 mint, admin-sig smart-wallet mint, admin-sig smart-wallet verify, and user-1271 deed verification all ship in TypeScript, Python, Go and Rust, enforced by just capability-matrix. See Sovereign tier § Every capability, every language for the full table alongside every other mode.
See also#
- Wallet login —
user-sig, the EOA baseline. - Passkey login —
user-passkey, the other maximally-private option. - Verify a deed — the relying-party side.
- Sovereign tier § Smart-wallet login and § Dashboard login § Smart-wallet admin — the full reference, including the exact verifier checklists.
- Errors — every error code a relying party branches on.