From e19bb9f8b61e56beedd3dbd0540681b38410015d Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 18:39:56 -0300 Subject: [PATCH 1/2] feat(emu): expand the confirm dialog to cover the Hive operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /hive/sign-operations hands the device a pre-serialized Graphene blob, so unlike every other chain the emulator confirm dialog had no params to read an amount or a counterparty out of. It passed a bare { operation: 'hiveSignOperations', chain: 'HIVE' }, which rendered as Hive Sign Operations Chain: HIVE [Confirm] [Reject] for a transaction that may carry up to four distinct operations. Claiming rewards, cancelling a market order and powering down all produced the same prompt, so the dialog told the user nothing about what they were approving. /hive/sign-message was equally bare — no indication of which login message a dApp had asked for. hiveConfirmDetails() expands the condenser-style op array the caller already serialized into the existing EmulatorConfirmDetails shape, following evmConfirmDetails: DISPLAY ONLY, never feeding the serializer. A single op gets its label, counterparty and amount; a batch names the count and lists the ops rather than claiming one recipient for four operations. It keeps this module's honesty contract, so it declines to assert what it does not know: - no "To:" row for claim_reward_balance / limit_order_cancel / account_update2, which act on the signer's own account — a recipient there would be a claim the operation does not make - a self power-up (to: "") says "self" instead of an empty row - zero rewards are dropped from a claim so the real amount is not buried beside "0.000 HBD" - an absent or unparseable op array falls back to the bare header rather than guessing; the OLED behind it is still authoritative hiveMessagePreview() shows the signBuffer payload, round-tripping the UTF-8 decode so a binary Buffer payload is reported as a byte count instead of rendered as mojibake. 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 shows on each press. That is intended — the OLED behind it is what advances. Scope note: this makes the prompt informative, it does NOT add a recovery path. A confirm that is never answered still leaves confirm_helper blocked on the poll thread, which wedges the emulator so that every subsequent request from any chain fails with "Malformed tiny packet" until the emulator is force-quit. Cancel is a legal tiny message, so the vault can unwedge itself; that belongs in its own change. 11 new tests; 129 pass across the deterministic units (this file, txbuilder/hive-ops, rest-sign-gating). The live-device integration tests in this suite vary run to run independently of this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/bun/emulator-confirm-details.test.ts | 131 +++++++++++++++ .../src/bun/emulator-confirm-details.ts | 153 ++++++++++++++++++ projects/keepkey-vault/src/bun/rest-api.ts | 6 +- 3 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts diff --git a/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts b/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts new file mode 100644 index 00000000..296e0005 --- /dev/null +++ b/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts @@ -0,0 +1,131 @@ +/** + * 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)') + }) +}) diff --git a/projects/keepkey-vault/src/bun/emulator-confirm-details.ts b/projects/keepkey-vault/src/bun/emulator-confirm-details.ts index ff76655d..8e143ffd 100644 --- a/projects/keepkey-vault/src/bun/emulator-confirm-details.ts +++ b/projects/keepkey-vault/src/bun/emulator-confirm-details.ts @@ -155,3 +155,156 @@ 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 = { + 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': + return Number.isFinite(Number(p?.weight)) ? `${(Number(p.weight) / 100).toFixed(0)}%` : undefined + 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): string { + const bytes = Buffer.from(Array.from(messageBytes)) + const txt = bytes.toString('utf8') + // Round-trip check: a lossy decode means it was not UTF-8 text. + if (!Buffer.from(txt, 'utf8').equals(bytes)) return `${bytes.length} bytes (binary)` + const oneLine = txt.replace(/\s+/g, ' ').trim() + return oneLine.length > 64 ? `${oneLine.slice(0, 64)}…` : oneLine +} diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 28ab9a17..647b1c61 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -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') @@ -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 From 74d42d67cbcb286e3808ce2a26eaa1ede344b3b3 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 19:02:40 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(emu):=20review=20findings=20=E2=80=94?= =?UTF-8?q?=20vote-weight=20rounding=20and=20spoofable=20"text"=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on this PR: 1. toFixed(0) misreported fractional vote weights. Weight is basis points, so 1bp = 0.01%: a 1 showed as "0%", a 50 (0.5%) as "1%", a -50 as "-1%". The dialog was stating a value different from the one being signed. Now keeps up to two decimals and drops trailing zeros, so 10000 still reads "100%". 2. hiveMessagePreview's UTF-8 round-trip only catches MALFORMED UTF-8. Bytes 00 01 02 03, embedded NULs, and bidi/zero-width controls are all valid UTF-8, so they passed the check and rendered as invisible or reordered "text" — U+202E can make the displayed string differ from the bytes signed, which is the exact misrepresentation this module exists to avoid. Adds a \p{C} control/format check (tab/newline/CR exempted as ordinary whitespace) and treats whitespace-only payloads as binary rather than showing an empty dialog. +6 regression tests covering both. Suite 17/17. --- .../src/bun/emulator-confirm-details.test.ts | 49 +++++++++++++++++++ .../src/bun/emulator-confirm-details.ts | 26 ++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts b/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts index 296e0005..6b4e49e8 100644 --- a/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts +++ b/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts @@ -128,4 +128,53 @@ describe('hiveMessagePreview', () => { 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%') + }) }) diff --git a/projects/keepkey-vault/src/bun/emulator-confirm-details.ts b/projects/keepkey-vault/src/bun/emulator-confirm-details.ts index 8e143ffd..35083d60 100644 --- a/projects/keepkey-vault/src/bun/emulator-confirm-details.ts +++ b/projects/keepkey-vault/src/bun/emulator-confirm-details.ts @@ -246,8 +246,15 @@ function hiveValue(name: string, p: any): string | undefined { .filter((v: any) => typeof v === 'string' && v && !/^0(\.0+)? /.test(v)) return parts.length ? parts.join(' + ') : undefined } - case 'vote': - return Number.isFinite(Number(p?.weight)) ? `${(Number(p.weight) / 100).toFixed(0)}%` : 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 } @@ -303,8 +310,21 @@ export function hiveConfirmDetails(operation: string, ops: any): EmulatorConfirm export function hiveMessagePreview(messageBytes: ArrayLike): 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 `${bytes.length} bytes (binary)` + 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 }