From da219dafe1f1444f42d3e59839a52f58613aa466 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 28 Jun 2026 18:59:02 -0500 Subject: [PATCH 1/3] feat(hive): sponsor-backed account onboarding + ETH anti-drain gate Hive receive/onboarding panel (HiveAccountPanel) on the Hive asset page: resolves the device's active key to an account via Pioneer, or runs the in-app sponsor onboarding wizard (live username availability, device-derived SLIP-0048 role keys, @keepkey-sponsored on-chain creation; keys never leave the device). Backend RPCs: hiveGetRoleKeys, hiveGetAccount, hiveUsernameAvailable, hiveCreateAccount (device owner-signed account_create attestation -> Pioneer sponsor endpoint). hiveSignTx/hiveSignAccountCreate route through emuSigningOp on the emulator for the interactive confirm gate. ETH anti-drain gate: hiveCreateAccount signs a fixed EIP-191 message bound to username+ownerKey with the device ETH key (m/44'/60'/0'/0/0) and sends ethAddress+ethSignature. The server requires the address to hold mainnet ETH (one sponsored account per funded address) to keep the free service from being drained. 403 surfaces a dedicated 'fund your ETH address' screen. Bumps hdwallet 91a9e9d -> d51727e4 (master) for the Hive account-lifecycle wallet methods (keepkey/hdwallet#50). --- modules/hdwallet | 2 +- projects/keepkey-vault/src/bun/index.ts | 121 ++++++++- .../src/mainview/components/AssetPage.tsx | 4 + .../mainview/components/HiveAccountPanel.tsx | 251 ++++++++++++++++++ .../keepkey-vault/src/shared/rpc-schema.ts | 4 + 5 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 projects/keepkey-vault/src/mainview/components/HiveAccountPanel.tsx diff --git a/modules/hdwallet b/modules/hdwallet index 91a9e9d6..d51727e4 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 91a9e9d6796a3ad4c6b09744660412c31c3aa76d +Subproject commit d51727e4a5047c1676f73cf671e6269ba6163175 diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 7c9ddeae..18713983 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -121,7 +121,7 @@ import { buildTx, broadcastTx } from "./txbuilder" import { buildCosmosStakingTx, buildCosmosNameRegTx } from "./txbuilder/cosmos" import { initializeOrchardFromDevice, scanOrchardNotes, getShieldedBalance, sendShielded, ensureFvkLoaded, displayOrchardAddressOnDevice } from "./txbuilder/zcash-shielded" import { isSidecarReady, startSidecar, stopSidecar, wipeSidecarWalletDb, hasFvkLoaded, getCachedFvk, onScanProgress, getScanState, updateSyncedTo, beginZcashSend, endZcashSend, isZcashSendInFlight } from "./zcash-sidecar" -import { CHAINS, customChainToChainDef, isChainSupported } from "../shared/chains" +import { CHAINS, customChainToChainDef, isChainSupported, hiveRolePath } from "../shared/chains" import { versionCompare } from "../shared/firmware-versions" import type { ChainDef } from "../shared/chains" import { BtcAccountManager } from "./btc-accounts" @@ -2383,9 +2383,126 @@ const rpc = BrowserView.defineRPC({ if (!result) throw new Error('hiveGetPublicKey returned no result') return result }, + // Derive all four SLIP-0048 role keys (owner/active/posting/memo) for an + // account index, for the Hive onboarding panel (account creation needs all 4). + hiveGetRoleKeys: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const accountIndex = params?.accountIndex ?? 0 + const roles = ['owner', 'active', 'posting', 'memo'] as const + const out: Record = {} + for (const role of roles) { + const r = await (engine.wallet as any).hiveGetPublicKey({ + addressNList: hiveRolePath(role, accountIndex), + showDisplay: false, + }) + if (!r?.publicKey) throw new Error(`hiveGetPublicKey(${role}) returned no key`) + out[role] = r.publicKey + } + return out as { owner: string; active: string; posting: string; memo: string } + }, + // Resolve a Hive account from a device public key via Pioneer. Returns + // { noAccount: true } when the key controls no account yet (onboarding + // case), or { account: {...} } with balances/RC when it does. + hiveGetAccount: async (params) => { + const pubkey = params?.pubkey + if (!pubkey) throw new Error('hiveGetAccount requires a pubkey') + const base = getPioneerApiBase() + const resp = await fetch(`${base}/api/v1/hive/account/${encodeURIComponent(pubkey)}`) + if (!resp.ok) throw new Error(`Pioneer hive/account ${resp.status}`) + return await resp.json() + }, + // Live username availability (format + on-chain), for the onboarding wizard. + hiveUsernameAvailable: async (params) => { + const name = params?.name + if (!name) return { success: false, available: false, reason: 'empty' } + const base = getPioneerApiBase() + const resp = await fetch(`${base}/api/v1/hive/username-available/${encodeURIComponent(name)}`) + return await resp.json() + }, + // Full sponsor-backed account creation: derive the 4 device keys, get a + // device attestation (owner-signed account_create, op 9), POST to Pioneer's + // sponsor endpoint. The sponsor (@keepkey) pays; no user key leaves the device. + hiveCreateAccount: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const username = params?.username + if (!username) throw new Error('hiveCreateAccount requires a username') + const wallet = engine.wallet as any + const accountIndex = params?.accountIndex ?? 0 + + // 1. Derive the four SLIP-0048 role keys. + const roles = ['owner', 'active', 'posting', 'memo'] as const + const keys: Record = {} + for (const role of roles) { + const r = await wallet.hiveGetPublicKey({ addressNList: hiveRolePath(role, accountIndex), showDisplay: false }) + if (!r?.publicKey) throw new Error(`hiveGetPublicKey(${role}) returned no key`) + keys[role] = r.publicKey + } + + // 2. Device attestation — owner-signed account_create (op 9). The device + // re-derives the keys itself; ref_block/expiration are unchecked by the + // server (attestation only, not broadcast). On the emulator, route + // through emuSigningOp so the interactive Confirm/Reject gate appears; + // a real device uses its physical button (calling emuSigningOp there + // would pop the emulator window). + const signAccountCreate = () => wallet.hiveSignAccountCreate({ + addressNList: hiveRolePath('owner', accountIndex), + refBlockNum: 0, refBlockPrefix: 0, expiration: 0, + creator: 'keepkey', + newAccountName: username, + ownerKey: keys.owner, activeKey: keys.active, + postingKey: keys.posting, memoKey: keys.memo, + feeAmount: 3000, + }) + const signed = engine.isEmulator + ? await emuSigningOp(signAccountCreate, { operation: 'hiveSignAccountCreate', chain: 'Hive' }) + : await signAccountCreate() + if (!signed?.signature || !signed?.serializedTx) { + throw new Error('Device did not return an attestation') + } + const toHex = (v: any) => v instanceof Uint8Array ? Buffer.from(v).toString('hex') : String(v) + + // 3. ETH gate (anti-sponsor-drain). Sign a fixed EIP-191 message bound to + // username+ownerKey with the device ETH key (m/44'/60'/0'/0/0). The + // server recovers the address, requires it to hold mainnet ETH, and + // allows one sponsored account per address. Message bytes are pinned by + // a server unit test — do NOT reformat (no trailing newline). + const gateMessage = `KeepKey Hive onboarding\nusername:${username}\nowner:${keys.owner}` + const gateMessageHex = '0x' + Buffer.from(gateMessage, 'utf8').toString('hex') + const signEthGate = () => wallet.ethSignMessage({ addressNList: evmAddressPath(0), message: gateMessageHex }) + const ethSigned = engine.isEmulator + ? await emuSigningOp(signEthGate, { operation: 'ethSignMessage', chain: 'Ethereum', memo: gateMessage.slice(0, 64) }) + : await signEthGate() + // hdwallet returns { address: eip55 0x…, signature: 0x…+65-byte r||s||v } — + // exactly the form ethers.verifyMessage expects. + if (!ethSigned?.address || !ethSigned?.signature) { + throw new Error('Device did not return an ETH gate signature') + } + + // 4. POST to the sponsor endpoint. + const base = getPioneerApiBase() + const resp = await fetch(`${base}/api/v1/hive/create-account`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + username, + ownerKey: keys.owner, activeKey: keys.active, + postingKey: keys.posting, memoKey: keys.memo, + attestation: { serializedTx: toHex(signed.serializedTx), signature: toHex(signed.signature) }, + ethAddress: ethSigned.address, + ethSignature: ethSigned.signature, + }), + }) + const body = await resp.json().catch(() => ({})) + return { status: resp.status, ...body } + }, hiveSignTx: async (params) => { if (!engine.wallet) throw new Error('No device connected') - const result = await (engine.wallet as any).hiveSignTx(params) + // Route through emuSigningOp on the emulator so the interactive + // Confirm/Reject gate appears (a real device uses its button). + const signTx = () => (engine.wallet as any).hiveSignTx(params) + const result = engine.isEmulator + ? await emuSigningOp(signTx, { operation: 'hiveSignTx', chain: 'Hive', to: params?.to, value: params?.amount != null ? String(params.amount) : undefined }) + : await signTx() if (!result) throw new Error('hiveSignTx returned no result') return { signature: result.signature instanceof Uint8Array diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 16609b5d..490044c6 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -12,6 +12,7 @@ import { AnimatedUsd } from "./AnimatedUsd" import { formatBalance } from "../lib/formatting" import { useFiat } from "../lib/fiat-context" import { ReceiveView } from "./ReceiveView" +import { HiveAccountPanel } from "./HiveAccountPanel" import { SendForm } from "./SendForm" // Lazy-load optional feature components — defers module evaluation to avoid @@ -200,6 +201,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi // TON: bounceable toggle (default: non-bounceable / UQ for safe receiving) const isTon = chain.chainFamily === 'ton' + const isHive = chain.chainFamily === 'hive' const [tonBounceable, setTonBounceable] = useState(false) const deriveAddress = useCallback(async (path?: number[], overrideBounceable?: boolean) => { @@ -1127,6 +1129,8 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi }> + ) : isHive ? ( + ) : ( + {pieces.map((_, i) => { + const left = Math.round((i * 137.5) % 100) + const delay = (i % 10) * 0.06 + const dur = 1.6 + (i % 7) * 0.18 + const color = colors[i % colors.length] + const size = 6 + (i % 3) * 3 + return ( + + ) + })} + + + ) +} +type Avail = { success: boolean; available: boolean; reason?: string } +type CreateResp = { status: number; success?: boolean; txid?: string; username?: string; error?: string; retryAfter?: number } + +function KeyRow({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false) + return ( + + {label} + {value} + { navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1200) }}> + + + + ) +} + +/** + * Hive receive / onboarding panel. Hive is account-based (you receive to an + * @username, not a key). Resolves the device's active key to an account via + * Pioneer and shows account view, or the in-app sponsor onboarding wizard. + */ +export function HiveAccountPanel({ activeKey, color }: { activeKey: string | null; color: string }) { + const [state, setState] = useState<"loading" | "has" | "none" | "error">("loading") + const [account, setAccount] = useState(null) + + const refresh = () => { + if (!activeKey) return + setState("loading") + rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000) + .then(r => { if (r.account) { setAccount(r.account); setState("has") } else setState("none") }) + .catch(() => setState("error")) + } + useEffect(() => { let c = false; if (activeKey) { rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000).then(r => { if (c) return; if (r.account) { setAccount(r.account); setState("has") } else setState("none") }).catch(() => { if (!c) setState("error") }) } return () => { c = true } }, [activeKey]) + + if (state === "loading") return + + if (state === "error") return ( + + Couldn't reach the Hive account service. Try again shortly. + + + ) + + if (state === "has" && account) return ( + + Hive Account + @{account.name} + + HIVE{account.hive} + HBD{account.hbd} + {account.hp != null && HP{account.hp}} + {account.rcPercent != null && RC{account.rcPercent}%} + + + ) + + return +} + +function HiveOnboardWizard({ color, onCreated }: { color: string; onCreated: () => void }) { + const [keys, setKeys] = useState(null) + const [showKeys, setShowKeys] = useState(false) + const [username, setUsername] = useState("") + const [avail, setAvail] = useState(null) + const [checking, setChecking] = useState(false) + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + const [needsFunding, setNeedsFunding] = useState(null) // 0x ETH address to fund, or null + const [created, setCreated] = useState<{ name: string; txid?: string } | null>(null) + const debounce = useRef | null>(null) + + useEffect(() => { rpcRequest("hiveGetRoleKeys", {}, 30000).then(setKeys).catch(() => {}) }, []) + + // Debounced availability check as the user types. + useEffect(() => { + setAvail(null); setError(null) + if (debounce.current) clearTimeout(debounce.current) + const name = username.trim().toLowerCase() + if (!name) return + setChecking(true) + debounce.current = setTimeout(() => { + rpcRequest("hiveUsernameAvailable", { name }, 10000) + .then(r => setAvail(r)).catch(() => setAvail(null)).finally(() => setChecking(false)) + }, 400) + return () => { if (debounce.current) clearTimeout(debounce.current) } + }, [username]) + + const create = async () => { + const name = username.trim().toLowerCase() + if (!name || !avail?.available || creating) return + setCreating(true); setError(null); setNeedsFunding(null) + try { + const r = await rpcRequest("hiveCreateAccount", { username: name }, 120000) + if (r.status === 200 && (r.success || r.txid)) { + setCreated({ name, txid: r.txid }) + // Account is on-chain; the pubkey→account lookup may lag a block. Refresh + // the parent (account view) after a short beat while we celebrate. + setTimeout(() => onCreated(), 4500) + return + } + // 403: ETH gate — the device's ETH address must hold mainnet ETH (one + // sponsored account per funded address). Show a dedicated fund-me screen. + if (r.status === 403) { + rpcRequest<{ addresses: { addressIndex: number; address: string }[] }>("getEvmAddresses", undefined, 10000) + .then(s => setNeedsFunding(s.addresses.find(a => a.addressIndex === 0)?.address ?? "")) + .catch(() => setNeedsFunding("")) + } + // 409 is overloaded: either the username was just taken, or this ETH + // address already claimed its one sponsored account. Server error text + // disambiguates; reformat-fail back to name-taken. + else if (r.status === 409) { + if (/eth|address/i.test(r.error ?? "")) setError("This device already has a sponsored Hive account.") + else { setError("That name was just taken — pick another."); setAvail({ success: true, available: false, reason: "taken" }) } + } + else if (r.status === 401) setError("Device confirmation didn't verify. Try again.") + else if (r.status === 503) setError(`Sponsor is busy. Try again in ${r.retryAfter ?? 60}s.`) + else if (r.status === 400) setError("Request rejected (invalid). This is a client bug — don't retry.") + else setError(r.error || "Account creation failed. Try again later.") + } catch { + setError("Account creation failed — device or network error.") + } finally { setCreating(false) } + } + + const name = username.trim().toLowerCase() + const canCreate = !!name && avail?.available === true && !creating + + if (needsFunding !== null) return ( + + Fund your ETH address first + + Sponsored Hive accounts are free, but each one needs a funded Ethereum address so the + service can't be drained. Send any amount of mainnet ETH to your KeepKey ETH + address below, then retry. One sponsored account per ETH address. + + {needsFunding ? ( + + {needsFunding} + navigator.clipboard.writeText(needsFunding)}> + + + + ) : Open the Ethereum asset to view your receive address.} + + + ) + + if (created) return ( + + + 🎉 + Account created! + @{created.name} + + Secured by KeepKey — sponsored by @keepkey, no fee. Loading your account… + + {created.txid && ( + rpcRequest("openUrl", { url: `https://hiveblocks.com/tx/${created.txid}` }).catch(() => {})}> + tx {created.txid.slice(0, 16)}… + + )} + + + ) + + return ( + + Create your Hive account + + Hive funds go to a username, not a key. KeepKey sponsors the on-chain creation — you pay nothing + and your keys never leave the device. Pick a name and confirm on your device. + + + + setUsername(e.target.value)} placeholder="username" + autoCapitalize="none" autoCorrect="off" spellCheck={false} + bg="var(--surface-1)" border="1px solid" borderColor="var(--border)" fontSize="14px" pr="9" /> + + {checking ? + : avail?.available ? + : avail && !avail.available ? : null} + + + {avail && !avail.available && avail.reason && ( + {avail.reason} + )} + {avail?.available && @{name} is available} + + + {error && {error}} + + setShowKeys(s => !s)}> + + Device keys for this account + + {showKeys && ( + + {keys ? (<> + + + + + ) : } + + )} + + ) +} diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index afc384ff..a9f3c962 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -83,6 +83,10 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { tonSignTx: { params: any; response: any } tonSignMessage: { params: any; response: any } hiveGetPublicKey: { params: any; response: any } + hiveGetRoleKeys: { params: { accountIndex?: number }; response: { owner: string; active: string; posting: string; memo: string } } + hiveGetAccount: { params: { pubkey: string }; response: { success?: boolean; noAccount?: boolean; account?: { name: string; hive: string; hbd: string; hp?: string; rcPercent?: number } } } + hiveUsernameAvailable: { params: { name: string }; response: { success: boolean; available: boolean; reason?: string } } + hiveCreateAccount: { params: { username: string; accountIndex?: number }; response: { status: number; success?: boolean; txid?: string; username?: string; error?: string; retryAfter?: number } } hiveSignTx: { params: any; response: any } // ── Pioneer integration ───────────────────────────────────────── From c442fb0c4daaffbbdf8bc568b8224063ad97caf5 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 28 Jun 2026 19:27:36 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(hive):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20success=20poll,=20derive-error=20surfacing,=20usern?= =?UTF-8?q?ame=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: after a successful create, poll hiveGetAccount (backing off, 90s cap) and only hand off to the parent once the account resolves, instead of a single 4.5s refresh that could bounce a real success back to the wizard when the pubkey->account lookup lagged. Poll halts on unmount. - P2: surface address-derivation failures instead of an infinite spinner — the panel now takes loading/deriveError/onRetryDerive; a null activeKey with an error (or no in-flight derive) shows an actionable Retry. - P2: ignore stale username-availability responses (tag avail with its name + latest-name guard) so a late 'available' can't enable creation for a different, unchecked name. create() guard hardened to match. --- .../src/mainview/components/AssetPage.tsx | 2 +- .../mainview/components/HiveAccountPanel.tsx | 62 +++++++++++++++---- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx index 490044c6..36e1f002 100644 --- a/projects/keepkey-vault/src/mainview/components/AssetPage.tsx +++ b/projects/keepkey-vault/src/mainview/components/AssetPage.tsx @@ -1130,7 +1130,7 @@ export function AssetPage({ chain, balance, onBack, firmwareVersion, initialActi ) : isHive ? ( - + ) : ( void +}) { const [state, setState] = useState<"loading" | "has" | "none" | "error">("loading") const [account, setAccount] = useState(null) @@ -66,6 +69,18 @@ export function HiveAccountPanel({ activeKey, color }: { activeKey: string | nul } useEffect(() => { let c = false; if (activeKey) { rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000).then(r => { if (c) return; if (r.account) { setAccount(r.account); setState("has") } else setState("none") }).catch(() => { if (!c) setState("error") }) } return () => { c = true } }, [activeKey]) + // No active key yet: distinguish a failed/absent derivation (actionable) from + // one still in flight (transient spinner) — otherwise a null key spins forever. + if (!activeKey) { + if (deriveError || !loading) return ( + + {deriveError || "Couldn't derive your Hive key from the device."} + {onRetryDerive && } + + ) + return + } + if (state === "loading") return if (state === "error") return ( @@ -88,48 +103,70 @@ export function HiveAccountPanel({ activeKey, color }: { activeKey: string | nul ) - return + return } -function HiveOnboardWizard({ color, onCreated }: { color: string; onCreated: () => void }) { +function HiveOnboardWizard({ color, activeKey, onCreated }: { color: string; activeKey: string; onCreated: () => void }) { const [keys, setKeys] = useState(null) const [showKeys, setShowKeys] = useState(false) const [username, setUsername] = useState("") - const [avail, setAvail] = useState(null) + // avail is tagged with the name it describes, so a late/out-of-order response + // for an old input can't enable creation for the current (unchecked) name. + const [avail, setAvail] = useState<(Avail & { name: string }) | null>(null) const [checking, setChecking] = useState(false) const [creating, setCreating] = useState(false) const [error, setError] = useState(null) const [needsFunding, setNeedsFunding] = useState(null) // 0x ETH address to fund, or null const [created, setCreated] = useState<{ name: string; txid?: string } | null>(null) const debounce = useRef | null>(null) + const latestName = useRef("") // current input; guards stale availability responses + const stopped = useRef(false) // set on unmount to halt the success poll useEffect(() => { rpcRequest("hiveGetRoleKeys", {}, 30000).then(setKeys).catch(() => {}) }, []) + useEffect(() => () => { stopped.current = true }, []) // Debounced availability check as the user types. useEffect(() => { setAvail(null); setError(null) if (debounce.current) clearTimeout(debounce.current) const name = username.trim().toLowerCase() - if (!name) return + latestName.current = name + if (!name) { setChecking(false); return } setChecking(true) debounce.current = setTimeout(() => { rpcRequest("hiveUsernameAvailable", { name }, 10000) - .then(r => setAvail(r)).catch(() => setAvail(null)).finally(() => setChecking(false)) + // Only apply if the input hasn't changed since this request fired. + .then(r => { if (latestName.current === name) setAvail({ ...r, name }) }) + .catch(() => { if (latestName.current === name) setAvail(null) }) + .finally(() => { if (latestName.current === name) setChecking(false) }) }, 400) return () => { if (debounce.current) clearTimeout(debounce.current) } }, [username]) const create = async () => { const name = username.trim().toLowerCase() - if (!name || !avail?.available || creating) return + if (!name || avail?.available !== true || avail.name !== name || creating) return setCreating(true); setError(null); setNeedsFunding(null) try { const r = await rpcRequest("hiveCreateAccount", { username: name }, 120000) if (r.status === 200 && (r.success || r.txid)) { setCreated({ name, txid: r.txid }) - // Account is on-chain; the pubkey→account lookup may lag a block. Refresh - // the parent (account view) after a short beat while we celebrate. - setTimeout(() => onCreated(), 4500) + // Account is on-chain but the pubkey→account lookup may lag a block or two. + // Poll (backing off) and only hand off to the parent once it resolves — + // otherwise the parent would see noAccount and bounce back to this wizard. + // If it never resolves, the celebration stays with its "View account" button. + const deadline = Date.now() + 90000 + const poll = (delay: number) => setTimeout(() => { + if (stopped.current) return + rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000) + .then(rr => { + if (stopped.current) return + if (rr.account) onCreated() + else if (Date.now() < deadline) poll(Math.min(delay * 1.5, 8000)) + }) + .catch(() => { if (!stopped.current && Date.now() < deadline) poll(Math.min(delay * 1.5, 8000)) }) + }, delay) + poll(2500) return } // 403: ETH gate — the device's ETH address must hold mainnet ETH (one @@ -144,7 +181,7 @@ function HiveOnboardWizard({ color, onCreated }: { color: string; onCreated: () // disambiguates; reformat-fail back to name-taken. else if (r.status === 409) { if (/eth|address/i.test(r.error ?? "")) setError("This device already has a sponsored Hive account.") - else { setError("That name was just taken — pick another."); setAvail({ success: true, available: false, reason: "taken" }) } + else { setError("That name was just taken — pick another."); setAvail({ success: true, available: false, reason: "taken", name }) } } else if (r.status === 401) setError("Device confirmation didn't verify. Try again.") else if (r.status === 503) setError(`Sponsor is busy. Try again in ${r.retryAfter ?? 60}s.`) @@ -156,7 +193,8 @@ function HiveOnboardWizard({ color, onCreated }: { color: string; onCreated: () } const name = username.trim().toLowerCase() - const canCreate = !!name && avail?.available === true && !creating + // Gate on avail belonging to the CURRENT name — never sign for an unchecked name. + const canCreate = !!name && avail?.available === true && avail.name === name && !creating if (needsFunding !== null) return ( From a7d1d4964ac5ab343f6cfe83b0947d32a47c2663 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 28 Jun 2026 19:43:28 -0500 Subject: [PATCH 3/3] fix(hive): guard the View account button against the success bounce too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-poll already waited for hiveGetAccount to resolve before handing off, but the manual 'View account' button still called onCreated blind — a click before Pioneer resolved the pubkey would refresh()->noAccount->wizard, unmounting the celebration. Both paths now share a guarded resolveToAccount(): the button does the same lookup, keeps the success screen on noAccount, and shows a 'still finalizing' hint instead of bouncing. --- .../mainview/components/HiveAccountPanel.tsx | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/projects/keepkey-vault/src/mainview/components/HiveAccountPanel.tsx b/projects/keepkey-vault/src/mainview/components/HiveAccountPanel.tsx index 7dd5c258..7d04656b 100644 --- a/projects/keepkey-vault/src/mainview/components/HiveAccountPanel.tsx +++ b/projects/keepkey-vault/src/mainview/components/HiveAccountPanel.tsx @@ -118,6 +118,8 @@ function HiveOnboardWizard({ color, activeKey, onCreated }: { color: string; act const [error, setError] = useState(null) const [needsFunding, setNeedsFunding] = useState(null) // 0x ETH address to fund, or null const [created, setCreated] = useState<{ name: string; txid?: string } | null>(null) + const [viewing, setViewing] = useState(false) // "View account" lookup in flight + const [viewMsg, setViewMsg] = useState(null) const debounce = useRef | null>(null) const latestName = useRef("") // current input; guards stale availability responses const stopped = useRef(false) // set on unmount to halt the success poll @@ -143,6 +145,26 @@ function HiveOnboardWizard({ color, activeKey, onCreated }: { color: string; act return () => { if (debounce.current) clearTimeout(debounce.current) } }, [username]) + // Guarded hand-off: only leave the success screen once Pioneer actually + // resolves the new account. Shared by the auto-poll and the "View account" + // button so neither can bounce the celebration back to the wizard. + const resolveToAccount = async (): Promise => { + try { + const rr = await rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000) + if (rr.account) { onCreated(); return true } + } catch { /* not resolved yet */ } + return false + } + + const onViewAccount = async () => { + if (viewing) return + setViewing(true); setViewMsg(null) + const ok = await resolveToAccount() + if (stopped.current) return + setViewing(false) + if (!ok) setViewMsg("Still finalizing on-chain — give it a few seconds and try again.") + } + const create = async () => { const name = username.trim().toLowerCase() if (!name || avail?.available !== true || avail.name !== name || creating) return @@ -156,15 +178,10 @@ function HiveOnboardWizard({ color, activeKey, onCreated }: { color: string; act // otherwise the parent would see noAccount and bounce back to this wizard. // If it never resolves, the celebration stays with its "View account" button. const deadline = Date.now() + 90000 - const poll = (delay: number) => setTimeout(() => { + const poll = (delay: number) => setTimeout(async () => { if (stopped.current) return - rpcRequest("hiveGetAccount", { pubkey: activeKey }, 15000) - .then(rr => { - if (stopped.current) return - if (rr.account) onCreated() - else if (Date.now() < deadline) poll(Math.min(delay * 1.5, 8000)) - }) - .catch(() => { if (!stopped.current && Date.now() < deadline) poll(Math.min(delay * 1.5, 8000)) }) + const ok = await resolveToAccount() + if (!ok && !stopped.current && Date.now() < deadline) poll(Math.min(delay * 1.5, 8000)) }, delay) poll(2500) return @@ -235,9 +252,11 @@ function HiveOnboardWizard({ color, activeKey, onCreated }: { color: string; act tx {created.txid.slice(0, 16)}… )} - + {viewMsg && {viewMsg}} )