From 590b316faf06eb2784a3cd154c2e4a0d356d5bde Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 17:44:09 -0300 Subject: [PATCH 1/2] fix(hive): render op summaries in the approval card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hiveHandler built a one-line summary per operation (opSummary) into unsignedTx.operations and no component ever read it. Grepping pages/side-panel/src/approval for `.operations` returned zero hits, so a Hive batch fell through to the generic amount table, which has no destination or amount to show for an op batch and rendered "N/A" and "0". The summaries surfaced only inside RequestDataCard's collapsed raw-JSON dump. That matters most for the four ops the extension does not construct itself — claim_reward_balance, account_update2, limit_order_create and limit_order_cancel reach the device via requestBroadcast, so a dApp composes them and the panel was the user's only look at them before the device screen. The OLED is authoritative and was always correct, so this was never a signing hole; but the clear-sign table exists so the user can compare the screen against what the dApp asked for, and half of that comparison was an unreadable blob. RequestDetailsCard now renders the account plus one row per op, falling back to the op name rather than blanking a row — an unsummarized op must stay visible, not disappear. SUPPORTED_OPS and opSummary move to hiveOps.ts, a leaf module. hiveHandler.ts imports @extension/storage, which touches chrome.* at import time and throws under vitest ("chrome is not defined"), so the table was untestable where it lived. No logic changed in the move. hiveOpSummary.test.ts pins the contract the card depends on: every op in SUPPORTED_OPS must summarize to something other than its own bare name (opSummary's default arm returns `name`, which is exactly the unreadable render this fixes) and must carry a test payload. It also pins the details a user has to check against the OLED — amounts with their symbols, the counterparty account, the '0.000000 VESTS' sentinels that mean stop/remove rather than "send zero", and comment_options beneficiary names and percentages, since a payout redirect is the one thing in the table an attacker would most want unreadable. Differential-verified: deleting the limit_order_create case fails with "limit_order_create falls through to the default arm — add a case to opSummary()". 121 tests pass, up from 116. type-check clean across all 15 packages. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/background/chains/hiveHandler.ts | 66 +------------ .../background/chains/hiveOpSummary.test.ts | 95 +++++++++++++++++++ .../src/background/chains/hiveOps.ts | 73 ++++++++++++++ .../src/approval/other/RequestDetailsCard.tsx | 44 +++++++++ 4 files changed, 213 insertions(+), 65 deletions(-) create mode 100644 chrome-extension/src/background/chains/hiveOpSummary.test.ts create mode 100644 chrome-extension/src/background/chains/hiveOps.ts diff --git a/chrome-extension/src/background/chains/hiveHandler.ts b/chrome-extension/src/background/chains/hiveHandler.ts index 4fab254..b0c9b81 100644 --- a/chrome-extension/src/background/chains/hiveHandler.ts +++ b/chrome-extension/src/background/chains/hiveHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as wallet from '../wallet'; import { createProviderRpcError, createTimeoutError } from '../utils'; import { requireHiveFirmware } from '../firmware'; +import { SUPPORTED_OPS, opSummary } from './hiveOps'; const TAG = ' | hiveHandler | '; @@ -563,27 +564,6 @@ async function hiveSignBuffer( return { result: signature, publicKey: public_key }; } -// Firmware clear-sign op table — phase 1 + phase 2 -// (handoff-hive-sign-operations-phase2.md). The vault serializer and the -// firmware both re-enforce this; the check here just fails fast with a -// clear dApp-facing error. -const SUPPORTED_OPS = new Set([ - 'vote', - 'comment', - 'custom_json', - 'transfer_to_vesting', - 'withdraw_vesting', - 'convert', - 'comment_options', - 'transfer_to_savings', - 'transfer_from_savings', - 'claim_reward_balance', - 'delegate_vesting_shares', - 'account_update2', - 'limit_order_create', - 'limit_order_cancel', -]); - /** Strict "x.xxx" normalization — same no-parseFloat rule as hiveTransfer. */ function normalizeAmount3(amount: any, what: string): string { if (typeof amount !== 'string' || !/^\d+(\.\d{1,3})?$/.test(amount)) { @@ -624,50 +604,6 @@ async function hpToVests(hp3: string): Promise { // conversions/withdrawals; per-account id tracking if a dApp ever collides. const epochRequestId = () => Math.floor(Date.now() / 1000); -/** One-line device-preview summary per op for the side-panel approval. */ -function opSummary(name: string, p: Record): string { - switch (name) { - case 'vote': - return `@${p.voter} → @${p.author}/${p.permlink} (${(Number(p.weight) / 100).toFixed(0)}%)`; - case 'comment': - return `@${p.author}: ${p.title || p.permlink}`; - case 'custom_json': - return `${p.id}: ${String(p.json).slice(0, 120)}`; - case 'transfer_to_vesting': - return `Power up ${p.amount} → @${p.to}`; - case 'withdraw_vesting': - return String(p.vesting_shares).startsWith('0.000000') - ? `Stop power down (@${p.account})` - : `Power down ${p.vesting_shares} from @${p.account}`; - case 'convert': - return `Convert ${p.amount} → HIVE (request ${p.requestid})`; - case 'comment_options': - return `Payout options for @${p.author}/${p.permlink}${ - (p.extensions?.[0]?.[1]?.beneficiaries ?? []) - .map((b: any) => ` · ${(Number(b.weight) / 100).toFixed(1)}% → @${b.account}`) - .join('') || '' - }`; - case 'transfer_to_savings': - return `Savings deposit ${p.amount} → @${p.to}`; - case 'transfer_from_savings': - return `Savings withdraw ${p.amount} → @${p.to}`; - case 'claim_reward_balance': - return `Claim ${p.reward_hive}, ${p.reward_hbd}, ${p.reward_vests}`; - case 'delegate_vesting_shares': - return String(p.vesting_shares).startsWith('0.000000') - ? `Remove delegation from @${p.delegatee}` - : `Delegate ${p.vesting_shares} → @${p.delegatee}`; - case 'account_update2': - return `Update profile @${p.account}`; - case 'limit_order_create': - return `Sell ${p.amount_to_sell} for ${p.min_to_receive}${p.fill_or_kill ? ' (fill or kill)' : ''}`; - case 'limit_order_cancel': - return `Cancel order ${p.orderid} (@${p.owner})`; - default: - return name; - } -} - /** * Shared path for vote/post/custom_json/broadcast: validate ops against the * firmware's phase-1 clear-sign table, approve, sign via the vault diff --git a/chrome-extension/src/background/chains/hiveOpSummary.test.ts b/chrome-extension/src/background/chains/hiveOpSummary.test.ts new file mode 100644 index 0000000..3248659 --- /dev/null +++ b/chrome-extension/src/background/chains/hiveOpSummary.test.ts @@ -0,0 +1,95 @@ +/** + * Every clear-signable Hive op must render a readable summary. + * + * The side-panel approval card shows `unsignedTx.operations[].summary` and + * nothing else — a Hive tx has no single destination or amount to fall back + * on. opSummary()'s `default` arm returns the bare op name, so an op added to + * SUPPORTED_OPS without a matching `case` degrades the approval to + * "limit_order_create" with no values in it, silently. That is the exact + * unreadable-confirm this pairs with RequestDetailsCard to prevent. + */ +import { describe, it, expect } from 'vitest'; +import { SUPPORTED_OPS, opSummary } from './hiveOps'; + +// One realistic payload per op, in the vault serializer's field names. +const SAMPLES: Record> = { + vote: { voter: 'alice', author: 'bob', permlink: 'a-post', weight: 10000 }, + comment: { author: 'alice', permlink: 'a-post', title: 'Hello', body: 'hi', json_metadata: '{}' }, + custom_json: { required_auths: [], required_posting_auths: ['alice'], id: 'follow', json: '["follow",{}]' }, + transfer_to_vesting: { from: 'alice', to: 'alice', amount: '1.500 HIVE' }, + withdraw_vesting: { account: 'alice', vesting_shares: '1000.000000 VESTS' }, + 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, + }, + limit_order_cancel: { owner: 'alice', orderid: 1 }, + convert: { owner: 'alice', requestid: 1, amount: '0.400 HBD' }, + comment_options: { + author: 'alice', + permlink: 'a-post', + max_accepted_payout: '1000000.000 HBD', + percent_hbd: 10000, + allow_votes: true, + allow_curation_rewards: true, + extensions: [], + }, + transfer_to_savings: { from: 'alice', to: 'bob', amount: '1.500 HIVE', memo: '' }, + transfer_from_savings: { from: 'alice', request_id: 1, to: 'bob', amount: '1.500 HIVE', memo: '' }, + claim_reward_balance: { + account: 'alice', + reward_hive: '1.500 HIVE', + reward_hbd: '0.400 HBD', + reward_vests: '1000.000000 VESTS', + }, + delegate_vesting_shares: { delegator: 'alice', delegatee: 'bob', vesting_shares: '1000.000000 VESTS' }, + account_update2: { account: 'alice', json_metadata: '', posting_json_metadata: '{}' }, +}; + +describe('Hive op summaries', () => { + it('has a sample payload for every supported op', () => { + const missing = [...SUPPORTED_OPS].filter(op => !(op in SAMPLES)); + expect(missing, `No test payload for: ${missing.join(', ')}`).toEqual([]); + }); + + it('summarizes every supported op with more than its own name', () => { + for (const op of SUPPORTED_OPS) { + const summary = opSummary(op, SAMPLES[op]); + // The `default` arm returns `name` verbatim. Anything equal to the op + // name means no case matched and the approval would render unreadably. + expect(summary, `${op} falls through to the default arm — add a case to opSummary()`).not.toBe(op); + expect(summary.length, `${op} summary is empty`).toBeGreaterThan(0); + } + }); + + it('renders the values a user must check against the device screen', () => { + // Amount + counterparty are what the OLED shows; the panel has to show the + // same thing or the comparison the clear-sign table exists for is impossible. + expect(opSummary('limit_order_create', SAMPLES.limit_order_create)).toContain('1.500 HIVE'); + expect(opSummary('limit_order_create', SAMPLES.limit_order_create)).toContain('0.400 HBD'); + expect(opSummary('transfer_to_savings', SAMPLES.transfer_to_savings)).toContain('bob'); + expect(opSummary('claim_reward_balance', SAMPLES.claim_reward_balance)).toContain('1000.000000 VESTS'); + }); + + it('distinguishes the zero-amount sentinels from real amounts', () => { + // '0.000000 VESTS' means stop/remove, not "send zero" — a user approving + // these must not see the same wording as an actual power-down. + expect(opSummary('withdraw_vesting', { account: 'alice', vesting_shares: '0.000000 VESTS' })).toMatch(/stop/i); + expect( + opSummary('delegate_vesting_shares', { delegator: 'alice', delegatee: 'bob', vesting_shares: '0.000000 VESTS' }), + ).toMatch(/remove/i); + }); + + it('names the beneficiaries a comment_options redirects payout to', () => { + const withBenes = { + ...SAMPLES.comment_options, + extensions: [[0, { beneficiaries: [{ account: 'carol', weight: 2500 }] }]], + }; + const summary = opSummary('comment_options', withBenes); + expect(summary).toContain('carol'); + expect(summary).toContain('25.0%'); + }); +}); diff --git a/chrome-extension/src/background/chains/hiveOps.ts b/chrome-extension/src/background/chains/hiveOps.ts new file mode 100644 index 0000000..da6a086 --- /dev/null +++ b/chrome-extension/src/background/chains/hiveOps.ts @@ -0,0 +1,73 @@ +/** + * The Hive clear-sign op table and its approval-card summaries. + * + * A leaf module on purpose: hiveHandler.ts imports @extension/storage, which + * touches chrome.* at import time and so cannot load under vitest. Keeping the + * table and the pure formatter here is what lets hiveOpSummary.test.ts import + * them at all. + */ + +// Firmware clear-sign op table — phase 1 + phase 2 +// (handoff-hive-sign-operations-phase2.md). The vault serializer and the +// firmware both re-enforce this; the check here just fails fast with a +// clear dApp-facing error. +export const SUPPORTED_OPS = new Set([ + 'vote', + 'comment', + 'custom_json', + 'transfer_to_vesting', + 'withdraw_vesting', + 'convert', + 'comment_options', + 'transfer_to_savings', + 'transfer_from_savings', + 'claim_reward_balance', + 'delegate_vesting_shares', + 'account_update2', + 'limit_order_create', + 'limit_order_cancel', +]); + +/** One-line device-preview summary per op for the side-panel approval. */ +export function opSummary(name: string, p: Record): string { + switch (name) { + case 'vote': + return `@${p.voter} → @${p.author}/${p.permlink} (${(Number(p.weight) / 100).toFixed(0)}%)`; + case 'comment': + return `@${p.author}: ${p.title || p.permlink}`; + case 'custom_json': + return `${p.id}: ${String(p.json).slice(0, 120)}`; + case 'transfer_to_vesting': + return `Power up ${p.amount} → @${p.to}`; + case 'withdraw_vesting': + return String(p.vesting_shares).startsWith('0.000000') + ? `Stop power down (@${p.account})` + : `Power down ${p.vesting_shares} from @${p.account}`; + case 'convert': + return `Convert ${p.amount} → HIVE (request ${p.requestid})`; + case 'comment_options': + return `Payout options for @${p.author}/${p.permlink}${ + (p.extensions?.[0]?.[1]?.beneficiaries ?? []) + .map((b: any) => ` · ${(Number(b.weight) / 100).toFixed(1)}% → @${b.account}`) + .join('') || '' + }`; + case 'transfer_to_savings': + return `Savings deposit ${p.amount} → @${p.to}`; + case 'transfer_from_savings': + return `Savings withdraw ${p.amount} → @${p.to}`; + case 'claim_reward_balance': + return `Claim ${p.reward_hive}, ${p.reward_hbd}, ${p.reward_vests}`; + case 'delegate_vesting_shares': + return String(p.vesting_shares).startsWith('0.000000') + ? `Remove delegation from @${p.delegatee}` + : `Delegate ${p.vesting_shares} → @${p.delegatee}`; + case 'account_update2': + return `Update profile @${p.account}`; + case 'limit_order_create': + return `Sell ${p.amount_to_sell} for ${p.min_to_receive}${p.fill_or_kill ? ' (fill or kill)' : ''}`; + case 'limit_order_cancel': + return `Cancel order ${p.orderid} (@${p.owner})`; + default: + return name; + } +} diff --git a/pages/side-panel/src/approval/other/RequestDetailsCard.tsx b/pages/side-panel/src/approval/other/RequestDetailsCard.tsx index 9193989..964bd7b 100644 --- a/pages/side-panel/src/approval/other/RequestDetailsCard.tsx +++ b/pages/side-panel/src/approval/other/RequestDetailsCard.tsx @@ -278,6 +278,50 @@ export default function RequestDetailsCard({ transaction }: any) { ); } + // Hive operation batches have no single destination or amount — a tx can + // carry up to four ops of different shapes. hiveHandler stashes a rendered + // one-liner per op (opSummary); show those instead of the amount table, + // which would render "N/A" and "0" for every one of them. + // + // This is the user's half of the clear-sign check: the device OLED is + // authoritative, and they can only compare it against something readable. + const operations: Array<{ op?: string; summary?: string }> | undefined = unsignedTx?.operations; + if (Array.isArray(operations) && operations.length > 0) { + return ( +
+ + + + + {unsignedTx?.from && ( + + + + + )} + {operations.map((o, i) => ( + + + {/* Fall back to the op name rather than blanking the row: + an unsummarized op must still be visible, not absent. */} + + + ))} + +
+ Account: + @{unsignedTx.from}
+ {o.op || 'operation'} + + {o.summary || o.op || 'N/A'} +
+
+ +
+
+ ); + } + return (
From a7a44b131b51dc4ec12f8d75a6a4bcd9c82636dc Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 18:07:58 -0300 Subject: [PATCH 2/2] fix(hive): show the signed payload, not a lossy render of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #131 found three ways the approval could differ from what the device signs. custom_json rendered String(p.json). The vault serializes `typeof json === 'string' ? json : JSON.stringify(json ?? {})` (hive-ops.ts:151), so a dApp passing an object got "[object Object]" in the panel while the object's real contents went to the device — the approval showed neither the value nor that it was hiding one. Mirror the vault's own expression instead. Truncation at 120 chars was unmarked, so two payloads sharing a prefix rendered as the same string. The cut now carries the dropped length. comment_options showed only the post and its beneficiaries, omitting max_accepted_payout, percent_hbd, allow_votes and allow_curation_rewards. A declined payout, an all-HIVE split and a votes-disabled post therefore had identical browser approvals. Every control that differs from the Hive default is now named; defaults stay quiet so the common case remains a one-liner. percent_steem_dollars is read as the alias the vault also accepts (hive-ops.ts:225) — ignoring it would have shown the default while a non-default value was signed. unsignedTx.operations kept only {op, summary}, so the Raw tab could not recover what the summary elides. It now carries `params` verbatim. RequestDataCard renders transaction.unsignedTx and nothing else — the event's own `request` is never displayed in any tab — so this is the only surface on which the operation body appears at all. The approval also opened on the Raw tab (defaultIndex={1}), whose data section is useState(false) and so renders a collapsed chevron with no transaction facts on it. A user could approve having seen nothing. Basic is now the default. This affects all five chains routed to OtherTransaction (ripple, solana, ton, tron, hive), and is an improvement or a wash for each: Hive, Ripple and Tron contract-calls gain a populated table, and the chains that render N/A in Basic were previously landing on an empty panel anyway, so the cost is one click to reach Raw and no fact became less visible. Differential-verified: restoring String(p.json) fails with "expected 'follow: [object Object]' not to contain '[object Object]'"; dropping the payout controls fails with "expected 'Payout options for @alice/a-post' to contain '0.000 HBD'". 125 tests pass, up from 121. type-check, prettier and build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/background/chains/hiveHandler.ts | 6 +- .../background/chains/hiveOpSummary.test.ts | 66 ++++++++++++++++++- .../src/background/chains/hiveOps.ts | 56 ++++++++++++++-- pages/side-panel/src/approval/other/index.tsx | 4 +- 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/chrome-extension/src/background/chains/hiveHandler.ts b/chrome-extension/src/background/chains/hiveHandler.ts index b0c9b81..b0b9203 100644 --- a/chrome-extension/src/background/chains/hiveHandler.ts +++ b/chrome-extension/src/background/chains/hiveHandler.ts @@ -636,7 +636,11 @@ async function hiveSignAndBroadcastOps( const event = buildEvent(requestInfo, displayType, params); (event as any).unsignedTx = { from: from.name, - operations: operations.map(([name, p]) => ({ op: name, summary: opSummary(name, p) })), + // `params` verbatim so the Raw tab remains a complete record: the summary + // is a one-liner and necessarily elides (long custom_json, default payout + // controls), and a value no view can recover is a value the user cannot + // check against the device screen. + operations: operations.map(([name, p]) => ({ op: name, summary: opSummary(name, p), params: p })), }; await requestUserApproval(event, requestInfo, displayType, params, requireApproval); diff --git a/chrome-extension/src/background/chains/hiveOpSummary.test.ts b/chrome-extension/src/background/chains/hiveOpSummary.test.ts index 3248659..e182c51 100644 --- a/chrome-extension/src/background/chains/hiveOpSummary.test.ts +++ b/chrome-extension/src/background/chains/hiveOpSummary.test.ts @@ -16,7 +16,8 @@ const SAMPLES: Record> = { vote: { voter: 'alice', author: 'bob', permlink: 'a-post', weight: 10000 }, comment: { author: 'alice', permlink: 'a-post', title: 'Hello', body: 'hi', json_metadata: '{}' }, custom_json: { required_auths: [], required_posting_auths: ['alice'], id: 'follow', json: '["follow",{}]' }, - transfer_to_vesting: { from: 'alice', to: 'alice', amount: '1.500 HIVE' }, + // to !== from on purpose: a self-power-up would hide a from/to swap. + transfer_to_vesting: { from: 'alice', to: 'bob', amount: '1.500 HIVE' }, withdraw_vesting: { account: 'alice', vesting_shares: '1000.000000 VESTS' }, limit_order_create: { owner: 'alice', @@ -71,6 +72,10 @@ describe('Hive op summaries', () => { expect(opSummary('limit_order_create', SAMPLES.limit_order_create)).toContain('1.500 HIVE'); expect(opSummary('limit_order_create', SAMPLES.limit_order_create)).toContain('0.400 HBD'); expect(opSummary('transfer_to_savings', SAMPLES.transfer_to_savings)).toContain('bob'); + // Recipient, not sender — the vault serializes str(from), str(to) and a + // swapped pair would still render plausibly. + expect(opSummary('transfer_to_vesting', SAMPLES.transfer_to_vesting)).toContain('@bob'); + expect(opSummary('transfer_to_vesting', SAMPLES.transfer_to_vesting)).toContain('1.500 HIVE'); expect(opSummary('claim_reward_balance', SAMPLES.claim_reward_balance)).toContain('1000.000000 VESTS'); }); @@ -83,6 +88,65 @@ describe('Hive op summaries', () => { ).toMatch(/remove/i); }); + it('shows the JSON a custom_json actually signs, object or string', () => { + // The vault serializes `typeof json === 'string' ? json : JSON.stringify(json)` + // (hive-ops.ts:151). String(obj) would render "[object Object]" while the + // object's real contents get signed — approval showing neither. + const asObject = opSummary('custom_json', { id: 'follow', json: { follow: 'bob' } }); + expect(asObject).not.toContain('[object Object]'); + expect(asObject).toContain('"follow":"bob"'); + + const asString = opSummary('custom_json', { id: 'follow', json: '["follow",{"a":1}]' }); + expect(asString).toContain('["follow",{"a":1}]'); + }); + + it('marks a truncated custom_json so two payloads cannot look identical', () => { + const prefix = 'x'.repeat(120); + const a = opSummary('custom_json', { id: 'test', json: prefix + 'AAAA' }); + const b = opSummary('custom_json', { id: 'test', json: prefix + 'BBBBBBBB' }); + expect(a).toContain('…'); + expect(a).not.toBe(b); + expect(a).toContain('+4 more chars'); + expect(b).toContain('+8 more chars'); + // Short payloads must not be marked at all. + expect(opSummary('custom_json', { id: 'test', json: '{"a":1}' })).not.toContain('…'); + }); + + it('surfaces every non-default comment_options payout control', () => { + // Defaults stay quiet — a default-everything comment_options is just the post. + expect(opSummary('comment_options', SAMPLES.comment_options)).toBe('Payout options for @alice/a-post'); + + const declined = opSummary('comment_options', { ...SAMPLES.comment_options, max_accepted_payout: '0.000 HBD' }); + expect(declined).toContain('0.000 HBD'); + + const allHive = opSummary('comment_options', { ...SAMPLES.comment_options, percent_hbd: 0 }); + expect(allHive).toContain('0.0% HBD'); + + const noVotes = opSummary('comment_options', { ...SAMPLES.comment_options, allow_votes: false }); + expect(noVotes).toMatch(/votes disabled/i); + + const noCuration = opSummary('comment_options', { + ...SAMPLES.comment_options, + allow_curation_rewards: false, + }); + expect(noCuration).toMatch(/curation rewards disabled/i); + + // Materially different payout behaviour must not render identically. + expect(declined).not.toBe(allHive); + expect(allHive).not.toBe(noVotes); + }); + + it('reads percent_steem_dollars, the legacy alias the vault also accepts', () => { + // hive-ops.ts:225 falls back to it; a summary that ignored it would show + // the default while a non-default value was signed. + const legacy = opSummary('comment_options', { + ...SAMPLES.comment_options, + percent_hbd: undefined, + percent_steem_dollars: 0, + }); + expect(legacy).toContain('0.0% HBD'); + }); + it('names the beneficiaries a comment_options redirects payout to', () => { const withBenes = { ...SAMPLES.comment_options, diff --git a/chrome-extension/src/background/chains/hiveOps.ts b/chrome-extension/src/background/chains/hiveOps.ts index da6a086..7f1f9fb 100644 --- a/chrome-extension/src/background/chains/hiveOps.ts +++ b/chrome-extension/src/background/chains/hiveOps.ts @@ -28,6 +28,33 @@ export const SUPPORTED_OPS = new Set([ 'limit_order_cancel', ]); +/** Hive's own defaults for comment_options — anything else is worth showing. */ +const DEFAULT_MAX_PAYOUT = '1000000.000 HBD'; +const DEFAULT_PERCENT_HBD = 10000; + +/** + * Cap a value for one-line display, always marking the cut. + * + * Silent truncation renders two different payloads sharing a 120-char prefix + * as the same string, so the marker carries the dropped length. The full value + * is in the Raw tab (hiveHandler stashes `params` verbatim) — this is the + * one-line view, not the record. + */ +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max)}… (+${s.length - max} more chars)`; +} + +/** + * The exact JSON the vault will serialize — mirrors hive-ops.ts:151. + * + * A dApp may pass `json` as an object; `String(obj)` yields "[object Object]" + * while the object's actual contents are what gets signed. The approval must + * show the signed bytes, not a placeholder for them. + */ +function serializedJson(json: any): string { + return typeof json === 'string' ? json : JSON.stringify(json ?? {}); +} + /** One-line device-preview summary per op for the side-panel approval. */ export function opSummary(name: string, p: Record): string { switch (name) { @@ -36,7 +63,7 @@ export function opSummary(name: string, p: Record): string { case 'comment': return `@${p.author}: ${p.title || p.permlink}`; case 'custom_json': - return `${p.id}: ${String(p.json).slice(0, 120)}`; + return `${p.id}: ${truncate(serializedJson(p.json), 120)}`; case 'transfer_to_vesting': return `Power up ${p.amount} → @${p.to}`; case 'withdraw_vesting': @@ -45,12 +72,29 @@ export function opSummary(name: string, p: Record): string { : `Power down ${p.vesting_shares} from @${p.account}`; case 'convert': return `Convert ${p.amount} → HIVE (request ${p.requestid})`; - case 'comment_options': + case 'comment_options': { + // Every payout control the device screen shows, whenever it differs from + // the Hive default. Showing only the post + beneficiaries made two + // comment_options with materially different payout behaviour — capped + // payout, all-HIVE split, votes or curation disabled — render + // identically in the browser. + const percentHbd = Number(p.percent_hbd ?? p.percent_steem_dollars); + const controls: string[] = []; + if (p.max_accepted_payout != null && p.max_accepted_payout !== DEFAULT_MAX_PAYOUT) { + controls.push(`max payout ${p.max_accepted_payout}`); + } + if (Number.isFinite(percentHbd) && percentHbd !== DEFAULT_PERCENT_HBD) { + controls.push(`${(percentHbd / 100).toFixed(1)}% HBD`); + } + if (p.allow_votes === false) controls.push('votes disabled'); + if (p.allow_curation_rewards === false) controls.push('curation rewards disabled'); + const beneficiaries = (p.extensions?.[0]?.[1]?.beneficiaries ?? []) + .map((b: any) => ` · ${(Number(b.weight) / 100).toFixed(1)}% → @${b.account}`) + .join(''); return `Payout options for @${p.author}/${p.permlink}${ - (p.extensions?.[0]?.[1]?.beneficiaries ?? []) - .map((b: any) => ` · ${(Number(b.weight) / 100).toFixed(1)}% → @${b.account}`) - .join('') || '' - }`; + controls.length ? ` · ${controls.join(' · ')}` : '' + }${beneficiaries}`; + } case 'transfer_to_savings': return `Savings deposit ${p.amount} → @${p.to}`; case 'transfer_from_savings': diff --git a/pages/side-panel/src/approval/other/index.tsx b/pages/side-panel/src/approval/other/index.tsx index cbb8e49..92a8e7c 100644 --- a/pages/side-panel/src/approval/other/index.tsx +++ b/pages/side-panel/src/approval/other/index.tsx @@ -65,7 +65,9 @@ export function OtherTransaction({ transaction: initialTransaction, handleRespon - + {/* Basic first: Raw opens to a collapsed data section, so defaulting + to it let a user approve without ever seeing the rendered details. */} + Basic {/*Fees*/}