Skip to content

fix(hive): render op summaries in the approval card#131

Merged
BitHighlander merged 2 commits into
developfrom
fix/hive-approval-op-summary
Jul 20, 2026
Merged

fix(hive): render op summaries in the approval card#131
BitHighlander merged 2 commits into
developfrom
fix/hive-approval-op-summary

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

What was broken

hiveHandler builds a one-line summary per operation and stashes it in unsignedTx.operations (hiveHandler.ts:641). No component ever read it — grepping pages/side-panel/src/approval/ for .operations returns zero hits.

So a Hive op batch fell through to the generic amount table, which has no destination and no amount to show for a batch:

before after
To N/A (row gone)
Amount 0 (row gone)
Account @alice
limit_order_create Sell 1.500 HIVE for 0.400 HBD
limit_order_cancel Cancel order 1 (@alice)

The summaries did exist in the UI — inside RequestDataCard's collapsed raw-JSON dump.

Why it matters

Four ops in SUPPORTED_OPS are passthrough-only: claim_reward_balance, account_update2, limit_order_create, limit_order_cancel. Keychain has no dedicated request method for them, so a dApp composes them and sends them through requestBroadcast — the panel was the user's only look 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.

The move to hiveOps.ts

SUPPORTED_OPS and opSummary are now a leaf module. hiveHandler.ts imports @extension/storage, which touches chrome.* at import time and throws chrome is not defined under vitest — the table was untestable where it lived. Same pattern as the feeFloors / lastResortRpcs leaf modules already in chains/. No logic changed in the move.

Tests

hiveOpSummary.test.ts pins the contract the card now depends on: every op in SUPPORTED_OPS must summarize to something other than its own bare name (opSummary's default arm returns name — precisely the unreadable render this fixes) and must carry a test payload, so adding an op without a summary fails the build.

It also pins what a user has to check against the OLED: amounts with symbols, the counterparty, the '0.000000 VESTS' sentinels that mean stop / remove rather than "send zero", and comment_options beneficiary names + percentages — a payout redirect being 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 pass, 11 files (up from 116). make type-check clean across all 15 packages; make build clean.

Not in scope

Host-side constraint validation (max-4 ops, single-tier, beneficiary sort, fill_or_kill boolean) stays absent client-side on purpose — the vault enforces all of it with a readable 400 before the device is touched, per docs/handoff-bex-hive-full-operations.md §4. Duplicating it here is two places to drift.

🤖 Generated with Claude Code

BitHighlander and others added 2 commits July 20, 2026 17:44
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@BitHighlander

Copy link
Copy Markdown
Collaborator Author

All three addressed in a7a44b1. Thanks — #1 and #2 were real divergences between the approval and the signed bytes, which is the one bug class this PR exists to close.

1. custom_json approval vs signed payload — fixed

Mirrored the vault's own expression rather than approximating it:

function serializedJson(json: any): string {
  return typeof json === 'string' ? json : JSON.stringify(json ?? {});
}

Byte-identical to hive-ops.ts:151. Truncation now carries the dropped length, so two payloads sharing a 120-char prefix no longer render alike:

test: xxx…(120)… (+4 more chars)
test: xxx…(120)… (+8 more chars)

Tests added for object payloads, string payloads, the truncation marker, differing suffixes, and the no-marker-when-short case.

2. comment_options payout controls — fixed, both halves

Every control that differs from the Hive default is now named; defaults stay quiet so the common case remains a one-liner:

Payout options for @alice/a-post · max payout 0.000 HBD · 0.0% HBD · votes disabled

Also caught while implementing: percent_steem_dollars is a legacy alias the vault falls back to at hive-ops.ts:225. A summary reading only percent_hbd would have shown the default while a non-default value was signed — same bug class, so it's read and tested.

On recoverability — you're right that {op, summary} made the raw view unable to recover these. unsignedTx.operations now carries params verbatim. Worth recording why this is load-bearing rather than a duplicate copy: RequestDataCard renders transaction.unsignedTx and nothing else, and the event's own request is never displayed in any tab. params is the only surface on which the operation body appears at all.

3. Default tab — fixed, and the finding was stronger than stated

defaultIndex={0}. The situation was worse than "starts collapsed": RequestDataCard is useState(false) with the JSON inside {isOpen && (…)}, so the old landing view was a chevron and the word "Data" — zero transaction facts on any chain, until a click.

Since this changes the default for all five chains routed to OtherTransaction, I checked each rather than assuming Hive's win generalized:

Chain Basic landing view vs. before
Hive (ops) Account + one row per op Improvement
Ripple destination / amount / destinationTag Improvement
Tron contract-call dedicated contract/function branch Improvement
Solana, TON N/A Wash — old view was empty

No chain regresses; cost is one click to Raw for the wash row.

Verification

125 tests (was 121); type-check, prettier, build clean.

Differential-verified both P1 fixes — reverting each fails loudly:

expected 'follow: [object Object]' not to contain '[object Object]'
expected 'Payout options for @alice/a-post' to contain '0.000 HBD'

I also ran an adversarial audit over the fixes (5 lenses × per-finding refutation, 35 candidates). None survived, but two pre-existing defects it surfaced are worth separate issues, both out of scope here:

  • Solana/TON transfer events never populate unsignedTx.paymentsolanaHandler.ts:937 / tonHandler.ts:444 stash to/amount at the top level while RequestDetailsCard reads payment?.*, so those approvals show N/A. This is why those two chains are a wash above rather than an improvement.
  • isSignMessage is gated on the two solana_* types only, so Hive signBuffer, TON and Tron sign-message events fall through to the To/Amount table. Note for whoever picks it up: don't just widen the predicate — TON puts plain text in unsignedTx.message where Solana puts hex, so the existing fallback would hex-dump readable text and warn "not printable UTF-8" falsely.

Not touched here: host-side constraint validation (max-4, single-tier, beneficiary sort, fill_or_kill boolean). The vault rejects all of those with a 400 before wallet.hiveSignOperations is called, so it costs an error message rather than a device interaction — and the 2048-byte cap is on serialized Graphene bytes the extension never produces, so it can't be checked here without reimplementing the serializer.

@BitHighlander
BitHighlander merged commit e2a0e57 into develop Jul 20, 2026
6 checks passed
@BitHighlander
BitHighlander deleted the fix/hive-approval-op-summary branch July 20, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant