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
180 changes: 180 additions & 0 deletions projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* The emulator confirm dialog must not MISREPRESENT a Hive operation.
*
* /hive/sign-operations hands the device a pre-serialized blob, so the dialog
* has nothing to read amounts out of unless the caller passes the op array. It
* previously showed "Hive Sign Operations / Chain: HIVE" and nothing else — the
* user approved a claim, a market order and a power-down through an identical
* prompt. These tests pin the two properties that matter: the op is named, and
* no row is invented for an op that has no counterparty.
*/
import { describe, it, expect } from 'bun:test'
import { hiveConfirmDetails, hiveMessagePreview } from './emulator-confirm-details'

describe('hiveConfirmDetails', () => {
it('names the operation instead of a bare chain header', () => {
const d = hiveConfirmDetails('hiveSignOperations', [
['claim_reward_balance', {
account: 'alice',
reward_hive: '1.500 HIVE',
reward_hbd: '0.000 HBD',
reward_vests: '1000.000000 VESTS',
}],
])
expect(d.opLabel).toBe('Claim Rewards')
expect(d.chain).toBe('Hive')
})

it('omits zero rewards so the real amount is not buried', () => {
const d = hiveConfirmDetails('hiveSignOperations', [
['claim_reward_balance', {
account: 'alice',
reward_hive: '1.500 HIVE',
reward_hbd: '0.000 HBD',
reward_vests: '1000.000000 VESTS',
}],
])
expect(d.value).toContain('1.500 HIVE')
expect(d.value).toContain('1000.000000 VESTS')
expect(d.value).not.toContain('0.000 HBD')
})

it('does not forge a recipient for an op that has none', () => {
// Claim/cancel/profile act on the signer's own account. A "To:" row here
// would be a claim the operation does not make.
for (const op of [
['claim_reward_balance', { account: 'alice', reward_hive: '1.500 HIVE' }],
['limit_order_cancel', { owner: 'alice', orderid: 1 }],
['account_update2', { account: 'alice', posting_json_metadata: '{}' }],
] as Array<[string, any]>) {
const d = hiveConfirmDetails('hiveSignOperations', [op])
expect(d.to, `${op[0]} invented a To row`).toBeUndefined()
}
})

it('labels the counterparty by its real role', () => {
const del = hiveConfirmDetails('hiveSignOperations', [
['delegate_vesting_shares', { delegator: 'alice', delegatee: 'bob', vesting_shares: '1000.000000 VESTS' }],
])
expect(del.to).toBe('@bob')
expect(del.toLabel).toBe('Delegatee')

const vote = hiveConfirmDetails('hiveSignOperations', [
['vote', { voter: 'alice', author: 'bob', permlink: 'a-post', weight: 10000 }],
])
expect(vote.toLabel).toBe('Post')
expect(vote.to).toContain('@bob')
})

it('says "self" for a self power-up rather than showing an empty row', () => {
const d = hiveConfirmDetails('hiveSignOperations', [
['transfer_to_vesting', { from: 'alice', to: '', amount: '1.500 HIVE' }],
])
expect(d.to).toBe('self')
expect(d.value).toBe('1.500 HIVE')
})

it('shows both sides of a market order', () => {
const d = hiveConfirmDetails('hiveSignOperations', [
['limit_order_create', {
owner: 'alice', orderid: 1,
amount_to_sell: '1.500 HIVE', min_to_receive: '0.400 HBD',
fill_or_kill: false, expiration: 1700003600,
}],
])
expect(d.opLabel).toBe('Market Order')
expect(d.value).toBe('1.500 HIVE → 0.400 HBD')
})

it('never claims a single recipient for a batch', () => {
const d = hiveConfirmDetails('hiveSignOperations', [
['comment', { author: 'alice', permlink: 'p', title: 'T', body: 'b', json_metadata: '{}' }],
['comment_options', { author: 'alice', permlink: 'p', percent_hbd: 10000 }],
])
expect(d.opLabel).toBe('2 Hive operations')
expect(d.to).toBeUndefined()
expect(d.memo).toContain('Post / Comment')
expect(d.memo).toContain('Payout Options')
})

it('asserts nothing when the op array is missing or unparseable', () => {
// Better a bare dialog than a fabricated one — the OLED is still correct.
for (const bad of [undefined, null, [], 'nonsense', [{ not: 'an op' }]]) {
const d = hiveConfirmDetails('hiveSignOperations', bad)
expect(d.chain).toBe('Hive')
expect(d.opLabel).toBeUndefined()
expect(d.to).toBeUndefined()
expect(d.value).toBeUndefined()
}
})
})

