Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions chrome-extension/public/injected.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions chrome-extension/src/background/chainConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const shortListSymbolToCaip: Record<string, string> = {
SOL: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
TON: 'ton:-239/slip44:607',
TRX: 'tron:27Lqcw/slip44:195',
HIVE: 'hive:beeab0de/slip44:1275',
};

export const shortListNameToCaip: Record<string, string> = {
Expand All @@ -65,6 +66,7 @@ export const shortListNameToCaip: Record<string, string> = {
solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
ton: 'ton:-239/slip44:607',
tron: 'tron:27Lqcw/slip44:195',
hive: 'hive:beeab0de/slip44:1275',
};

// ---- bip32ToAddressNList ----
Expand Down
308 changes: 308 additions & 0 deletions chrome-extension/src/background/chains/hiveHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
import { requestStorage } from '@extension/storage';
import { v4 as uuidv4 } from 'uuid';
import * as wallet from '../wallet';
import { createProviderRpcError, createTimeoutError } from '../utils';
import { requireHiveFirmware } from '../firmware';

const TAG = ' | hiveHandler | ';

// Vault REST API (device signing) + Pioneer (chain reads / broadcast — the
// RPC source of truth; no direct Hive-node URLs, same policy as the vault).
const VAULT_URL = 'http://localhost:1646';
const PIONEER_URL = 'https://api.keepkey.info';
const HIVE_NETWORK_ID = 'hive:beeab0de';
const HIVE_DECIMALS = 3;

// SLIP-0048: m/48'/13'/role'/account'/0' (all hardened). Must match vault +
// firmware (hive.h HIVE_SLIP48_*) and Ledger's Hive app.
const H = 0x80000000;
const HIVE_ROLES = { owner: 0, active: 1, memo: 3, posting: 4 } as const;
function hiveAddressN(role: keyof typeof HIVE_ROLES = 'active', accountIndex = 0): number[] {
return [H + 48, H + 13, H + HIVE_ROLES[role], H + accountIndex, H + 0];
}

// ponytail: single-account (index 0, active role) spike — multi-account and
// posting-key ops when a dApp actually needs them.
let cachedPubkey: string | null = null;
let cachedAccountName: string | null = null;

export function resetHiveState() {
cachedPubkey = null;
cachedAccountName = null;
}

/** Get the Bearer API key from the SDK for direct REST calls */
function getApiKey(): string {
const sdk = wallet.getSdk();
const key = sdk.getClient?.()?.getApiKey?.();
if (!key) {
throw createProviderRpcError(-32603, 'API key not available — vault may not be connected');
}
return key;
}

/** Derive the active-role STM public key via the vault. */
async function getHivePublicKey(): Promise<string> {
if (cachedPubkey) return cachedPubkey;
const resp = await fetch(`${VAULT_URL}/addresses/hive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getApiKey()}` },
body: JSON.stringify({ address_n: hiveAddressN('active', 0) }),
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
if (resp.status === 403)
throw createProviderRpcError(4100, 'Hive is disabled in the vault — enable it in Vault settings');
if (resp.status === 501) throw createProviderRpcError(4100, `Hive requires firmware 7.15.0+: ${text}`);
throw createProviderRpcError(-32603, `Vault /addresses/hive failed (${resp.status}): ${text}`);
}
const { address } = await resp.json();
if (!address) throw createProviderRpcError(-32603, 'Vault returned no Hive public key');
cachedPubkey = address;
return address;
}

/** Resolve the on-chain account name owning our active key (Pioneer lookup). */
async function getHiveAccount(): Promise<{ name: string; pubkey: string }> {
const pubkey = await getHivePublicKey();
if (cachedAccountName) return { name: cachedAccountName, pubkey };
const resp = await fetch(`${PIONEER_URL}/api/v1/hive/account/${encodeURIComponent(pubkey)}`, {
signal: AbortSignal.timeout(20_000),
});
const data = await resp.json();
if (!data.success || data.noAccount || !data.account?.name) {
throw createProviderRpcError(
4100,
'No Hive account exists for this KeepKey yet — create one in the KeepKey Vault (Hive onboarding)',
);
}
cachedAccountName = data.account.name;
return { name: data.account.name, pubkey };
}

/** Build the event object for the side-panel approval flow */
function buildEvent(requestInfo: any, method: string, params: any[]) {
if (!requestInfo.id) requestInfo.id = uuidv4();
return {
id: requestInfo.id,
networkId: HIVE_NETWORK_ID,
chain: 'hive',
href: requestInfo.href,
language: requestInfo.language,
platform: requestInfo.platform,
referrer: requestInfo.referrer,
requestTime: requestInfo.requestTime,
scriptSource: requestInfo.scriptSource,
siteUrl: requestInfo.siteUrl,
userAgent: requestInfo.userAgent,
injectScriptVersion: requestInfo.version,
requestInfo,
type: method,
request: params,
status: 'request',
timestamp: new Date().toISOString(),
};
}

