A wallet with no account attaches to the account you sign in with

Pressing a wallet on the sign-in page sent IAM a login, and IAM's answer
for a wallet nobody holds — "no account is linked to this wallet" — was
drawn as a dead end beside a Create account button. Somebody who already
had an account by email or a provider made a second one to get in.

IAM attaches a verified wallet to an existing identity only when the
verify arrives with that identity's own same-site session (hanzoai/iam
internal/wallet/verify.go). So the refusal is now named (`unlinked`),
the chain is parked in session storage, the page says to sign in below,
and after any sign-in — the form, a second factor, a provider's return
through /callback — the wallet flow runs once more with the session and
IAM links it. From then on the wallet alone signs them in. A declined
second signature leaves the sign-in as it was, wallet unattached.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-09-10 21:37:09 -07:00
co-authored by Hanzo Dev
parent 70df6265f2
commit 5c1b3a4dcb
9 changed files with 197 additions and 12 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import type { Brand, Org } from '@hanzo/id-shared'
import { createIam } from '@hanzo/id-auth'
import { attachParkedWallet, createAuthClient, createIam } from '@hanzo/id-auth'
import { BrandFooter } from '../components/BrandFooter'
/**
@@ -32,7 +32,10 @@ export function Callback({ org, brand }: { org: Org; brand: Brand }) {
const iam = createIam(org)
iam
.handleCallback(window.location.href)
.then((tok) => {
.then(async (tok) => {
// A wallet refused before this sign-in for having no account attaches to
// the account that just signed in through a provider.
await attachParkedWallet(createAuthClient({ org }))
const target = sessionStorage.getItem('post_login_redirect')
sessionStorage.removeItem('post_login_redirect')
if (target) {
+7 -4
View File
@@ -8,8 +8,7 @@ import {
SocialButtons,
mfaChannelOf,
type AuthClient,
type LoginResponse,
} from '@hanzo/id-auth'
type LoginResponse, attachParkedWallet } from '@hanzo/id-auth'
import { BrandFooter } from '../components/BrandFooter'
import { clientIdFrom } from '../route'
@@ -86,7 +85,10 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
// The credential check succeeded (or MFA was satisfied). For a downstream
// OIDC request, re-enter authorize with the now-established IAM session so it
// mints the code; for a bare portal sign-in, land on onboarding.
function completeAfterAuth() {
async function completeAfterAuth() {
// A wallet refused earlier for having no account attaches to the account
// that just satisfied its factor.
await attachParkedWallet(client)
if (redirectUri) {
window.location.href = client.authorize({
clientId,
@@ -176,9 +178,10 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
if (res.error) {
setChallengeError(res.error)
} else if (res.redirectUrl) {
await attachParkedWallet(client)
window.location.href = res.redirectUrl
} else {
completeAfterAuth()
await completeAfterAuth()
}
}
return (
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.2.98",
"version": "0.2.99",
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
"packageManager": "[email protected]",
"scripts": {
+3
View File
@@ -20,6 +20,9 @@ export { createIam } from './iam'
export { authorizeRequest, matchProviderHint } from './social'
export {
loginWithWalletChain,
attachParkedWallet,
parkWallet,
parkedWallet,
detectWalletChains,
WALLET_CHAIN_LABELS,
type WalletLoginContext,
+7
View File
@@ -81,6 +81,13 @@ export interface LoginResponse {
* the address actually bound — which is what a caller may show or record.
*/
readonly walletAddress?: string
/**
* On a wallet flow: the signature verified, and no account holds this wallet.
* Not a dead end — a person who has an account by email or a provider can sign
* in with it and the wallet attaches to that account, which is what the login
* page offers when this is set. `error` carries IAM's own sentence as well.
*/
readonly unlinked?: boolean
readonly error?: string
}
+10 -2
View File
@@ -5,6 +5,7 @@ import { Alert } from './Alert'
import { lazy, Suspense } from 'react'
import { PasswordField } from './PasswordField'
import { Submit } from './Submit'
import { attachParkedWallet } from '../web3'
export interface LoginFormProps {
readonly client: AuthClient
@@ -205,9 +206,16 @@ export function LoginForm(props: LoginFormProps) {
if (res.error) {
setError(res.error)
props.onRefused?.(res.error)
} else if (res.mfaRequired) {
return
}
if (res.mfaRequired) {
props.onMfaRequired?.(res)
} else if (props.onAuthenticated) {
return
}
// Signed in. A wallet that was refused for having no account attaches to
// this one now, before the page is left — see attachParkedWallet.
await attachParkedWallet(client)
if (props.onAuthenticated) {
// Caller owns the next step (e.g. device approval) — suppress the
// default navigation so we stay on-page.
props.onAuthenticated(res)
+12 -2
View File
@@ -5,7 +5,7 @@ import type { AuthClient } from '../client'
import type { AppProvider } from '../types'
import { authorizeRequest, matchProviderHint, PROVIDER_ORDER } from '../social'
import { createIam } from '../iam'
import { loginWithWalletChain, detectWalletChains, WALLET_CHAIN_LABELS } from '../web3'
import { loginWithWalletChain, detectWalletChains, parkWallet, WALLET_CHAIN_LABELS } from '../web3'
import { GitHubIcon, GitLabIcon, GoogleIcon, PhoneIcon, WalletIcon } from './icons'
import { Alert } from './Alert'
import { Divider } from './Divider'
@@ -317,7 +317,17 @@ export function SocialButtons({
codeChallenge: sp.get('code_challenge') ?? undefined,
codeChallengeMethod: (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined,
})
if (res.error) {
if (res.unlinked) {
// A wallet nobody holds is not a dead end. Somebody who already has an
// account — by email or a provider — was met with IAM's refusal and a
// "Create account" button, and made a second account to get in. The
// wallet is parked; the sign-in below attaches it, and from then on
// the wallet alone signs them in.
parkWallet(chain)
setError(
'No account has this wallet yet. Sign in below with your email or a provider and it attaches to that account; after that, the wallet signs you in. New here? Create an account.',
)
} else if (res.error) {
setError(res.error)
} else if (res.redirectUrl) {
// Same post-login redirect the password flow performs.
+89
View File
@@ -11,9 +11,12 @@ import { test } from 'vitest'
import assert from 'node:assert/strict'
import { createAuthClient } from './client.ts'
import {
attachParkedWallet,
loginWithWalletChain,
detectWalletChains,
offeredWalletChains,
parkWallet,
parkedWallet,
WALLET_CHAIN_LABELS,
type WalletSigner,
} from './web3.ts'
@@ -253,3 +256,89 @@ test('an IAM verify error is returned as { error }', async () => {
assert.equal(res.error, 'web3: bad signature')
assert.equal(res.redirectUrl, undefined)
})
/** A session store for tests: the Storage surface over a Map. */
function memory(): Storage {
const m = new Map<string, string>()
return {
get length() {
return m.size
},
clear: () => m.clear(),
getItem: (k) => m.get(k) ?? null,
key: (i) => [...m.keys()][i] ?? null,
removeItem: (k) => void m.delete(k),
setItem: (k, v) => void m.set(k, v),
}
}
test('a wallet nobody holds is refused as unlinked, with the address, not as a dead end', async () => {
const fetchImpl: typeof fetch = async (input) => {
const url = input.toString()
if (url.includes('/v1/iam/web3/nonce')) {
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), { status: 200 })
}
return new Response(JSON.stringify({ status: 'error', msg: 'web3: no account is linked to this wallet' }), { status: 200 })
}
const { sign, proof } = fakeSigner()
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
assert.equal(res.unlinked, true)
assert.equal(res.walletAddress, proof.address)
assert.match(res.error ?? '', /no account is linked/)
})
test('any other refusal is not unlinked', async () => {
const fetchImpl: typeof fetch = async (input) => {
const url = input.toString()
if (url.includes('/v1/iam/web3/nonce')) {
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), { status: 200 })
}
return new Response(JSON.stringify({ status: 'error', msg: 'web3: this wallet is already linked to another account' }), { status: 200 })
}
const { sign } = fakeSigner()
const res = await loginWithWalletChain(createAuthClient({ org: org(), fetchImpl }), 'evm', {}, fetchImpl, sign)
assert.equal(res.unlinked, false)
})
test('a parked wallet is read once, and only a known chain comes back', () => {
const store = memory()
parkWallet('solana', store)
assert.equal(parkedWallet(store), 'solana')
assert.equal(parkedWallet(store), null)
store.setItem('hanzo_id_wallet_to_attach', 'not-a-chain')
assert.equal(parkedWallet(store), null)
})
test('after a sign-in the parked wallet runs the flow once more, with a session, and the park is cleared', async () => {
const store = memory()
parkWallet('evm', store)
const { calls, fetchImpl } = capturingFetch('AUTHCODE')
const { sign, seen } = fakeSigner()
const client = createAuthClient({ org: org(), fetchImpl })
const res = await attachParkedWallet(client, fetchImpl, sign, store)
assert.equal(seen.length, 1, 'signed once')
assert.equal(calls.filter((c) => c.url.includes('/v1/iam/web3/verify')).length, 1, 'verified once')
assert.equal(res?.walletAddress, '0xabc0000000000000000000000000000000000def')
assert.equal(parkedWallet(store), null, 'the park is cleared')
})
test('with nothing parked, a sign-in attaches nothing and touches no wallet', async () => {
const { calls, fetchImpl } = capturingFetch()
const { sign, seen } = fakeSigner()
const res = await attachParkedWallet(createAuthClient({ org: org(), fetchImpl }), fetchImpl, sign, memory())
assert.equal(res, null)
assert.equal(seen.length, 0)
assert.equal(calls.length, 0)
})
test('a declined second signature does not fail the sign-in it follows', async () => {
const store = memory()
parkWallet('evm', store)
const { fetchImpl } = capturingFetch()
const sign: WalletSigner = async () => {
throw new Error('User rejected the request')
}
const res = await attachParkedWallet(createAuthClient({ org: org(), fetchImpl }), fetchImpl, sign, store)
assert.match(res?.error ?? '', /rejected/)
})
+63 -1
View File
@@ -257,7 +257,12 @@ async function parseVerifyResponse(
return { error: `HTTP ${res.status} non-JSON response` }
}
if (!res.ok || body.status === 'error') {
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
const error = typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}`
// IAM's login branch refuses a wallet nobody holds with this one sentence
// (hanzoai/iam internal/wallet/verify.go, errNoAccount). It is the one
// refusal a person can do something about, so it is named rather than left
// as text: the page parks the wallet and offers a sign-in that attaches it.
return { error, unlinked: /no account is linked to this wallet/i.test(error), walletAddress: address }
}
const data = body.data
@@ -280,3 +285,60 @@ function errMessage(err: unknown): string {
if (err instanceof Error) return err.message
return String(err)
}
// -- A wallet that has no account yet, attached to the one signed in next ----------
//
// IAM attaches a verified wallet to an EXISTING identity only when the verify
// arrives with that identity's own same-site session (verify.go: `in.Session`).
// So the order is: the wallet is refused ("no account is linked"), the person
// signs in the way they already can — email, password, a provider — and THEN the
// same wallet flow runs once more, now with a session, and IAM links it. From
// then on the wallet signs them in. The chain is parked in session storage
// because a provider sign-in leaves the page and returns through /callback.
const PARKED = 'hanzo_id_wallet_to_attach'
const parking = (): Storage | undefined =>
typeof window === 'undefined' ? undefined : window.sessionStorage
/** Remember that `chain`'s wallet was refused for having no account, to attach after the next sign-in. */
export function parkWallet(chain: Chain, store: Storage | undefined = parking()): void {
try {
store?.setItem(PARKED, chain)
} catch {
/* no storage: the person can press the wallet again once signed in */
}
}
/** The parked chain, if any, cleared on read. */
export function parkedWallet(store: Storage | undefined = parking()): Chain | null {
try {
const chain = store?.getItem(PARKED) ?? null
store?.removeItem(PARKED)
return chain && (CHAINS as readonly string[]).includes(chain) ? (chain as Chain) : null
} catch {
return null
}
}
/**
* After a sign-in: attach the parked wallet, if one was parked, by running the
* wallet flow again with the session now live. Resolves to the flow's answer, or
* null when nothing was parked. A refusal here — the person declines the second
* signature, say — is not a failure of the sign-in that just happened, so callers
* carry on with it either way; the wallet simply stays unattached.
*/
export async function attachParkedWallet(
client: AuthClient,
fetchImpl: typeof fetch = fetch,
sign: WalletSigner = defaultSigner,
store: Storage | undefined = parking(),
): Promise<LoginResponse | null> {
const chain = parkedWallet(store)
if (!chain) return null
try {
return await loginWithWalletChain(client, chain, {}, fetchImpl, sign)
} catch (err) {
return { error: errMessage(err) }
}
}