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..36e1f002 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, loading, deriveError, onRetryDerive }: { + activeKey: string | null; color: string + loading?: boolean; deriveError?: string | null; onRetryDerive?: () => void +}) { + 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]) + + // 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 ( + + 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, activeKey, onCreated }: { color: string; activeKey: string; onCreated: () => void }) { + const [keys, setKeys] = useState(null) + const [showKeys, setShowKeys] = useState(false) + const [username, setUsername] = useState("") + // 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 [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 + + 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() + latestName.current = name + if (!name) { setChecking(false); return } + setChecking(true) + debounce.current = setTimeout(() => { + rpcRequest("hiveUsernameAvailable", { name }, 10000) + // 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]) + + // 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 + 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 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(async () => { + if (stopped.current) return + const ok = await resolveToAccount() + if (!ok && !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 + // 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", 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.`) + 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() + // 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 ( + + 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)}… + + )} + + {viewMsg && {viewMsg}} + + ) + + 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 ─────────────────────────────────────────