/** Save event to storage + open side panel + wait for user approval */
async function requestUserApproval(
event: any,
requestInfo: any,
method: string,
params: any[],
requireApproval: (networkId: string, requestInfo: any, chain: any, method: string, params: any) => Promise<any>,
) {
await requestStorage.addEvent(event);
chrome.runtime.sendMessage({ action: 'TRANSACTION_CONTEXT_UPDATED', id: event.id }).catch(() => {});
const approval = await requireApproval(HIVE_NETWORK_ID, requestInfo, 'hive', method, params);
if (!approval?.success) {
throw createProviderRpcError(4001, 'User rejected the request');
}
}

/**
* Full transfer flow: build (Pioneer tx-params + account), approve
* (side panel), sign (vault → device), broadcast (Pioneer).
*
* params[0] mirrors Hive Keychain's requestTransfer:
* { to, amount: "1.000", memo?, currency: 'HIVE' }
*/
async function hiveTransfer(
params: any[],
requestInfo: any,
requireApproval: (networkId: string, requestInfo: any, chain: any, method: string, params: any) => Promise<any>,
) {
const { username, to, amount, memo, currency, enforce } = params[0] || {};
if (!to || !amount) throw createProviderRpcError(-32602, 'Hive transfer requires { to, amount }');
// ponytail: HIVE only — HBD needs Pioneer broadcast support first
if (currency !== 'HIVE') {
throw createProviderRpcError(4200, `Only HIVE transfers are supported (got ${currency ?? 'no currency'})`);
}
// Keychain contract: a memo starting with '#' must be encrypted with the
// memo key. We can't do that yet — reject rather than leak it on-chain
// as plaintext forever.
if (typeof memo === 'string' && memo.startsWith('#')) {
throw createProviderRpcError(
4200,
'Encrypted memos (starting with "#") are not supported — the memo would be broadcast as public plaintext',
);
}
// Exact decimal-string validation (Keychain requires 3 decimals); no
// parseFloat — "1foo" and rounding of "0.0006" must be rejected, not
// silently coerced into a different transfer.
if (typeof amount !== 'string' || !/^\d+(\.\d{1,3})?$/.test(amount)) {
throw createProviderRpcError(
-32602,
`Invalid amount "${amount}" — expected a decimal string with up to 3 decimals`,
);
}
const [whole, frac = ''] = amount.split('.');
const amountBase = Number(whole) * 10 ** HIVE_DECIMALS + Number(frac.padEnd(HIVE_DECIMALS, '0'));
if (!Number.isSafeInteger(amountBase) || amountBase <= 0) {
throw createProviderRpcError(-32602, `Invalid amount "${amount}" — must be greater than zero`);
}
const amountStr = (amountBase / 10 ** HIVE_DECIMALS).toFixed(HIVE_DECIMALS) + ' HIVE';

await requireHiveFirmware('Hive transfer');
const from = await getHiveAccount();
// Keychain semantics: the dApp may name the sending account. We control
// exactly one account (index 0), so any mismatch is a hard reject — never
// report success for a payment from a different account than requested.
if (enforce === true && !username) {
throw createProviderRpcError(-32602, 'enforce=true requires a username');
}
if (username && username !== from.name) {
throw createProviderRpcError(
4100,
`This KeepKey controls @${from.name}, not @${username}${enforce ? ' (account enforced by the dApp)' : ''}`,
);
}

const txParamsResp = await fetch(`${PIONEER_URL}/api/v1/hive/tx-params`, {
signal: AbortSignal.timeout(20_000),
}).then(r => r.json());
if (!txParamsResp.success) {
throw createProviderRpcError(-32603, `Hive tx-params failed: ${txParamsResp.error || 'unknown'}`);
}

// Approval first — the user sees resolved from/to/amount before the device round-trip
const event = buildEvent(requestInfo, 'transfer', params);
(event as any).unsignedTx = {
from: from.name,
memo: memo || '',
refBlockNum: txParamsResp.refBlockNum,
expiration: txParamsResp.expirationIso,
caip: 'hive:beeab0de/slip44:1275',
// Shape the generic approval renderer reads (RequestDetailsCard):
// amount in base units, divided by 10^decimals for display.
payment: {
destination: to,
amount: amountBase,
decimals: HIVE_DECIMALS,
symbol: 'HIVE',
},
};
await requestUserApproval(event, requestInfo, 'transfer', params, requireApproval);

// Sign on-device via the vault (serialization happens in firmware)
let signResp: Response;
try {
signResp = await fetch(`${VAULT_URL}/hive/sign-transfer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getApiKey()}` },
body: JSON.stringify({
address_n: hiveAddressN('active', 0),
ref_block_num: txParamsResp.refBlockNum,
ref_block_prefix: txParamsResp.refBlockPrefix,
expiration: txParamsResp.expirationUnix,
from: from.name,
to,
amount: amountBase,
asset_symbol: 'HIVE',
memo: memo || undefined,
chain_id: txParamsResp.chainId,
}),
// Wait on the user holding the device button
signal: AbortSignal.timeout(300_000),
});
} catch (e: any) {
if (e.name === 'TimeoutError' || e.name === 'AbortError') throw createTimeoutError('Vault signing timed out');
throw createProviderRpcError(-32603, `Vault connection failed: ${e.message}`);
}
if (!signResp.ok) {
const text = await signResp.text().catch(() => '');
throw createProviderRpcError(-32603, `Vault Hive sign failed (${signResp.status}): ${text}`);
}
const { signature } = await signResp.json();
if (!signature) throw createProviderRpcError(-32603, 'Vault returned no Hive signature');

