From 10752c2d327abd9949add95b1e98515ee942c419 Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Thu, 12 Nov 2026 21:04:51 -0300 Subject: [PATCH 01/12] security(ui): harden how the device treats a payment it signed before Co-Authored-By: Claude --- ui/src/VaultSigner.tsx | 12 ++++++++--- ui/src/i18n/en.ts | 4 +++- ui/src/i18n/pt-BR.ts | 4 +++- ui/src/owner-signing.test.ts | 38 +++++++++++++++++++++++++++++++++ ui/src/owner-signing.ts | 25 ++++++++++++++++++---- ui/src/screens/SigningPanel.tsx | 12 +++++++---- ui/src/signing-panel.test.tsx | 13 +++++++++++ 7 files changed, 95 insertions(+), 13 deletions(-) diff --git a/ui/src/VaultSigner.tsx b/ui/src/VaultSigner.tsx index ac72c4b7..caa26fb3 100644 --- a/ui/src/VaultSigner.tsx +++ b/ui/src/VaultSigner.tsx @@ -45,7 +45,7 @@ interface VaultSignerCtx { armActive: (opts?: { again?: boolean }) => Promise /** A second transaction for this payment was refused: until when the first one stays valid. */ /** The first transaction's expiry block, and when this device stops counting it (unix seconds, 0 when unknown). */ - signedBefore: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean } | null + signedBefore: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null } | null /** When this device's signature stops counting (epoch ms), or null when it is not armed. */ armedUntil: number | null /** What the active proposal asks to pay, from the read the gate compares: undefined until read, @@ -188,7 +188,7 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) { vaultIdRef.current = vault?.id ?? null // A second transaction for an approval this device already signed, refused: what the panel tells // its owner (until when the first stays valid) before they may sign again knowing it. - const [signedBefore, setSignedBefore] = useState<{ proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean } | null>(null) + const [signedBefore, setSignedBefore] = useState<{ proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null } | null>(null) const gate = useMemo( () => makeSigningGate({ mode: () => 'manual', @@ -212,7 +212,13 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) { // it is counted), so the screen says exactly when this warning stops. // The machine words this one itself: a record it could not read is not a second transaction. if (r === 'unreadable') throw new Error('[unreadable] this device could not read what it signed') - if (r === 'second' && last) setSignedBefore({ proposal: last.proposal, firstExpiry: last.firstExpiry, firstUntil: last.firstUntil, untilHeight: last.untilHeight, byEpoch: last.byEpoch }) + if (r === 'second' && last) setSignedBefore({ proposal: last.proposal, firstExpiry: last.firstExpiry, firstUntil: last.firstUntil, untilHeight: last.untilHeight, byEpoch: last.byEpoch, lapsedAt: null }) + // Every earlier attempt lapsed: asked the same way, worded for what this device cannot know (A1). + const lapsed = owner.current.lastLapsed + if (r === 'lapsed' && lapsed) { + const at = lapsed.earlier.length ? Math.max(...lapsed.earlier) : 0 + setSignedBefore({ proposal: lapsed.proposal, firstExpiry: at, firstUntil: 0, untilHeight: null, byEpoch: false, lapsedAt: at }) + } return r === 'ok' }, [], diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts index cfb05b83..cccb4c14 100644 --- a/ui/src/i18n/en.ts +++ b/ui/src/i18n/en.ts @@ -1071,7 +1071,7 @@ export const en: Record = { 'signing.tryAgain': 'Try again', 'signing.againTitle': 'Sign this payment again?', 'signing.againBody': 'This device already signed an earlier attempt at this payment. If that attempt goes out, signing again could pay it twice: {amt} ZEC a second time. Do this only if the book does not show the payment as sent.', - 'signing.againUntil': 'Until about {when}, this device treats the earlier attempt as able to reach the network (the network itself gives up on it earlier). Waiting until then is the safe choice: after that, this device stops asking about the earlier attempt, and signing works as usual.', + 'signing.againUntil': 'Until about {when}, this device treats the earlier attempt as able to reach the network (the network itself gives up on it earlier). Waiting until then is the safe choice. After that, this device still asks before it signs this payment again.', // DRAFT (#638), for the maintainer to approve before merge. A network upgrade is behind each of the next // texts; none can appear until the upgrade's row is in the build. 'signing.againUntilEpoch': 'An earlier transaction for this payment can only be mined until block {block} (about {when}). If it is mined first, signing again pays this payment twice. Waiting until then is the safe choice.', @@ -1082,6 +1082,8 @@ export const en: Record = { 'error.upgradeHoldCrossing': 'This payment moves funds between pools and could be held by the network across the upgrade. Send it after the upgrade; nothing was built.', 'error.coordinatorNotUpdated': 'The coordinator has not been updated for the network upgrade yet. Nothing was signed or sent. Your vote stands until the proposal expires (72 hours); you will be asked to sign again when the payment can be built.', 'signing.againChecked': 'I checked the book: this payment does not show as sent.', + 'signing.againLapsedBody': 'This device already signed an earlier attempt at this payment. That attempt can no longer be mined, but this device cannot tell whether it was mined before then: it could have been, up to block {block}. If it was, signing again pays {amt} ZEC a second time. If you have the earlier transaction id, look it up in a block explorer: if it is there, the payment went out.', + 'signing.againLapsedChecked': 'I know the earlier attempt was not mined.', 'signing.againCta': 'Sign again: {amt} ZEC', 'signing.readAgainHeading': 'What the vault built this time', 'signing.againCtaSend': 'Sign again and send: {amt} ZEC', diff --git a/ui/src/i18n/pt-BR.ts b/ui/src/i18n/pt-BR.ts index 63068d20..173e2df7 100644 --- a/ui/src/i18n/pt-BR.ts +++ b/ui/src/i18n/pt-BR.ts @@ -1067,7 +1067,7 @@ export const ptBR: Record = { 'signing.tryAgain': 'Tentar de novo', 'signing.againTitle': 'Assinar este pagamento de novo?', 'signing.againBody': 'Este aparelho já assinou uma tentativa anterior deste pagamento. Se aquela tentativa sair, assinar de novo pode pagá-lo duas vezes: {amt} ZEC uma segunda vez. Faça isso só se o livro não mostrar o pagamento como enviado.', - 'signing.againUntil': 'Até por volta de {when}, este aparelho trata a tentativa anterior como capaz de chegar à rede (a própria rede desiste dela antes). Esperar até lá é o caminho seguro: depois disso, este aparelho deixa de perguntar pela tentativa anterior, e assinar volta ao normal.', + 'signing.againUntil': 'Até por volta de {when}, este aparelho trata a tentativa anterior como capaz de chegar à rede (a própria rede desiste dela antes). Esperar até lá é o caminho seguro. Depois disso, este aparelho ainda pergunta antes de assinar este pagamento de novo.', // RASCUNHO (#638), para o mantenedor aprovar antes do merge. Uma atualizacao da rede esta por tras de cada // um dos proximos textos; nenhum aparece ate a linha da atualizacao entrar na versao. 'signing.againUntilEpoch': 'Uma transação anterior deste pagamento só pode ser minerada até o bloco {block} (por volta de {when}). Se ela for minerada antes, assinar de novo paga este pagamento duas vezes. Esperar até lá é o caminho seguro.', @@ -1078,6 +1078,8 @@ export const ptBR: Record = { 'error.upgradeHoldCrossing': 'Este pagamento move fundos entre pools e poderia ficar preso na rede durante a atualização. Envie depois da atualização; nada foi montado.', 'error.coordinatorNotUpdated': 'O coordenador ainda não foi atualizado para a atualização da rede. Nada foi assinado nem enviado. Seu voto continua valendo até a proposta expirar (72 horas); você vai precisar assinar de novo quando o pagamento puder ser montado.', 'signing.againChecked': 'Conferi o livro: este pagamento não aparece como enviado.', + 'signing.againLapsedBody': 'Este aparelho já assinou uma tentativa anterior deste pagamento. Aquela tentativa não pode mais ser minerada, mas este aparelho não sabe dizer se ela foi minerada antes disso: pode ter sido, até o bloco {block}. Se foi, assinar de novo paga {amt} ZEC uma segunda vez. Se você tem o id da transação anterior, procure-o num explorador de blocos: se ele estiver lá, o pagamento saiu.', + 'signing.againLapsedChecked': 'Sei que a tentativa anterior não foi minerada.', 'signing.againCta': 'Assinar de novo: {amt} ZEC', 'signing.readAgainHeading': 'O que o cofre montou desta vez', 'signing.againCtaSend': 'Assinar de novo e enviar: {amt} ZEC', diff --git a/ui/src/owner-signing.test.ts b/ui/src/owner-signing.test.ts index 29de8f4c..f7e01b09 100644 --- a/ui/src/owner-signing.test.ts +++ b/ui/src/owner-signing.test.ts @@ -23,6 +23,8 @@ function memory(): RecordStore { const A = 'ab'.repeat(32) const B = 'cd'.repeat(32) const ALIVE = 4_000_000_000 +// An expiry no transaction can still be mined at, by any clock this suite pins. +const DEAD = 1 // NU6.3's consensus branch id: an oracle written out here, not read from the code under test. const NU63 = 0x37a5165b // NU7 is the last row of the table: nothing follows it, so a record under it ends at its own expiry. @@ -144,4 +146,40 @@ describe('the owner signing on this device', () => { expect(readSigned(store, 'v1', 'p1').length).toBe(1) expect(readSigned(store, 'v1', 'p2')).toEqual([]) }) + + // A transaction this device signed for an approval can no longer be mined: the device does not know + // whether it WAS mined. Signing a new one silently would pay twice if it was, so it asks, like a + // second transaction does, and a click alone never covers it. + it('after an earlier transaction lapsed, a new one for the approval asks, it is never signed on the click alone', async () => { + const store = memory() + const o = new OwnerSigning(store) + o.give('p1', Date.now()) + expect(await o.claim('v1', 'p1', A, DEAD)).toBe('ok') + await o.record(A, DEAD, NU63) + o.give('p1', Date.now()) + expect(await o.claim('v1', 'p1', B, ALIVE)).toBe('lapsed') + expect(o.lastLapsed).toEqual({ proposal: 'p1', sighash: B, earlier: [DEAD] }) + expect(o.allows('p1'), 'the refused click is spent').toBe(false) + }) + + it('after a lapse, the owner signs again knowing it, for the transaction shown, and only once', async () => { + const store = memory() + const o = new OwnerSigning(store) + o.give('p1', Date.now()) + await o.claim('v1', 'p1', A, DEAD) + await o.record(A, DEAD, NU63) + o.give('p1', Date.now()) + expect(await o.claim('v1', 'p1', B, ALIVE)).toBe('lapsed') + o.give('p1', Date.now(), { again: true }) + // Another transaction than the one shown is not covered by that answer. + expect(await o.claim('v1', 'p1', 'ef'.repeat(32), ALIVE)).toBe('lapsed') + o.give('p1', Date.now()) + expect(await o.claim('v1', 'p1', B, ALIVE)).toBe('lapsed') + o.give('p1', Date.now(), { again: true }) + expect(await o.claim('v1', 'p1', B, ALIVE)).toBe('ok') + await o.record(B, ALIVE, NU63) + // The answer was spent on B. + o.give('p1', Date.now(), { again: true }) + expect(await o.claim('v1', 'p1', 'ef'.repeat(32), ALIVE)).toBe('second') + }) }) diff --git a/ui/src/owner-signing.ts b/ui/src/owner-signing.ts index 92676e38..84fd96c0 100644 --- a/ui/src/owner-signing.ts +++ b/ui/src/owner-signing.ts @@ -7,9 +7,9 @@ // A coordinator that says a send failed cannot make that second signature silent: the failure ends // the click, not the record. import { claimConsent, consentAllows, giveConsent, newConsent, withdrawConsent, type OwnerConsent } from './signing-gate' -import { decideFor, recordSigned, type RecordStore } from './signed-record' +import { decideFor, readSigned, recordSigned, type RecordStore } from './signed-record' -export type Claim = 'ok' | 'second' | 'refused' | 'unreadable' +export type Claim = 'ok' | 'second' | 'lapsed' | 'refused' | 'unreadable' export class OwnerSigning { readonly consent: OwnerConsent = newConsent() @@ -22,6 +22,11 @@ export class OwnerSigning { private claimed: { vault: string; proposal: string; sighash: string } | null = null /** The last second transaction refused, for the screen: which approval, and until when the first stays valid. */ lastSecond: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; sighash: string } | null = null + /** The last new transaction refused because every earlier one for the approval can no longer be + * mined: the device does not know whether one WAS mined, so signing again may pay twice. For the + * screen: which approval, the transaction refused, and the block each earlier one expired at + * (the most it could have been mined at). */ + lastLapsed: { proposal: string; sighash: string; earlier: number[] } | null = null private readonly store: RecordStore private readonly now: () => number @@ -34,9 +39,11 @@ export class OwnerSigning { /** The owner signs `proposal`; `again` when they confirmed signing a second transaction for it. */ give(proposal: string, at: number, opts: { again?: boolean } = {}): void { giveConsent(this.consent, proposal, at) - const shown = this.lastSecond + // An answer covers the transaction its owner was shown, whichever of the two asked for it. + const shown = this.lastSecond ?? this.lastLapsed this.again = opts.again && shown?.proposal === proposal ? { proposal, sighash: shown.sighash } : null this.lastSecond = null + this.lastLapsed = null } withdraw(): void { @@ -73,8 +80,18 @@ export class OwnerSigning { this.lastSecond = { proposal: activeId, firstExpiry: decision.firstExpiry, firstUntil: decision.untilUnix ?? 0, untilHeight: decision.untilHeight, byEpoch: decision.byEpoch, sighash: h } return 'second' } + const answered = this.again?.proposal === activeId && this.again.sighash === h + // Every earlier transaction lapsed: none can be mined from now on, and none is known NOT to have + // been. Never silent: the owner answers for this transaction, as for a second one (A1). + if (decision.kind === 'afterExpiry' && !answered) { + this.consent.spent = true + let earlier: number[] = [] + try { earlier = readSigned(this.store, vault, activeId).map((t) => t.e) } catch { /* decided above */ } + this.lastLapsed = { proposal: activeId, sighash: h, earlier } + return 'lapsed' + } if (!claimConsent(this.consent, activeId, this.now(), sighashHex)) return 'refused' - if (decision.kind === 'second') this.again = null + if (decision.kind === 'second' || decision.kind === 'afterExpiry') this.again = null this.claimed = { vault, proposal: activeId, sighash: sighashHex.trim().toLowerCase() } return 'ok' } diff --git a/ui/src/screens/SigningPanel.tsx b/ui/src/screens/SigningPanel.tsx index 333519f4..a52d50a9 100644 --- a/ui/src/screens/SigningPanel.tsx +++ b/ui/src/screens/SigningPanel.tsx @@ -104,7 +104,7 @@ function PlanLines({ lines: rows }: { lines: ApprovedPlan['lines'] }) { ) } -export function SignConfirm({ payrollLines, destPublic = false, again, firstUntil, byEpoch = false, untilHeight = null, locale, amt, usd, dest, isPayroll, iWouldBeLast, t, onCancel, onConfirm, now = Date.now(), fee, total }: { +export function SignConfirm({ payrollLines, destPublic = false, again, firstUntil, byEpoch = false, untilHeight = null, lapsedAt = null, locale, amt, usd, dest, isPayroll, iWouldBeLast, t, onCancel, onConfirm, now = Date.now(), fee, total }: { again: boolean /** When this device stops counting the earlier attempt (unix seconds, from `approvalDecision`); 0 when unknown. */ firstUntil: number @@ -113,6 +113,9 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti byEpoch?: boolean /** The last block the earlier attempt can be mined in (the block before the upgrade); with `byEpoch`. */ untilHeight?: number | null + /** Every earlier attempt can no longer be mined, and this device cannot tell whether one was: the + * highest block one could have been mined at. Its own wording, never silent (A1). */ + lapsedAt?: number | null locale: 'pt-BR' | 'en' amt: string usd?: string | null @@ -137,7 +140,7 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti // is said when it is unknown or already past (the confirm then no longer applies). // The epoch wording only when WASM named the block the upgrade cuts it at; an unknown time says so // instead of staying silent (a decision with no turning point is not "nothing to wait for"). - const until = firstUntil * 1000 > now + const until = lapsedAt !== null ? '' : firstUntil * 1000 > now ? byEpoch && untilHeight !== null ? t('signing.againUntilEpoch', { when: fmtWhen(firstUntil, locale), block: untilHeight }) : t('signing.againUntil', { when: fmtWhen(firstUntil, locale) }) @@ -166,13 +169,13 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti {again ? ( <>

- {t('signing.againBody', { amt })} + {lapsedAt !== null ? t('signing.againLapsedBody', { amt, block: lapsedAt }) : t('signing.againBody', { amt })} {iWouldBeLast && <> {t('signing.confirmBody')}}

{until &&

{until}

} ) : ( @@ -975,6 +978,7 @@ export default function SigningPanel() { firstUntil={signedBefore?.firstUntil ?? 0} byEpoch={signedBefore?.byEpoch ?? false} untilHeight={signedBefore?.untilHeight ?? null} + lapsedAt={signedBefore?.lapsedAt ?? null} fee={amountZat !== null && feeKnown ? fmtZecExact(feeZat / 1e8) : undefined} total={amountZat !== null && feeKnown ? fmtZecExact((amountZat + feeZat) / 1e8) : undefined} locale={locale} diff --git a/ui/src/signing-panel.test.tsx b/ui/src/signing-panel.test.tsx index 2cb4d869..493a2aa6 100644 --- a/ui/src/signing-panel.test.tsx +++ b/ui/src/signing-panel.test.tsx @@ -283,6 +283,19 @@ describe('the confirm for signing again', () => { expect(html).toContain('signing.againCta amt=1') expect(html).not.toContain('signing.confirmSignTitle') }) + // A1: every earlier attempt lapsed, and the device cannot tell whether one was mined. Its own + // wording: no "until" (there is nothing to wait for), the block it could have been mined up to, and + // the CTA held behind the owner saying they know it was not. + it('after a lapse, says it cannot tell whether the earlier attempt was mined, names the block, and holds the CTA', () => { + const html = renderToStaticMarkup() + expect(html).toContain('signing.againLapsedBody amt=1 block=3600123') + expect(html).toContain('signing.againLapsedChecked') + expect(html).not.toContain('signing.againBody') + expect(html).not.toContain('signing.againUntil') + expect(html).not.toContain('signing.againNoTime') + const cta = html.slice(html.lastIndexOf(' { const html = renderToStaticMarkup() expect(html).not.toContain('signing.againUntil') From 1016fca854758a2a2124e53739580b7d2cdb734d Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Thu, 12 Nov 2026 21:09:22 -0300 Subject: [PATCH 02/12] security(ui): the device recognises a payment it signed under any proposal Co-Authored-By: Claude --- ui/src/VaultSigner.tsx | 8 +++++-- ui/src/approved-payment.test.ts | 14 +++++++++++++ ui/src/approved-payment.ts | 10 +++++++++ ui/src/owner-signing.test.ts | 37 +++++++++++++++++++++++++++++++++ ui/src/owner-signing.ts | 22 ++++++++++++++------ ui/src/signed-record.ts | 36 +++++++++++++++++++++++++------- ui/src/vault-signer.test.tsx | 4 +++- 7 files changed, 115 insertions(+), 16 deletions(-) diff --git a/ui/src/VaultSigner.tsx b/ui/src/VaultSigner.tsx index caa26fb3..fd26f248 100644 --- a/ui/src/VaultSigner.tsx +++ b/ui/src/VaultSigner.tsx @@ -10,7 +10,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { getVault, getProposalDetail, IS_NET, type Proposal, type Vault } from './api' import { listVaults } from './storage' -import { approvedLinesFor, readPlan, refusePlan, gateDecision, ourReceiversFrom, paysOurselves, type ApprovedLine, type ApprovedPlan, type GateDecision, type PcztOutput } from './approved-payment' +import { approvedLinesFor, paidKey, readPlan, refusePlan, gateDecision, ourReceiversFrom, paysOurselves, type ApprovedLine, type ApprovedPlan, type GateDecision, type PcztOutput } from './approved-payment' import { ensureWasm } from './wasm-ready' import { uaReceiver } from './wasm-pkg/konclave_wasm.js' import { useBackgroundSigner, type BackgroundSignerState } from './useBackgroundSigner' @@ -204,7 +204,11 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) { // stops reading as signed, so its owner can sign again after checking the book (the panel says so). const claim = useMemo( () => async (sighashHex: string, expiry: number) => { - const r = await owner.current.claim(vaultIdRef.current ?? '', activeIdRef.current, sighashHex, expiry) + // What the transaction pays, as the gate just matched it: recognises the same payment under a new + // proposal id until approvals are bound to content (#567). + const approved = approvalRef.current?.approved + const paid = approved && approved.length ? await paidKey(approved) : undefined + const r = await owner.current.claim(vaultIdRef.current ?? '', activeIdRef.current, sighashHex, expiry, paid) if (r !== 'ok') { setArmedProposal(null); setArmedAt(null) } const last = owner.current.lastSecond // The machine read this transaction, so the WASM is ready. The time was computed by WASM, by the diff --git a/ui/src/approved-payment.test.ts b/ui/src/approved-payment.test.ts index a5603a72..faa9cbde 100644 --- a/ui/src/approved-payment.test.ts +++ b/ui/src/approved-payment.test.ts @@ -295,3 +295,17 @@ describe('mayContribute', () => { expect(mayContribute({ cp: 3, receiversState: 'verified', abandoned: 'x' }, undefined)).toBe(false) }) }) + +// The key the signing record keeps of what a transaction pays (#567 bridge): the same lines in any +// order are the same payment; any change of receiver, amount or memo is another. +describe('paidKey', () => { + const l = (r: string, z: number, m = '') => ({ toReceiver: r, amountZat: z, memo: m }) + it('is order-free, case-free on the receiver, and changes with any field', async () => { + const { paidKey } = await import('./approved-payment') + const k = await paidKey([l('AA', 1), l('bb', 2, 'x')]) + expect(await paidKey([l('bb', 2, 'x'), l('aa', 1)])).toBe(k) + expect(await paidKey([l('aa', 1), l('bb', 3, 'x')])).not.toBe(k) + expect(await paidKey([l('aa', 1), l('bb', 2, 'y')])).not.toBe(k) + expect(await paidKey([l('aa', 1), l('cc', 2, 'x')])).not.toBe(k) + }) +}) diff --git a/ui/src/approved-payment.ts b/ui/src/approved-payment.ts index d3cb4aaa..940ad2be 100644 --- a/ui/src/approved-payment.ts +++ b/ui/src/approved-payment.ts @@ -281,3 +281,13 @@ export function refusePlan(id: string): null { export function forgetPlansForTests(): void { planMemory.clear() } + +/** A key of what a transaction pays: its approved lines (receiver, amount, memo), order-free, hashed. + * Read only after the gate matched the transaction to these lines, so it names what it pays. The + * device's signing record keeps it so the same payment is recognised under a new proposal id, until + * approvals are bound to content (#567). */ +export async function paidKey(lines: ApprovedLine[]): Promise { + const rows = lines.map((l) => [norm(l.toReceiver), l.amountZat, l.memo]).map((r) => JSON.stringify(r)).sort() + const d = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(rows)))) + return Array.from(d, (b) => b.toString(16).padStart(2, '0')).join('') +} diff --git a/ui/src/owner-signing.test.ts b/ui/src/owner-signing.test.ts index f7e01b09..1c747b99 100644 --- a/ui/src/owner-signing.test.ts +++ b/ui/src/owner-signing.test.ts @@ -182,4 +182,41 @@ describe('the owner signing on this device', () => { o.give('p1', Date.now(), { again: true }) expect(await o.claim('v1', 'p1', 'ef'.repeat(32), ALIVE)).toBe('second') }) + + // Until approvals are bound to content (#567), the record is keyed by a proposal id the coordinator + // chooses. A new proposal that pays exactly what this device already signed for another one is not a + // first signature: the device recognises the payment by what the transaction pays. + it('a new proposal paying exactly what it already signed for another is not a first signature', async () => { + const store = memory() + const o = new OwnerSigning(store) + o.give('p1', Date.now()) + expect(await o.claim('v1', 'p1', A, ALIVE, 'K')).toBe('ok') + await o.record(A, ALIVE, NU63) + o.give('p2', Date.now()) + expect(await o.claim('v1', 'p2', B, ALIVE, 'K')).toBe('second') + o.give('p3', Date.now()) + expect(await o.claim('v1', 'p3', B, ALIVE, 'OTHER')).toBe('ok') + }) + + it('the same payment under a new proposal, after the earlier one lapsed, asks as a lapse', async () => { + const store = memory() + const o = new OwnerSigning(store) + o.give('p1', Date.now()) + await o.claim('v1', 'p1', A, DEAD, 'K') + await o.record(A, DEAD, NU63) + o.give('p2', Date.now()) + expect(await o.claim('v1', 'p2', B, ALIVE, 'K')).toBe('lapsed') + }) + + it('a payment signed more than seven days ago no longer marks a new proposal for the same outputs', async () => { + const store = memory() + let now = Date.now() + const o = new OwnerSigning(store, () => now) + o.give('p1', now) + await o.claim('v1', 'p1', A, DEAD, 'K') + await o.record(A, DEAD, NU63) + now += 8 * 24 * 3600 * 1000 + o.give('p2', now) + expect(await o.claim('v1', 'p2', B, ALIVE, 'K')).toBe('ok') + }) }) diff --git a/ui/src/owner-signing.ts b/ui/src/owner-signing.ts index 84fd96c0..3c83b10b 100644 --- a/ui/src/owner-signing.ts +++ b/ui/src/owner-signing.ts @@ -7,7 +7,10 @@ // A coordinator that says a send failed cannot make that second signature silent: the failure ends // the click, not the record. import { claimConsent, consentAllows, giveConsent, newConsent, withdrawConsent, type OwnerConsent } from './signing-gate' -import { decideFor, readSigned, recordSigned, type RecordStore } from './signed-record' +import { decideAmong, decideFor, readByOutputs, readSigned, recordSigned, type RecordStore, type SignedTx } from './signed-record' + +/** How long a payment this device signed marks a new proposal that pays the same outputs. */ +export const SAME_OUTPUTS_HORIZON_MS = 7 * 24 * 3600 * 1000 export type Claim = 'ok' | 'second' | 'lapsed' | 'refused' | 'unreadable' @@ -19,7 +22,7 @@ export class OwnerSigning { /** The transaction this consent cleared, and for which approval: the only one a share may go into, * recorded under that approval whatever the screen shows by then. Gone when the consent is * withdrawn; a new click on the same payment keeps it, so a ceremony under way is not cut. */ - private claimed: { vault: string; proposal: string; sighash: string } | null = null + private claimed: { vault: string; proposal: string; sighash: string; outputs?: string } | null = null /** The last second transaction refused, for the screen: which approval, and until when the first stays valid. */ lastSecond: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; sighash: string } | null = null /** The last new transaction refused because every earlier one for the approval can no longer be @@ -63,11 +66,18 @@ export class OwnerSigning { } /** The machine cleared `sighashHex` (expiring at `expiry`) for `activeId` on `vault`. */ - async claim(vault: string, activeId: string | null, sighashHex: string, _expiry: number): Promise { + async claim(vault: string, activeId: string | null, sighashHex: string, _expiry: number, outputs?: string): Promise { if (!activeId || !consentAllows(this.consent, activeId, this.now())) return 'refused' let decision + let earlierList: SignedTx[] | null = null try { decision = await decideFor(this.store, vault, activeId, sighashHex) + // Nothing under this proposal: the same payment may have been signed under another id (#567 + // bridge). Judged by the same rule, against those transactions. + if (decision.kind === 'first' && outputs) { + const same = readByOutputs(this.store, vault, outputs, this.now() - SAME_OUTPUTS_HORIZON_MS) + if (same.length) { decision = await decideAmong(same, sighashHex); earlierList = same } + } } catch { // Not knowing what this device signed never reads as having signed nothing. Its own answer, // so the screen says what happened rather than "already signed". @@ -86,13 +96,13 @@ export class OwnerSigning { if (decision.kind === 'afterExpiry' && !answered) { this.consent.spent = true let earlier: number[] = [] - try { earlier = readSigned(this.store, vault, activeId).map((t) => t.e) } catch { /* decided above */ } + try { earlier = (earlierList ?? readSigned(this.store, vault, activeId)).map((t) => t.e) } catch { /* decided above */ } this.lastLapsed = { proposal: activeId, sighash: h, earlier } return 'lapsed' } if (!claimConsent(this.consent, activeId, this.now(), sighashHex)) return 'refused' if (decision.kind === 'second' || decision.kind === 'afterExpiry') this.again = null - this.claimed = { vault, proposal: activeId, sighash: sighashHex.trim().toLowerCase() } + this.claimed = { vault, proposal: activeId, sighash: sighashHex.trim().toLowerCase(), outputs } return 'ok' } @@ -107,6 +117,6 @@ export class OwnerSigning { if (!c || c.sighash !== sighashHex.trim().toLowerCase() || !consentAllows(this.consent, c.proposal, this.now(), sighashHex)) { throw new Error('[withdrawn] no live consent for this transaction') } - await recordSigned(this.store, c.vault, c.proposal, sighashHex, expiry, branch) + await recordSigned(this.store, c.vault, c.proposal, sighashHex, expiry, branch, c.outputs ? { o: c.outputs, t: this.now() } : undefined) } } diff --git a/ui/src/signed-record.ts b/ui/src/signed-record.ts index bdeb6fdf..bb2722b2 100644 --- a/ui/src/signed-record.ts +++ b/ui/src/signed-record.ts @@ -22,7 +22,10 @@ export interface RecordStore { getItem(key: string): string | null setItem(key: string, value: string): void } -export interface SignedTx { h: string; e: number; b?: number } +// `o`: a key of what the transaction pays (its paying outputs, as the gate matched them), and `t` when +// it was recorded (ms). Until approvals are bound to content (#567) the record is keyed by a proposal +// id the coordinator chooses; `o` lets the device recognise the same payment under a new id. +export interface SignedTx { h: string; e: number; b?: number; o?: string; t?: number } export type Decision = | { kind: 'first' } | { kind: 'same' } @@ -60,15 +63,30 @@ function readAll(store: RecordStore, vault: string): Record /** The transactions this device signed for `proposal` on `vault`. Throws when the record cannot be read. */ export function readSigned(store: RecordStore, vault: string, proposal: string): SignedTx[] { const list = readAll(store, vault)[proposal] ?? [] - if (!Array.isArray(list) || list.some((t) => typeof t?.h !== 'string' || !Number.isInteger(t?.e) || (t?.b !== undefined && !Number.isInteger(t?.b)))) { - throw new Error('signed record: unreadable') - } + if (!Array.isArray(list) || list.some(malformed)) throw new Error('signed record: unreadable') return list } +function malformed(t: SignedTx): boolean { + return typeof t?.h !== 'string' || !Number.isInteger(t?.e) || (t?.b !== undefined && !Number.isInteger(t?.b)) + || (t?.o !== undefined && typeof t.o !== 'string') || (t?.t !== undefined && !Number.isInteger(t.t)) +} + +/** Every transaction this device signed on `vault`, under any proposal, that paid what `outputs` names + * and was recorded at or after `since` (ms). Throws when the record cannot be read. */ +export function readByOutputs(store: RecordStore, vault: string, outputs: string, since: number): SignedTx[] { + const all = readAll(store, vault) + const out: SignedTx[] = [] + for (const list of Object.values(all)) { + if (!Array.isArray(list) || list.some(malformed)) throw new Error('signed record: unreadable') + for (const t of list) if (t.o === outputs && (t.t ?? 0) >= since) out.push(t) + } + return out +} + /** Record that this device's share went into the transaction `sighashHex`, expiring at `expiry` under * consensus branch `branch`: both read by this device from the header it checked. */ -export async function recordSigned(store: RecordStore, vault: string, proposal: string, sighashHex: string, expiry: number, branch: number): Promise { +export async function recordSigned(store: RecordStore, vault: string, proposal: string, sighashHex: string, expiry: number, branch: number, paid?: { o: string; t: number }): Promise { // A branch that is missing or not a whole positive u32 would write nothing (JSON drops undefined), // and the entry would then read as NU6.3 for good: a later upgrade's transaction counted as dead // too early. Fail closed, before any share leaves (the machine records first). @@ -77,13 +95,17 @@ export async function recordSigned(store: RecordStore, vault: string, proposal: const all = readAll(store, vault) const list = readSigned(store, vault, proposal) if (list.some((t) => t.h === h)) return - all[proposal] = [...list, { h, e: expiry, b: branch }] + all[proposal] = [...list, paid ? { h, e: expiry, b: branch, o: paid.o, t: paid.t } : { h, e: expiry, b: branch }] store.setItem(key(vault), JSON.stringify(all)) } /** What the transaction `sighashHex` is for this approval, by the rule in WASM and this device's clock. */ export async function decideFor(store: RecordStore, vault: string, proposal: string, sighashHex: string): Promise { - const list = readSigned(store, vault, proposal) + return decideAmong(readSigned(store, vault, proposal), sighashHex) +} + +/** What the transaction `sighashHex` is against `list`, by the rule in WASM. */ +export async function decideAmong(list: SignedTx[], sighashHex: string): Promise { // `h:e:b`; an entry with no branch goes as `h:e`, which WASM reads as NU6.3. const signed = list.map((t) => (t.b === undefined ? `${t.h}:${t.e}` : `${t.h}:${t.e}:${t.b}`)).join(',') return JSON.parse(approvalDecision(signed, await hashSighash(sighashHex))) as Decision diff --git a/ui/src/vault-signer.test.tsx b/ui/src/vault-signer.test.tsx index 75e34a29..08280562 100644 --- a/ui/src/vault-signer.test.tsx +++ b/ui/src/vault-signer.test.tsx @@ -91,7 +91,9 @@ describe("the owner's claim and record reach the signer", () => { it('the provider passes its own claim and record, made from OwnerSigning', () => { const src = codeOf('./VaultSigner.tsx') expect(src).toMatch(/useBackgroundSigner\(unlocked, gate, decideApproval, claim, record, endConsent\)/) - expect(src).toMatch(/const r = await owner\.current\.claim\(vaultIdRef\.current \?\? '', activeIdRef\.current, sighashHex, expiry\)/) + // With the key of what the gate matched (#567 bridge): the same payment under a new id is recognised. + expect(src).toMatch(/const r = await owner\.current\.claim\(vaultIdRef\.current \?\? '', activeIdRef\.current, sighashHex, expiry, paid\)/) + expect(src).toMatch(/const paid = approved && approved\.length \? await paidKey\(approved\) : undefined/) expect(src).toMatch(/\(sighashHex: string, expiry: number, branch: number\) => owner\.current\.record\(sighashHex, expiry, branch\)/) expect(src).toMatch(/return r === 'ok'/) }) From d4eb7dd39cf286ec69018f648201307815ba80bf Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Thu, 12 Nov 2026 21:12:57 -0300 Subject: [PATCH 03/12] security(ui): proposals are signed from the signing panel, not from /net Co-Authored-By: Claude --- ui/src/net-execute.test.ts | 17 ++++++++++ ui/src/screens/NetVault.tsx | 66 +++---------------------------------- ui/src/send-attempt.test.ts | 12 +++---- 3 files changed, 27 insertions(+), 68 deletions(-) create mode 100644 ui/src/net-execute.test.ts diff --git a/ui/src/net-execute.test.ts b/ui/src/net-execute.test.ts new file mode 100644 index 00000000..a6bb4d62 --- /dev/null +++ b/ui/src/net-execute.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { codeOf } from './source-scan' + +// /net's own signing driver keeps no record of what it signed and checks no approval. Where a +// coordinator is configured, a proposal is signed from its signing panel (VaultSigner), which does; +// so /net refuses to execute one there and points to the panel. Without a coordinator /net shows no +// proposals at all (its panel renders only with one), and its other parts stay until #627. +describe('/net does not execute a proposal where a coordinator is configured', () => { + const src = codeOf('./screens/NetVault.tsx') + it('the page has no way to execute a proposal: it never calls executeProposal', () => { + expect(src).not.toMatch(/\bexecuteProposal\b/) + }) + it('the proposal row offers the signing panel, not a Sign & send button', () => { + expect(src).not.toMatch(/'Sign & send'/) + expect(src).toMatch(/nav\('\/proposal', \{ state: \{ id: p\.id \} \}\)/) + }) +}) diff --git a/ui/src/screens/NetVault.tsx b/ui/src/screens/NetVault.tsx index 256ce7e3..1c45473e 100644 --- a/ui/src/screens/NetVault.tsx +++ b/ui/src/screens/NetVault.tsx @@ -15,7 +15,7 @@ import { import { quorumIsPossible, DkgCeremony, DKG_VERSION, type DkgAbort, type DkgConfig, type DkgMsg } from '../dkg-ceremony' import { InviteCard, CodeCheck, StopPanel, WaitLine, saveWaitText } from './CeremonyParts' import { creationBusy, closeIntent } from '../creation-guard' -import { RelaySession, newRoomCode, newInviteCode, parseInvite, joinParam, formatInvite, deriveRoom, pinIsLongEnough, pinProblem, pinLengthNote, generateRoomPin, formatRoomPin, spokenRoomPin, ephemeralTag, b64, relayBase, type RelayMsg } from '../net' +import { RelaySession, newRoomCode, newInviteCode, parseInvite, joinParam, formatInvite, deriveRoom, pinIsLongEnough, pinProblem, pinLengthNote, generateRoomPin, formatRoomPin, spokenRoomPin, ephemeralTag, b64, type RelayMsg } from '../net' import { decodeBundle } from '../signing' import { SigningMachine, type SignPreview } from '../signing-machine' import { seatHolder } from '../signing-seats' @@ -25,7 +25,6 @@ import { deriveReadKey, deriveSeatProofKey, generateVaultSecret } from '../vault import { deviceCommsKey, devicePubHex } from '../device-key' import { concludeRegistration, refusalKey } from '../conclude-registration' import { buildVaultBackup } from '../build-backup' -import { writeProof } from '../api' import { useT, useTr, useI18n } from '../i18n' import { Letterhead, PassphraseField } from '../components' import { newPassphraseProblem } from '../passphrase' @@ -48,11 +47,8 @@ import { coordinatorCreationMin, vaultBalance, listProposals, - executeProposal, type Proposal, } from '../helper' -import { readReply } from '../send-outcome' -import { netReplyText } from '../send-preflight' import { zatToZec, fmtZecExact, vaultFingerprint } from '../format' import { readingRows } from '../device-reading' import encodeQR from '@paulmillr/qr' @@ -283,13 +279,6 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: { // Proposals: proposing/voting lives in the PWA (Dashboard). /net only lists the READY ones and // signs them (the ceremony), so it keeps the list + a status message, not a create/vote form. const [proposals, setProposals] = useState(null) - const [propMsg, setPropMsg] = useState('') - // Single-flight guard: one send at a time from this device. Two concurrent sends (a double-click, - // or the same tab firing twice) would publish two competing sign-requests into the relay room and - // neither would gather quorum. The cross-DEVICE lock (two tabs pressing) is the per-vault signing - // room + singleton in issue #49; this stops the same device from colliding with itself. - const [sendingId, setSendingId] = useState(null) - const sendingRef = useRef(false) const [signPhase, setSignPhase] = useState<'none' | 'signing' | 'signed'>('none') const [signature, setSignature] = useState('') const [signOk, setSignOk] = useState(false) @@ -876,44 +865,6 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: { setProposals(await listProposals(groupVk)) }, [groupVk]) - // Execute a ready proposal: the browsers sign over the relay and the helper broadcasts. This - // moves real funds, so it is gated by an explicit confirmation (a single click never sends, §7). - const execProp = useCallback( - async (id: string) => { - if (sendingRef.current) return // a send is already in flight from this device; ignore re-entry - const ok = typeof window === 'undefined' || window.confirm( - pe( - 'Executar este pagamento aprovado? As duas abas vão assinar e a transação vai para a rede.', - 'Execute this approved payment? Both tabs will sign and the transaction goes to the network.', - ), - ) - if (!ok) return - sendingRef.current = true - setSendingId(id) - setPropMsg(pe('Executando: as abas assinam pelo relay…', 'Executing: the tabs sign over the relay…')) - try { - const r = await executeProposal({ - vault: groupVk, - proposalId: id, - relayBase: relayBase(), - room, - dryRun: false, - // #288, same rule as the app's signing panel: firing a broadcast is a governance write. - proof: await writeProof(groupVk, 'send', id), - }) - // What the reply means is one rule (readReply, send-outcome.ts), the same as the signing - // panel's, and what /net says for it is netReplyText (send-preflight.ts). - const said = netReplyText(readReply(r)) - setPropMsg(pe(said.pt, said.en)) - if (said.stop) return - void loadProposals() - } finally { - sendingRef.current = false - setSendingId(null) - } - }, - [groupVk, room, pe, loadProposals], - ) // Auto-load the proposals once the vault is known (helper configured + group key set). useEffect(() => { @@ -1632,9 +1583,6 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: { - {propMsg && ( -

{propMsg}

- )} {(() => { const ready = (proposals ?? []).filter((p) => p.state === 'ready') if (proposals && ready.length === 0) { @@ -1661,17 +1609,13 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: {
+ {/* Signed from the proposal's own signing panel, which records what this device + signed and checks the approval; /net's driver does neither. */}
diff --git a/ui/src/send-attempt.test.ts b/ui/src/send-attempt.test.ts index 9dcb3c9b..983035a9 100644 --- a/ui/src/send-attempt.test.ts +++ b/ui/src/send-attempt.test.ts @@ -64,12 +64,10 @@ describe('the panel goes through the send memory', () => { }) }) -describe('/net reads a send reply by the same rule', () => { - it('NetVault goes through readReply, never the reply\'s fields by hand', () => { - const net = codeOf('./screens/NetVault.tsx') - const at = net.indexOf('const r = await executeProposal(') - const block = net.slice(at, net.indexOf('} finally {', at)) - expect(block).toMatch(/const said = netReplyText\(readReply\(r\)\)\s*setPropMsg\(pe\(said\.pt, said\.en\)\)\s*if \(said\.stop\) return/) - expect(block).not.toMatch(/'error' in r|'unknown' in r|r\.txid|end\./) +describe('/net sends nothing of its own', () => { + // It used to execute a ready proposal and read the reply here; that driver kept no record and + // checked no approval, so a proposal is now signed and sent from its signing panel (net-execute.test.ts). + it('NetVault never calls executeProposal', () => { + expect(codeOf('./screens/NetVault.tsx')).not.toMatch(/\bexecuteProposal\b/) }) }) From d3cd63b1969ae4bd37e56ad38b815720d3522861 Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Thu, 12 Nov 2026 21:16:07 -0300 Subject: [PATCH 04/12] security(ui): show an earlier transaction only when the device can confirm it signed it Co-Authored-By: Claude --- ui/src/VaultSigner.tsx | 15 +++++++++++---- ui/src/i18n/en.ts | 2 ++ ui/src/i18n/pt-BR.ts | 2 ++ ui/src/owner-signing.test.ts | 2 +- ui/src/owner-signing.ts | 9 +++++---- ui/src/screens/SigningPanel.tsx | 15 ++++++++++++++- ui/src/signed-record.test.ts | 17 +++++++++++++++++ ui/src/signed-record.ts | 10 ++++++++++ ui/src/signing-panel.test.tsx | 9 +++++++++ 9 files changed, 71 insertions(+), 10 deletions(-) diff --git a/ui/src/VaultSigner.tsx b/ui/src/VaultSigner.tsx index fd26f248..934e3bc2 100644 --- a/ui/src/VaultSigner.tsx +++ b/ui/src/VaultSigner.tsx @@ -16,7 +16,7 @@ import { uaReceiver } from './wasm-pkg/konclave_wasm.js' import { useBackgroundSigner, type BackgroundSignerState } from './useBackgroundSigner' import { ARM_TTL_MS, makeSigningGate } from './signing-gate' import { OwnerSigning } from './owner-signing' -import { browserRecordStore } from './signed-record' +import { browserRecordStore, txidIsOurs } from './signed-record' import type { FailureCode } from './background-session' import { deviceRefusal, unlockedHere } from './session' import { mayContribute } from './approved-payment' @@ -45,7 +45,7 @@ interface VaultSignerCtx { armActive: (opts?: { again?: boolean }) => Promise /** A second transaction for this payment was refused: until when the first one stays valid. */ /** The first transaction's expiry block, and when this device stops counting it (unix seconds, 0 when unknown). */ - signedBefore: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null } | null + signedBefore: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number } } | null /** When this device's signature stops counting (epoch ms), or null when it is not armed. */ armedUntil: number | null /** What the active proposal asks to pay, from the read the gate compares: undefined until read, @@ -121,6 +121,9 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) { // The owner signs a PROPOSAL, because that is what they see and click; the consent it gives covers // the first transaction this device clears for it (`consent` below). const activeIdRef = useRef(null) + // The open proposal's txid as the coordinator answers it: shown after a lapse only if this device signed it. + const activeTxidRef = useRef(null) + activeTxidRef.current = active?.txid ?? null activeIdRef.current = active?.id ?? null // #281: what did the quorum actually approve, in the bytes the note is bound to? // @@ -188,7 +191,7 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) { vaultIdRef.current = vault?.id ?? null // A second transaction for an approval this device already signed, refused: what the panel tells // its owner (until when the first stays valid) before they may sign again knowing it. - const [signedBefore, setSignedBefore] = useState<{ proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null } | null>(null) + const [signedBefore, setSignedBefore] = useState<{ proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number } } | null>(null) const gate = useMemo( () => makeSigningGate({ mode: () => 'manual', @@ -221,7 +224,11 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) { const lapsed = owner.current.lastLapsed if (r === 'lapsed' && lapsed) { const at = lapsed.earlier.length ? Math.max(...lapsed.earlier) : 0 - setSignedBefore({ proposal: lapsed.proposal, firstExpiry: at, firstUntil: 0, untilHeight: null, byEpoch: false, lapsedAt: at }) + // A txid handed to the device is shown only when it is one this device signed (R5): the + // coordinator cannot make the screen point at another transaction. + const cand = activeTxidRef.current + const ours = cand ? await txidIsOurs(cand, lapsed.hashes ?? []) : false + setSignedBefore({ proposal: lapsed.proposal, firstExpiry: at, firstUntil: 0, untilHeight: null, byEpoch: false, lapsedAt: at, lapsedTxid: ours ? cand : null, lapsedOf: { k: ours ? 1 : 0, n: lapsed.earlier.length } }) } return r === 'ok' }, diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts index cccb4c14..139422eb 100644 --- a/ui/src/i18n/en.ts +++ b/ui/src/i18n/en.ts @@ -1084,6 +1084,8 @@ export const en: Record = { 'signing.againChecked': 'I checked the book: this payment does not show as sent.', 'signing.againLapsedBody': 'This device already signed an earlier attempt at this payment. That attempt can no longer be mined, but this device cannot tell whether it was mined before then: it could have been, up to block {block}. If it was, signing again pays {amt} ZEC a second time. If you have the earlier transaction id, look it up in a block explorer: if it is there, the payment went out.', 'signing.againLapsedChecked': 'I know the earlier attempt was not mined.', + 'signing.againLapsedTxid': 'Checked on this device as a transaction it signed ({k} of {n} earlier attempts identified). Look it up in a block explorer:', + 'signing.againLapsedNoTxid': 'This device cannot show the transaction id of the earlier attempt, so it cannot point you to it. Do not sign again unless you know it was not mined.', 'signing.againCta': 'Sign again: {amt} ZEC', 'signing.readAgainHeading': 'What the vault built this time', 'signing.againCtaSend': 'Sign again and send: {amt} ZEC', diff --git a/ui/src/i18n/pt-BR.ts b/ui/src/i18n/pt-BR.ts index 173e2df7..5b3e2aa6 100644 --- a/ui/src/i18n/pt-BR.ts +++ b/ui/src/i18n/pt-BR.ts @@ -1080,6 +1080,8 @@ export const ptBR: Record = { 'signing.againChecked': 'Conferi o livro: este pagamento não aparece como enviado.', 'signing.againLapsedBody': 'Este aparelho já assinou uma tentativa anterior deste pagamento. Aquela tentativa não pode mais ser minerada, mas este aparelho não sabe dizer se ela foi minerada antes disso: pode ter sido, até o bloco {block}. Se foi, assinar de novo paga {amt} ZEC uma segunda vez. Se você tem o id da transação anterior, procure-o num explorador de blocos: se ele estiver lá, o pagamento saiu.', 'signing.againLapsedChecked': 'Sei que a tentativa anterior não foi minerada.', + 'signing.againLapsedTxid': 'Conferida neste aparelho como uma transação que ele assinou ({k} de {n} tentativas anteriores identificadas). Procure-a num explorador de blocos:', + 'signing.againLapsedNoTxid': 'Este aparelho não consegue mostrar o id da transação anterior, então não pode indicá-la. Não assine de novo a menos que saiba que ela não foi minerada.', 'signing.againCta': 'Assinar de novo: {amt} ZEC', 'signing.readAgainHeading': 'O que o cofre montou desta vez', 'signing.againCtaSend': 'Assinar de novo e enviar: {amt} ZEC', diff --git a/ui/src/owner-signing.test.ts b/ui/src/owner-signing.test.ts index 1c747b99..ae3f4389 100644 --- a/ui/src/owner-signing.test.ts +++ b/ui/src/owner-signing.test.ts @@ -158,7 +158,7 @@ describe('the owner signing on this device', () => { await o.record(A, DEAD, NU63) o.give('p1', Date.now()) expect(await o.claim('v1', 'p1', B, ALIVE)).toBe('lapsed') - expect(o.lastLapsed).toEqual({ proposal: 'p1', sighash: B, earlier: [DEAD] }) + expect(o.lastLapsed).toMatchObject({ proposal: 'p1', sighash: B, earlier: [DEAD] }) expect(o.allows('p1'), 'the refused click is spent').toBe(false) }) diff --git a/ui/src/owner-signing.ts b/ui/src/owner-signing.ts index 3c83b10b..0ad0a119 100644 --- a/ui/src/owner-signing.ts +++ b/ui/src/owner-signing.ts @@ -29,7 +29,7 @@ export class OwnerSigning { * mined: the device does not know whether one WAS mined, so signing again may pay twice. For the * screen: which approval, the transaction refused, and the block each earlier one expired at * (the most it could have been mined at). */ - lastLapsed: { proposal: string; sighash: string; earlier: number[] } | null = null + lastLapsed: { proposal: string; sighash: string; earlier: number[]; hashes?: string[] } | null = null private readonly store: RecordStore private readonly now: () => number @@ -95,9 +95,10 @@ export class OwnerSigning { // been. Never silent: the owner answers for this transaction, as for a second one (A1). if (decision.kind === 'afterExpiry' && !answered) { this.consent.spent = true - let earlier: number[] = [] - try { earlier = (earlierList ?? readSigned(this.store, vault, activeId)).map((t) => t.e) } catch { /* decided above */ } - this.lastLapsed = { proposal: activeId, sighash: h, earlier } + let list: SignedTx[] = [] + try { list = earlierList ?? readSigned(this.store, vault, activeId) } catch { /* decided above */ } + // The hashes let the screen show an earlier txid it is handed only when this device signed it. + this.lastLapsed = { proposal: activeId, sighash: h, earlier: list.map((t) => t.e), hashes: list.map((t) => t.h) } return 'lapsed' } if (!claimConsent(this.consent, activeId, this.now(), sighashHex)) return 'refused' diff --git a/ui/src/screens/SigningPanel.tsx b/ui/src/screens/SigningPanel.tsx index a52d50a9..9a376923 100644 --- a/ui/src/screens/SigningPanel.tsx +++ b/ui/src/screens/SigningPanel.tsx @@ -104,7 +104,7 @@ function PlanLines({ lines: rows }: { lines: ApprovedPlan['lines'] }) { ) } -export function SignConfirm({ payrollLines, destPublic = false, again, firstUntil, byEpoch = false, untilHeight = null, lapsedAt = null, locale, amt, usd, dest, isPayroll, iWouldBeLast, t, onCancel, onConfirm, now = Date.now(), fee, total }: { +export function SignConfirm({ payrollLines, destPublic = false, again, firstUntil, byEpoch = false, untilHeight = null, lapsedAt = null, lapsedTxid = null, lapsedOf, locale, amt, usd, dest, isPayroll, iWouldBeLast, t, onCancel, onConfirm, now = Date.now(), fee, total }: { again: boolean /** When this device stops counting the earlier attempt (unix seconds, from `approvalDecision`); 0 when unknown. */ firstUntil: number @@ -116,6 +116,10 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti /** Every earlier attempt can no longer be mined, and this device cannot tell whether one was: the * highest block one could have been mined at. Its own wording, never silent (A1). */ lapsedAt?: number | null + /** An earlier attempt's txid, checked on this device as one it signed; null when none could be. */ + lapsedTxid?: string | null + /** How many earlier attempts there are, and how many of them the screen could identify. */ + lapsedOf?: { k: number; n: number } locale: 'pt-BR' | 'en' amt: string usd?: string | null @@ -173,6 +177,13 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti {iWouldBeLast && <> {t('signing.confirmBody')}}

{until &&

{until}

} + {lapsedAt !== null && ( +

+ {lapsedTxid + ? <>{t('signing.againLapsedTxid', { k: lapsedOf?.k ?? 1, n: lapsedOf?.n ?? 1 })} {lapsedTxid} + : t('signing.againLapsedNoTxid')} +

+ )}