describe('hiveMessagePreview', () => {
it('shows the login message a dApp is asking to sign', () => {
const msg = Buffer.from('{login: "bithighlander22"}', 'utf8')
expect(hiveMessagePreview(msg)).toBe('{login: "bithighlander22"}')
})

it('collapses whitespace and truncates rather than flooding the dialog', () => {
const long = Buffer.from('a'.repeat(200), 'utf8')
const out = hiveMessagePreview(long)
expect(out.length).toBeLessThanOrEqual(65)
expect(out.endsWith('…')).toBe(true)
expect(hiveMessagePreview(Buffer.from('a\n\n b', 'utf8'))).toBe('a b')
})

it('reports binary payloads as bytes instead of mojibake', () => {
// Keychain signs a serialized Buffer as raw bytes; those are not text.
const bin = Buffer.from([0xff, 0xfe, 0x00, 0x01])
expect(hiveMessagePreview(bin)).toBe('4 bytes (binary)')
})

it('reports VALID-UTF-8 control bytes as binary, not invisible text', () => {
// 00 01 02 03 is well-formed UTF-8, so a decode round-trip alone accepts
// it and the dialog would show an empty-looking message for a payload the
// device is being asked to sign.
expect(hiveMessagePreview(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe('4 bytes (binary)')
// Embedded NUL inside otherwise-printable text.
expect(hiveMessagePreview(Buffer.from([0x61, 0x62, 0x00, 0x63, 0x64]))).toBe('5 bytes (binary)')
})

it('reports bidi/zero-width controls as binary — they spoof what is read', () => {
// U+202E RIGHT-TO-LEFT OVERRIDE reorders the rendered string, so the text
// shown can differ from the bytes signed.
const rlo = Buffer.from('user‮exe.gnp', 'utf8')
expect(hiveMessagePreview(rlo)).toBe(`${rlo.length} bytes (binary)`)
// U+200B ZERO WIDTH SPACE — invisible, so "alice" and "al​ice" look identical.
const zwsp = Buffer.from('al​ice', 'utf8')
expect(hiveMessagePreview(zwsp)).toBe(`${zwsp.length} bytes (binary)`)
})

it('still accepts ordinary whitespace and non-ASCII text', () => {
expect(hiveMessagePreview(Buffer.from('line1\nline2\tend', 'utf8'))).toBe('line1 line2 end')
expect(hiveMessagePreview(Buffer.from('gm 🛹 skatehive', 'utf8'))).toBe('gm 🛹 skatehive')
})

it('reports whitespace-only payloads as binary rather than an empty dialog', () => {
expect(hiveMessagePreview(Buffer.from(' \n\t ', 'utf8'))).toBe('6 bytes (binary)')
})
})

describe('vote weight formatting', () => {
const pct = (weight: number) =>
hiveConfirmDetails('hiveSignOperations', [
['vote', { voter: 'alice', author: 'bob', permlink: 'p', weight }],
] as any).value

it('does not round fractional basis-point weights to a wrong percentage', () => {
// toFixed(0) reported these as 0%, 1% and -1% — misstating the vote.
expect(pct(1)).toBe('0.01%')
expect(pct(50)).toBe('0.5%')
expect(pct(-50)).toBe('-0.5%')
})

it('keeps whole percentages clean', () => {
expect(pct(10000)).toBe('100%')
expect(pct(-10000)).toBe('-100%')
expect(pct(0)).toBe('0%')
expect(pct(500)).toBe('5%')
})
})
173 changes: 173 additions & 0 deletions projects/keepkey-vault/src/bun/emulator-confirm-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,176 @@ export function evmConfirmDetails(operation: string, fallbackChain: string, para
memo: `data ${selector}${decoded ? '' : ' (unrecognized)'}`,
}
}

// ── Hive ────────────────────────────────────────────────────────────────
//
// /hive/sign-operations sends the device a pre-serialized Graphene blob, so
// unlike every other chain the confirm dialog has no params to read amounts
// out of — without this it showed "Hive Sign Operations / Chain: HIVE" and
// nothing else, for a tx that may carry up to four distinct operations. The
// caller passes the ORIGINAL op array (the same one it serialized) purely for
// display; nothing here feeds the serializer.
//
// Multi-screen ops (claim_reward_balance is 2 screens, limit_order_create 2,
// comment_options 2 + one per beneficiary) raise one prompt PER firmware
// ButtonRequest, so the same summary is shown on each press. That is intended:
// the OLED behind it is what changes, and it stays authoritative.

/** Human label per op — same wording the extension's approval card uses. */
const HIVE_OP_LABELS: Record<string, string> = {
vote: 'Vote',
comment: 'Post / Comment',
comment_options: 'Payout Options',
custom_json: 'Custom JSON',
transfer_to_vesting: 'Power Up',
withdraw_vesting: 'Power Down',
delegate_vesting_shares: 'Delegate HP',
transfer_to_savings: 'Savings Deposit',
transfer_from_savings: 'Savings Withdraw',
claim_reward_balance: 'Claim Rewards',
convert: 'Convert HBD',
account_update2: 'Update Profile',
limit_order_create: 'Market Order',
limit_order_cancel: 'Cancel Order',
}

/**
* The counterparty account an op acts on, and what to call it. Returns null
* when an op has no meaningful counterparty (claim, cancel, profile update) —
* better to show no "To:" row than to forge one from the signer's own name.
*/
function hiveCounterparty(name: string, p: any): { to: string; toLabel: string } | null {
const at = (v: any) => (typeof v === 'string' && v ? `@${v}` : null)
switch (name) {
case 'vote': {
const a = at(p?.author)
return a ? { to: `${a}/${p?.permlink ?? ''}`, toLabel: 'Post' } : null
}
case 'comment_options': {
const a = at(p?.author)
return a ? { to: `${a}/${p?.permlink ?? ''}`, toLabel: 'Post' } : null
}
case 'transfer_to_vesting': {
// to may be "" meaning self — say so rather than showing an empty row.
const a = at(p?.to)
return { to: a ?? 'self', toLabel: 'To' }
}
case 'transfer_to_savings':
case 'transfer_from_savings': {
const a = at(p?.to)
return a ? { to: a, toLabel: 'To' } : null
}
case 'delegate_vesting_shares': {
const a = at(p?.delegatee)
return a ? { to: a, toLabel: 'Delegatee' } : null
}
default:
return null
}
}

/** The amount an op moves, verbatim as serialized (already a Hive asset string). */
function hiveValue(name: string, p: any): string | undefined {
const asset = (v: any) => (typeof v === 'string' && v ? v : undefined)
switch (name) {
case 'transfer_to_vesting':
case 'transfer_to_savings':
case 'transfer_from_savings':
case 'convert':
return asset(p?.amount)
case 'withdraw_vesting':
case 'delegate_vesting_shares':
return asset(p?.vesting_shares)
case 'limit_order_create':
return asset(p?.amount_to_sell) && asset(p?.min_to_receive)
? `${p.amount_to_sell} → ${p.min_to_receive}`
: asset(p?.amount_to_sell)
case 'claim_reward_balance': {
// Only the non-zero rewards — a claim is typically 2 of 3, and printing
// "0.000 HIVE" alongside the real one buries it.
const parts = [p?.reward_hive, p?.reward_hbd, p?.reward_vests]
.filter((v: any) => typeof v === 'string' && v && !/^0(\.0+)? /.test(v))
return parts.length ? parts.join(' + ') : undefined
}
case 'vote': {
// Weight is basis points (-10000..10000), so 1bp = 0.01%. toFixed(0)
// would round a real 0.5% vote to "1%" and a 0.01% vote to "0%" —
// misreporting the value being signed. Keep up to two decimals and drop
// trailing zeros so whole percentages still read as "100%".
const w = Number(p?.weight)
if (!Number.isFinite(w)) return undefined
return `${Number((w / 100).toFixed(2))}%`
}
default:
return undefined
}
}

/**
* Build confirm details for a Hive operation batch.
*
* `ops` is the condenser-style [[name, params], …] array. A single op gets the
* full treatment (label, counterparty, amount); a batch names the count and
* lists the ops, since one dialog cannot honestly claim a single recipient for
* four operations.
*/
export function hiveConfirmDetails(operation: string, ops: any): EmulatorConfirmDetails {
const list: Array<[string, any]> = Array.isArray(ops)
? ops.filter((o: any) => Array.isArray(o) && typeof o[0] === 'string')
: []

if (list.length === 0) {
// Unparseable/absent — say nothing rather than guess. The OLED still shows
// the real thing; this dialog just adds no claim of its own.
return { operation, chain: 'Hive' }
}

if (list.length === 1) {
const [name, p] = list[0]
const party = hiveCounterparty(name, p)
return {
operation,
opLabel: HIVE_OP_LABELS[name] ?? name,
chain: 'Hive',
...(party ? { to: party.to, toLabel: party.toLabel } : {}),
value: hiveValue(name, p),
memo: name, // the raw op name, so an unlabeled op is still identifiable
}
}

return {
operation,
opLabel: `${list.length} Hive operations`,
chain: 'Hive',
memo: list.map(([name]) => HIVE_OP_LABELS[name] ?? name).join(' · '),
}
}

/**
* One-line preview of a Hive signBuffer payload for the confirm dialog.
*
* Keychain signBuffer is overwhelmingly dApp login, where the message names the
* site and account being logged into — the single fact worth showing. Binary
* payloads are reported as a byte count rather than rendered as mojibake.
*/
export function hiveMessagePreview(messageBytes: ArrayLike<number>): string {
const bytes = Buffer.from(Array.from(messageBytes))
const txt = bytes.toString('utf8')
const binary = `${bytes.length} bytes (binary)`
// Round-trip check: a lossy decode means it was not UTF-8 text.
if (!Buffer.from(txt, 'utf8').equals(bytes)) return binary
// A round-trip alone is not enough: 00 01 02 03 and embedded NULs are VALID
// UTF-8, so they survive it and would render as invisible "text". Bidi and
// other format controls are worse than invisible — they reorder what the
// user reads, so a payload could display as something other than what is
// signed. Anything carrying control or format characters is reported by
// byte count instead of rendered.
// \p{C} = control, format, surrogate, private-use, unassigned
// Tab/newline/CR are ordinary whitespace in a login message and are folded
// to spaces below, so they are dropped before the test rather than exempted
// inside it (a trailing \p{C} in one pattern would match them too).
if (/\p{C}/u.test(txt.replace(/[\t\n\r]/g, ''))) return binary
const oneLine = txt.replace(/\s+/g, ' ').trim()
if (!oneLine) return binary
return oneLine.length > 64 ? `${oneLine.slice(0, 64)}…` : oneLine
}
6 changes: 4 additions & 2 deletions projects/keepkey-vault/src/bun/rest-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2179,10 +2179,11 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1
// Default to the posting role — Keychain signBuffer is overwhelmingly
// dApp login, which verifies against the account's posting authority.
const addressNList = body.addressNList || body.address_n || hiveRolePath('posting', 0)
const { hiveMessagePreview } = await import('./emulator-confirm-details')
const result = await emuWrap(() => (wallet as any).hiveSignMessage({
addressNList,
message: new Uint8Array(messageBytes),
}), { operation: 'hiveSignMessage', chain: 'HIVE' })
}), { operation: 'hiveSignMessage', opLabel: 'Sign Message', chain: 'Hive', memo: hiveMessagePreview(messageBytes) })
if (!result?.signature) throw new HttpError(500, 'Hive sign-message: device returned no signature')
const sigBytes = result.signature instanceof Uint8Array ? Buffer.from(result.signature) : Buffer.from(String(result.signature), 'hex')
const pubBytes = result.publicKey instanceof Uint8Array ? Buffer.from(result.publicKey) : Buffer.from(String(result.publicKey), 'hex')
Expand Down Expand Up @@ -2237,11 +2238,12 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1

// Role from op tier unless the caller pinned a path
const addressNList = body.addressNList || body.address_n || hiveRolePath(serialized.tier, 0)
const { hiveConfirmDetails } = await import('./emulator-confirm-details')
const result: any = await emuWrap(() => (wallet as any).hiveSignOperations({
addressNList,
chainId: txParams.chainId,
serializedTx: new Uint8Array(serialized.serializedTx),
}), { operation: 'hiveSignOperations', chain: 'HIVE' })
}), hiveConfirmDetails('hiveSignOperations', body.operations))
if (!result?.signature) throw new HttpError(500, 'Hive sign-operations: device returned no signature')
const sigBytes = result.signature instanceof Uint8Array ? Buffer.from(result.signature) : Buffer.from(String(result.signature), 'hex')
// Return the expiration derived from the SIGNED expirationUnix (the value
Expand Down