// Broadcast via Pioneer
const bResp = await fetch(`${PIONEER_URL}/api/v1/hive/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ref_block_num: txParamsResp.refBlockNum,
ref_block_prefix: txParamsResp.refBlockPrefix,
expiration: txParamsResp.expirationIso,
from: from.name,
to,
amount: amountStr,
memo: memo || '',
signature: signature.replace(/^0x/, ''),
}),
signal: AbortSignal.timeout(30_000),
});
const bData = await bResp.json().catch(() => ({}));
if (!bData.success || !bData.txid) {
throw createProviderRpcError(
-32603,
`Hive broadcast failed: ${bData.error || JSON.stringify(bData).slice(0, 200)}`,
);
}

await requestStorage.updateEventById(event.id, { ...event, txid: bData.txid, status: 'broadcasted' } as any);
chrome.runtime
.sendMessage({
action: 'transaction_complete',
eventId: requestInfo.id,
txHash: bData.txid,
explorerTxLink: 'https://hiveblocks.com/tx/',
networkId: HIVE_NETWORK_ID,
})
.catch(() => {});

// Keychain-shaped result — dApps read response.result.id / .tx_id
// (hive-tx.utils.ts returns { id, tx_id, confirmed }).
return {
id: bData.txid,
tx_id: bData.txid,
confirmed: Boolean(bData.blockNum ?? bData.block_num),
};
}

export const handleHiveRequest = async (
method: string,
params: any[],
requestInfo: any,
_ADDRESS: string,
_KEEPKEY_WALLET: any,
requireApproval: (networkId: string, requestInfo: any, chain: any, method: string, params: any) => Promise<any>,
): Promise<any> => {
console.log(TAG, 'method:', method);
switch (method) {
case 'hive_handshake': {
// Keychain-compat presence check — succeed without touching the device
return true;
}
case 'hive_getAccount': {
const account = await getHiveAccount();
return account;
}
case 'hive_transfer': {
return await hiveTransfer(params, requestInfo, requireApproval);
}
default:
throw createProviderRpcError(4200, `Hive method ${method} not supported`);
}
};
31 changes: 27 additions & 4 deletions chrome-extension/src/background/firmware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import * as wallet from './wallet';
import { createProviderRpcError } from './utils';

const REQUIRED = { major: 7, minor: 14, patch: 1 } as const;
// Hive (SLIP-0048 keys + Graphene transfer signing) shipped in 7.15.0;
// anything older returns Failure_UnknownMessage for the Hive message types.
const REQUIRED_HIVE = { major: 7, minor: 15, patch: 0 } as const;

interface FirmwareVersion {
major: number;
Expand All @@ -34,10 +37,10 @@ function parseVersion(features: any): FirmwareVersion | null {
return { major, minor, patch };
}

function meetsMin(v: FirmwareVersion): boolean {
if (v.major !== REQUIRED.major) return v.major > REQUIRED.major;
if (v.minor !== REQUIRED.minor) return v.minor > REQUIRED.minor;
return v.patch >= REQUIRED.patch;
function meetsMin(v: FirmwareVersion, min: FirmwareVersion = REQUIRED): boolean {
if (v.major !== min.major) return v.major > min.major;
if (v.minor !== min.minor) return v.minor > min.minor;
return v.patch >= min.patch;
}

async function readVersion(): Promise<FirmwareVersion | null> {
Expand Down Expand Up @@ -68,3 +71,23 @@ export async function requireMessageSigningFirmware(label: string): Promise<void
);
}
}

/**
* Throw a user-facing error if the connected KeepKey is on firmware
* older than 7.15.0 (the release that ships Hive support).
*/
export async function requireHiveFirmware(label: string): Promise<void> {
const v = await readVersion();
if (!v) {
throw createProviderRpcError(
-32603,
`${label} requires firmware 7.15.0 — could not read device firmware version. Plug in your KeepKey and try again.`,
);
}
if (!meetsMin(v, REQUIRED_HIVE)) {
throw createProviderRpcError(
4200,
`${label} requires firmware 7.15.0 or later. Your KeepKey is on ${v.major}.${v.minor}.${v.patch}. Update via the KeepKey Vault desktop app and retry.`,
);
}
}
Loading
Loading