Files
hanzo.ai/lib/auth/wallet.ts
zeekay ac2da21e5f sign-in: wallet and phone, and a person claims a wallet's account
/login and /signup draw "Continue with wallet" and "Continue with phone"
after Google and GitHub, each only where IAM's methods say it is on.

Wallet: where IAM signs a wallet in (@hanzo/iam 0.21.37 `wallet`) and the
browser injects an EIP-1193 wallet; the press is loginWithWallet (IAM's
challenge, personal_sign of its EIP-4361 text, IAM's verify) and ends as a
password's sign-in ends. A wallet IAM has not seen is made an account in an
org of its own, with no email.

Phone: where IAM says it sends codes (`code`) and has answered. The number
is read in its country with the SMS consent ticked, then IAM's code flow.
IAM's address lookup answers an email address only, so a number is texted as
any is and the account that holds it is signed in by the code.

Settings -> General -> Sign in another way adds an email or a phone to the
signed-in account through IAM's own proof (addAddress, PUT /v1/iam/address),
drawn only where IAM's methods say `address`. Same account, org, keys and
balance; nothing is created, so nothing earns twice.

Gates: wallet.spec (a throwaway secp256k1 key behind an injected provider),
phone.spec, address.spec. login.spec expects the phone after the providers.
2026-10-26 16:47:32 -07:00

44 lines
1.9 KiB
TypeScript

/**
* A wallet, as the sign-in panel and Settings see it. The sign-in itself is
* @hanzo/iam's `loginWithWallet`: IAM's challenge, the EIP-4361 text rendered
* from it, `personal_sign` by the browser's own wallet, and IAM's verify, which
* answers as its login does. This file answers the three questions the page asks
* around that call, and is pure, so each is tested without a browser.
*/
/** The EIP-1193 request surface `loginWithWallet` signs through. */
type Provider = { request?: unknown }
/**
* Whether this browser holds an Ethereum wallet the sign-in can ask: the injected
* EIP-1193 provider (`window.ethereum`) `loginWithWallet` signs with when it is
* handed none. No wallet, no button: one that could not finish is worse than none.
*/
export function injected(w: { ethereum?: Provider } | undefined = typeof window === 'undefined' ? undefined : (window as { ethereum?: Provider })): boolean {
return typeof w?.ethereum?.request === 'function'
}
/**
* Whether the wallet declined to sign: the person said no in its prompt, which
* EIP-1193 states as error 4001. Said in the page's words, not the wallet's.
*/
export function declined(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 4001
}
/** One wallet that signs an account in, as IAM names it: its chain family and its address. */
export type Wallet = { chain: string; address: string }
/**
* The wallets an access token says sign its account in: IAM's `wallets` claim, in
* IAM's order. Anything that is not a chain and an address is not one.
*/
export function wallets(claims: Record<string, unknown> | null | undefined): Wallet[] {
const set = claims?.wallets
if (!Array.isArray(set)) return []
return set.flatMap((w) => {
const r = (w ?? {}) as Record<string, unknown>
return typeof r.chain === 'string' && r.chain && typeof r.address === 'string' && r.address ? [{ chain: r.chain, address: r.address }] : []
})
}