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}
}
setChecked(e.target.checked)} />
- {t('signing.againChecked')}
+ {lapsedAt !== null ? t('signing.againLapsedChecked') : t('signing.againChecked')}
>
) : (
@@ -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 }: {
void loadProposals()}>
{pe('Atualizar', 'Refresh')}
- {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. */}
void execProp(p.id)}
+ onClick={() => { setSelectedVault(groupVk); nav('/proposal', { state: { id: p.id } }) }}
>
- {sendingId === p.id
- ? pe('Enviando…', 'Sending…')
- : room
- ? pe('Assinar e enviar', 'Sign & send')
- : pe('Inicie uma sessão de assinatura', 'Start a signing session')}
+ {pe('Abrir para assinar', 'Open to sign')}
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')}
+
+ )}
setChecked(e.target.checked)} />
{lapsedAt !== null ? t('signing.againLapsedChecked') : t('signing.againChecked')}
@@ -979,6 +990,8 @@ export default function SigningPanel() {
byEpoch={signedBefore?.byEpoch ?? false}
untilHeight={signedBefore?.untilHeight ?? null}
lapsedAt={signedBefore?.lapsedAt ?? null}
+ lapsedTxid={signedBefore?.lapsedTxid ?? null}
+ lapsedOf={signedBefore?.lapsedOf}
fee={amountZat !== null && feeKnown ? fmtZecExact(feeZat / 1e8) : undefined}
total={amountZat !== null && feeKnown ? fmtZecExact((amountZat + feeZat) / 1e8) : undefined}
locale={locale}
diff --git a/ui/src/signed-record.test.ts b/ui/src/signed-record.test.ts
index 189fce60..24f93991 100644
--- a/ui/src/signed-record.test.ts
+++ b/ui/src/signed-record.test.ts
@@ -184,3 +184,20 @@ describe('what a new transaction is, by the rule in WASM', () => {
expect(await decideFor(t, 'v1', 'p1', B)).toEqual({ kind: 'afterExpiry' })
})
})
+
+// A txid the device is handed (the ledger, the proposal answer) is shown as one it signed only when it
+// is: for a transaction with no transparent input the txid is the sighash byte-reversed (ZIP 244), and
+// the record keeps SHA-256 of the sighash.
+describe('txidIsOurs', () => {
+ it('matches a txid to the record entry of its sighash, and nothing else', async () => {
+ const { txidIsOurs, hashSighash: hs } = await import('./signed-record')
+ const sighash = '01' + 'ab'.repeat(30) + 'ff'
+ const txid = sighash.match(/../g)!.reverse().join('')
+ const h = await hs(sighash)
+ expect(await txidIsOurs(txid, [h])).toBe(true)
+ expect(await txidIsOurs(txid.toUpperCase(), [h])).toBe(true)
+ expect(await txidIsOurs(sighash, [h]), 'not reversed').toBe(false)
+ expect(await txidIsOurs(txid, [])).toBe(false)
+ expect(await txidIsOurs('zz', [h])).toBe(false)
+ })
+})
diff --git a/ui/src/signed-record.ts b/ui/src/signed-record.ts
index bb2722b2..69167d84 100644
--- a/ui/src/signed-record.ts
+++ b/ui/src/signed-record.ts
@@ -50,6 +50,16 @@ export async function hashSighash(sighashHex: string): Promise {
return Array.from(d, (b) => b.toString(16).padStart(2, '0')).join('')
}
+/** Whether `txid` is one of the transactions whose sighash hashes are `hashes`. For a transaction
+ * with no transparent input the txid is the sighash byte-reversed (ZIP 244: the signature digest is
+ * the txid digest; konclave-wasm `read_for_signing_with`). A txid that is not 64 hex digits is not ours. */
+export async function txidIsOurs(txid: string, hashes: string[]): Promise {
+ const t = txid.trim().toLowerCase()
+ if (!/^[0-9a-f]{64}$/.test(t)) return false
+ const sighash = t.match(/../g)!.reverse().join('')
+ return hashes.includes(await hashSighash(sighash))
+}
+
function readAll(store: RecordStore, vault: string): Record {
// A record that cannot be read throws: not knowing what this device signed must never read as
// having signed nothing.
diff --git a/ui/src/signing-panel.test.tsx b/ui/src/signing-panel.test.tsx
index 493a2aa6..cff7c9c8 100644
--- a/ui/src/signing-panel.test.tsx
+++ b/ui/src/signing-panel.test.tsx
@@ -296,6 +296,15 @@ describe('the confirm for signing again', () => {
const cta = html.slice(html.lastIndexOf(' {
+ const tx = 'ab'.repeat(32)
+ const shown = renderToStaticMarkup( )
+ expect(shown).toContain('signing.againLapsedTxid k=1 n=2')
+ expect(shown).toContain(tx)
+ const none = renderToStaticMarkup( )
+ expect(none).toContain('signing.againLapsedNoTxid')
+ expect(none).not.toContain('signing.againLapsedTxid')
+ })
it('claims no time that has already passed', () => {
const html = renderToStaticMarkup( )
expect(html).not.toContain('signing.againUntil')
From 8e4290f3954e197656df77d1031c220abc7bb12e Mon Sep 17 00:00:00 2001
From: Daniel Gorgonha
Date: Thu, 12 Nov 2026 21:16:22 -0300
Subject: [PATCH 05/12] docs(ui): changelog fragments and an honest comment for
the signing record
Co-Authored-By: Claude
---
ui/changelog.d/known-limits/signing-record-residuals.md | 5 +++++
ui/changelog.d/security/device-signing-record.md | 5 +++++
ui/src/signed-record.ts | 8 +++++---
3 files changed, 15 insertions(+), 3 deletions(-)
create mode 100644 ui/changelog.d/known-limits/signing-record-residuals.md
create mode 100644 ui/changelog.d/security/device-signing-record.md
diff --git a/ui/changelog.d/known-limits/signing-record-residuals.md b/ui/changelog.d/known-limits/signing-record-residuals.md
new file mode 100644
index 00000000..c761d1be
--- /dev/null
+++ b/ui/changelog.d/known-limits/signing-record-residuals.md
@@ -0,0 +1,5 @@
+- **What a device remembers about its own signatures stays on that device.** A second device of the
+ same seat, a restored device, or a browser whose site data was cleared does not know what was
+ signed before, and asks as for a first signature. A backup does not carry this record. The
+ standalone `/net` page, without a coordinator configured, still has its own signing for creation
+ and the lab (#627).
diff --git a/ui/changelog.d/security/device-signing-record.md b/ui/changelog.d/security/device-signing-record.md
new file mode 100644
index 00000000..3b98345d
--- /dev/null
+++ b/ui/changelog.d/security/device-signing-record.md
@@ -0,0 +1,5 @@
+- **Your device is more careful with a payment it already signed.** It asks before signing a payment
+ again in more cases than before, recognises the same payment when it comes back under another
+ proposal, and shows an earlier transaction only when it can confirm it signed it. Proposals are now
+ signed from their signing panel; the standalone `/net` page no longer executes them. The details
+ follow in a later entry.
diff --git a/ui/src/signed-record.ts b/ui/src/signed-record.ts
index 69167d84..04d08ad5 100644
--- a/ui/src/signed-record.ts
+++ b/ui/src/signed-record.ts
@@ -13,9 +13,11 @@
// not keep the branch while its engine builds the later one, so there an absent branch is UNKNOWN.
// A NEW entry always carries a branch: `recordSigned` refuses to write one without.
//
-// A hash, not the sighash: the sighash gives the txid, and the device's storage need not say which
-// transactions it signed. The record lives in the browser's own storage so it outlives a reload,
-// which a record in memory did not.
+// A hash of the sighash, not the sighash. It is NOT a privacy boundary: the hash is unsalted and sits
+// beside the expiry, so whoever reads this storage can hash the public txids of each expiry window and
+// find which transactions this device signed (the txid is the sighash byte-reversed). It is kept as a
+// hash because the device only needs to compare. A keyed record is a separate item. The record lives in
+// the browser's own storage so it outlives a reload, which a record in memory did not.
import { approvalDecision } from './wasm-pkg/konclave_wasm.js'
export interface RecordStore {
From 7f4cda73791026fa9a79bacce018a1364fef72ea Mon Sep 17 00:00:00 2001
From: Daniel Gorgonha
Date: Thu, 12 Nov 2026 21:31:13 -0300
Subject: [PATCH 06/12] security(ui): address the review of the signing record
changes
Co-Authored-By: Claude
---
.../known-limits/signing-record-residuals.md | 4 +-
.../security/device-signing-record.md | 5 ++-
ui/src/VaultSigner.tsx | 8 ++--
ui/src/approved-payment.ts | 2 +-
ui/src/i18n/en.ts | 14 ++++---
ui/src/i18n/pt-BR.ts | 14 ++++---
ui/src/net-execute.test.ts | 4 ++
ui/src/owner-signing.test.ts | 41 +++++++++++++++++--
ui/src/owner-signing.ts | 10 +++--
ui/src/screens/NetVault.tsx | 13 +++---
ui/src/screens/SigningPanel.tsx | 34 ++++++++++-----
ui/src/send-attempt.test.ts | 8 ----
ui/src/signed-record.test.ts | 18 +++++++-
ui/src/signed-record.ts | 4 +-
ui/src/signing-panel.test.tsx | 19 +++++++--
ui/src/vault-signer.test.tsx | 12 ++++++
16 files changed, 153 insertions(+), 57 deletions(-)
diff --git a/ui/changelog.d/known-limits/signing-record-residuals.md b/ui/changelog.d/known-limits/signing-record-residuals.md
index c761d1be..2b16a1bc 100644
--- a/ui/changelog.d/known-limits/signing-record-residuals.md
+++ b/ui/changelog.d/known-limits/signing-record-residuals.md
@@ -1,5 +1,3 @@
- **What a device remembers about its own signatures stays on that device.** A second device of the
same seat, a restored device, or a browser whose site data was cleared does not know what was
- signed before, and asks as for a first signature. A backup does not carry this record. The
- standalone `/net` page, without a coordinator configured, still has its own signing for creation
- and the lab (#627).
+ signed before, and asks as for a first signature. A backup does not carry this record.
diff --git a/ui/changelog.d/security/device-signing-record.md b/ui/changelog.d/security/device-signing-record.md
index 3b98345d..c55b278c 100644
--- a/ui/changelog.d/security/device-signing-record.md
+++ b/ui/changelog.d/security/device-signing-record.md
@@ -1,5 +1,6 @@
- **Your device is more careful with a payment it already signed.** It asks before signing a payment
again in more cases than before, recognises the same payment when it comes back under another
proposal, and shows an earlier transaction only when it can confirm it signed it. Proposals are now
- signed from their signing panel; the standalone `/net` page no longer executes them. The details
- follow in a later entry.
+ signed only from their signing panel; the standalone `/net` page no longer signs them. This narrows
+ the risk of a payment going out twice; it does not close it until approvals are bound to what a
+ payment pays. The details follow in a later entry.
diff --git a/ui/src/VaultSigner.tsx b/ui/src/VaultSigner.tsx
index 934e3bc2..1b1feca7 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; lapsedAt?: number | null; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number } } | null
+ signedBefore: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number }; sameOutputs?: boolean } | 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,
@@ -191,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; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number } } | 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 }; sameOutputs?: boolean } | null>(null)
const gate = useMemo(
() => makeSigningGate({
mode: () => 'manual',
@@ -219,7 +219,7 @@ 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, lapsedAt: null })
+ if (r === 'second' && last) setSignedBefore({ proposal: last.proposal, firstExpiry: last.firstExpiry, firstUntil: last.firstUntil, untilHeight: last.untilHeight, byEpoch: last.byEpoch, lapsedAt: null, sameOutputs: !!last.sameOutputs })
// 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) {
@@ -228,7 +228,7 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) {
// 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 } })
+ 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 }, sameOutputs: !!lapsed.sameOutputs })
}
return r === 'ok'
},
diff --git a/ui/src/approved-payment.ts b/ui/src/approved-payment.ts
index 940ad2be..2621c4ba 100644
--- a/ui/src/approved-payment.ts
+++ b/ui/src/approved-payment.ts
@@ -287,7 +287,7 @@ export function forgetPlansForTests(): void {
* 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 rows = lines.map((l) => [norm(l.toReceiver), l.amountZat, l.memo.normalize('NFC')]).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/i18n/en.ts b/ui/src/i18n/en.ts
index 139422eb..18993b5f 100644
--- a/ui/src/i18n/en.ts
+++ b/ui/src/i18n/en.ts
@@ -1082,10 +1082,14 @@ 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.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.againLapsedBody': 'This device already signed an earlier attempt at this payment. By this device\'s clock it cannot go out after block {block}, but the device cannot tell whether it went out before then. If it did, signing again pays {amt} ZEC a second time.',
+ 'signing.againLapsedBodyMany': 'This device already signed {n} earlier attempts at this payment. By this device\'s clock none of them can go out after block {block}, but the device cannot tell whether one went out before then. If one did, signing again pays {amt} ZEC a second time.',
+ 'signing.againLapsedPartial': 'It could identify {k} of its {n} earlier attempts. The others cannot be checked here: ask the other members whether the payment went out.',
+ 'signing.againSameBody': 'Recently, this device signed a payment of exactly these recipients, amounts and memos under another proposal. If this is that same payment again, signing pays {amt} ZEC a second time. If it is a separate payment, you can sign.',
+ 'signing.againSameChecked': 'This is a separate payment, not the earlier one again.',
+ 'signing.againLapsedChecked': 'I checked: no earlier attempt at this payment went out, and the explorer\'s latest block is past {block}. If I am wrong, this pays it twice.',
+ 'signing.againLapsedTxid': 'This device checked that it signed the transaction below. Look it up in a block explorer: if the explorer shows it, the payment went out, so do not sign.',
+ 'signing.againLapsedNoTxid': 'This device cannot show the transaction id of the earlier attempt, so it cannot point you to it. Ask the other members whether the payment went out before signing again.',
'signing.againCta': 'Sign again: {amt} ZEC',
'signing.readAgainHeading': 'What the vault built this time',
'signing.againCtaSend': 'Sign again and send: {amt} ZEC',
@@ -1371,7 +1375,7 @@ export const en: Record = {
'net.err.unreadableSpends': 'Refused to sign: this device could not read which parts of the transaction need its signature. Nothing was signed. The reason follows.',
'net.err.relayDown': 'Could not reach the relay (the network went down). Reload both tabs and try again.',
'net.err.notApproved': 'Refused to sign: this transaction does not pay exactly what the group approved. Nothing was signed. What this device read is below; memos are checked too, but not shown. Do not try again, and tell the other members.',
- 'net.err.signedAnother': 'Not signed: this device already signed this payment once, and the vault has since built it again. Nothing was signed this time. Check the book first. If the payment shows as sent, stop here. If it does not, you can sign again.',
+ 'net.err.signedAnother': 'Not signed: this device already signed this payment once, and the vault has since built it again. Nothing was signed this time. Check first that the payment did not go out. If it did, stop here. If it did not, you can sign again.',
'net.err.approvalUnknown': 'Refused to sign: this device could not load what the group approved, or its own record of the vault, so it did not check this transaction against them. Nothing was signed. This alone does not show tampering, but the coordinator can cause it: reload Konclave on this device and open the proposal again. If it keeps happening, do not sign, and tell the other members.',
'net.err.feeClock': "Refused to sign: the network fee in this transaction is lower than this device accepts for its date and time. That can mean this device's date or time is set too early, or that the transaction was built at a rate this vault does not use yet. Nothing was signed. If this device's date or time is wrong, correct it, but never set it ahead of the real time to make it sign. If they are right, do not try again, and tell the other members.",
'net.err.feeTooHigh': 'Refused to sign: the network fee in this transaction is not the standard fee for its size. Higher, the difference would leave the vault; lower, the network may not relay it. Nothing was signed. This can mean the request was tampered with: do not try again, and tell the other members.',
diff --git a/ui/src/i18n/pt-BR.ts b/ui/src/i18n/pt-BR.ts
index 5b3e2aa6..97256c44 100644
--- a/ui/src/i18n/pt-BR.ts
+++ b/ui/src/i18n/pt-BR.ts
@@ -1078,10 +1078,14 @@ 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.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.againLapsedBody': 'Este aparelho já assinou uma tentativa anterior deste pagamento. Pelo relógio do aparelho, ela não pode mais sair depois do bloco {block}, mas o aparelho não sabe dizer se ela saiu antes disso. Se saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
+ 'signing.againLapsedBodyMany': 'Este aparelho já assinou {n} tentativas anteriores deste pagamento. Pelo relógio do aparelho, nenhuma delas pode sair depois do bloco {block}, mas o aparelho não sabe dizer se alguma saiu antes disso. Se saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
+ 'signing.againLapsedPartial': 'Foi possível identificar {k} das {n} tentativas anteriores. As outras não podem ser conferidas aqui: pergunte aos outros membros se o pagamento saiu.',
+ 'signing.againSameBody': 'Há pouco tempo, este aparelho assinou um pagamento com exatamente estes destinatários, valores e memos, em outra proposta. Se este for o mesmo pagamento de novo, assinar paga {amt} ZEC uma segunda vez. Se for um pagamento separado, você pode assinar.',
+ 'signing.againSameChecked': 'Este é um pagamento separado, não o anterior de novo.',
+ 'signing.againLapsedChecked': 'Conferi: nenhuma tentativa anterior deste pagamento saiu, e o último bloco do explorador já passou de {block}. Se eu tiver me enganado, isto paga duas vezes.',
+ 'signing.againLapsedTxid': 'Este aparelho conferiu que assinou a transação abaixo. Procure-a num explorador de blocos: se ela aparecer lá, o pagamento saiu, então não assine.',
+ 'signing.againLapsedNoTxid': 'Este aparelho não consegue mostrar o id da transação anterior, então não pode indicá-la. Pergunte aos outros membros se o pagamento saiu antes de assinar de novo.',
'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',
@@ -1367,7 +1371,7 @@ export const ptBR: Record = {
'net.err.unreadableSpends': 'Assinatura recusada: este aparelho não conseguiu ler quais partes da transação precisam da assinatura dele. Nada foi assinado. O motivo vem a seguir.',
'net.err.relayDown': 'Não consegui falar com o relay (a rede caiu). Recarregue as duas abas e refaça.',
'net.err.notApproved': 'Assinatura recusada: esta transação não paga exatamente o que o grupo aprovou. Nada foi assinado. O que este aparelho leu está abaixo; os memos também são conferidos, mas não aparecem. Não tente de novo e avise os outros membros.',
- 'net.err.signedAnother': 'Não assinado: este aparelho já assinou este pagamento uma vez, e o cofre o montou de novo. Nada foi assinado desta vez. Confira o livro primeiro. Se o pagamento aparecer como enviado, pare aqui. Se não aparecer, você pode assinar de novo.',
+ 'net.err.signedAnother': 'Não assinado: este aparelho já assinou este pagamento uma vez, e o cofre o montou de novo. Nada foi assinado desta vez. Confira antes que o pagamento não saiu. Se saiu, pare aqui. Se não saiu, você pode assinar de novo.',
'net.err.approvalUnknown': 'Assinatura recusada: este aparelho não conseguiu carregar o que o grupo aprovou, ou o próprio registro do cofre, então não conferiu esta transação com eles. Nada foi assinado. Isso sozinho não mostra adulteração, mas o coordenador pode provocá-lo: recarregue o Konclave neste aparelho e abra a proposta de novo. Se continuar acontecendo, não assine e avise os outros membros.',
'net.err.feeClock': 'Assinatura recusada: a taxa da rede nesta transação é menor do que este aparelho aceita para a data e a hora dele. Isso pode querer dizer que a data ou a hora deste aparelho está atrasada, ou que a transação foi montada numa taxa que este cofre ainda não usa. Nada foi assinado. Se a data ou a hora deste aparelho estiver errada, corrija, mas nunca adiante além da hora real para ele assinar. Se estiverem certas, não tente de novo e avise os outros membros.',
'net.err.feeTooHigh': 'Assinatura recusada: a taxa da rede nesta transação não é a taxa padrão para o tamanho dela. Maior, a diferença sairia do cofre; menor, a rede pode não transmiti-la. Nada foi assinado. Isso pode significar um pedido adulterado: não tente de novo e avise os outros membros.',
diff --git a/ui/src/net-execute.test.ts b/ui/src/net-execute.test.ts
index a6bb4d62..4a0733c0 100644
--- a/ui/src/net-execute.test.ts
+++ b/ui/src/net-execute.test.ts
@@ -10,6 +10,10 @@ describe('/net does not execute a proposal where a coordinator is configured', (
it('the page has no way to execute a proposal: it never calls executeProposal', () => {
expect(src).not.toMatch(/\bexecuteProposal\b/)
})
+ it('its signer contributes to no coordinator request: the claim refuses, so no share leaves', () => {
+ expect(src).toMatch(/claim: \(\) => false,/)
+ expect(src).not.toMatch(/claim: \(\) => true/)
+ })
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/owner-signing.test.ts b/ui/src/owner-signing.test.ts
index ae3f4389..3951ef0c 100644
--- a/ui/src/owner-signing.test.ts
+++ b/ui/src/owner-signing.test.ts
@@ -6,6 +6,8 @@ import { OwnerSigning } from './owner-signing'
import { ARM_TTL_MS } from './signing-gate'
import { readSigned, type RecordStore } from './signed-record'
import { pinClock, unpinClock } from './test-vectors'
+import { readdirSync } from 'node:fs'
+import { codeOf } from './source-scan'
// What the device did with an earlier transaction depends on the clock (WASM reads `Date.now()`), so
// these tests run at a pinned instant. On the real clock they pass until the upgrade and fail after.
@@ -57,7 +59,7 @@ describe('the owner signing on this device', () => {
reloaded.give('p1', Date.now())
// ...does not cover another transaction for the same approval while the first is alive.
expect(await reloaded.claim('v1', 'p1', B, ALIVE)).toEqual('second')
- expect(reloaded.lastSecond).toEqual({ proposal: 'p1', firstExpiry: ALIVE, firstUntil: expect.any(Number), untilHeight: null, byEpoch: false, sighash: B })
+ expect(reloaded.lastSecond).toEqual({ proposal: 'p1', firstExpiry: ALIVE, firstUntil: expect.any(Number), untilHeight: null, byEpoch: false, sighash: B, sameOutputs: false })
expect(reloaded.allows('p1'), 'the refused click is spent').toBe(false)
})
@@ -215,8 +217,41 @@ describe('the owner signing on this device', () => {
o.give('p1', now)
await o.claim('v1', 'p1', A, DEAD, 'K')
await o.record(A, DEAD, NU63)
- now += 8 * 24 * 3600 * 1000
+ // Inside the horizon the same payment is recognised; past it, it is not.
+ now += 6 * 24 * 3600 * 1000
o.give('p2', now)
- expect(await o.claim('v1', 'p2', B, ALIVE, 'K')).toBe('ok')
+ expect(await o.claim('v1', 'p2', B, ALIVE, 'K')).toBe('lapsed')
+ now += 2 * 24 * 3600 * 1000
+ o.give('p3', now)
+ expect(await o.claim('v1', 'p3', B, ALIVE, 'K')).toBe('ok')
+ })
+
+ it('a decision from another proposal paying the same lines is marked, so the screen can ask whether it is a separate payment', async () => {
+ const store = memory()
+ const o = new OwnerSigning(store)
+ o.give('p1', Date.now())
+ await o.claim('v1', 'p1', A, ALIVE, 'K')
+ await o.record(A, ALIVE, NU63)
+ o.give('p2', Date.now())
+ expect(await o.claim('v1', 'p2', B, ALIVE, 'K')).toBe('second')
+ expect(o.lastSecond?.sameOutputs).toBe(true)
+ o.give('p1', Date.now())
+ expect(await o.claim('v1', 'p1', B, ALIVE, 'K')).toBe('second')
+ expect(o.lastSecond?.sameOutputs, 'under its own proposal it is not the bridge').toBe(false)
+ })
+})
+
+// The pin: one place handles what the WASM rule decides. No driver reads `afterExpiry` itself.
+describe('the pin: only OwnerSigning handles the decision', () => {
+ it('no source file but owner-signing.ts and signed-record.ts names afterExpiry', () => {
+ const hits: string[] = []
+ const walk = (sub: string) => {
+ for (const e of readdirSync(new URL(sub, new URL('./', import.meta.url)), { withFileTypes: true })) {
+ if (e.isDirectory()) { if (e.name !== 'wasm-pkg') walk(`${sub}${e.name}/`) }
+ else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name) && /afterExpiry/.test(codeOf(`./${sub}${e.name}`))) hits.push(`${sub}${e.name}`)
+ }
+ }
+ walk('')
+ expect(hits.sort()).toEqual(['owner-signing.ts', 'signed-record.ts'])
})
})
diff --git a/ui/src/owner-signing.ts b/ui/src/owner-signing.ts
index 0ad0a119..5ead7fa1 100644
--- a/ui/src/owner-signing.ts
+++ b/ui/src/owner-signing.ts
@@ -24,12 +24,12 @@ export class OwnerSigning {
* 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; 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
+ lastSecond: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; sighash: string; sameOutputs?: boolean } | 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[]; hashes?: string[] } | null = null
+ lastLapsed: { proposal: string; sighash: string; earlier: number[]; hashes?: string[]; sameOutputs?: boolean } | null = null
private readonly store: RecordStore
private readonly now: () => number
@@ -87,7 +87,9 @@ export class OwnerSigning {
const h = sighashHex.trim().toLowerCase()
if (decision.kind === 'second' && !(this.again?.proposal === activeId && this.again.sighash === h)) {
this.consent.spent = true
- this.lastSecond = { proposal: activeId, firstExpiry: decision.firstExpiry, firstUntil: decision.untilUnix ?? 0, untilHeight: decision.untilHeight, byEpoch: decision.byEpoch, sighash: h }
+ // `sameOutputs`: the earlier transaction was under ANOTHER proposal that paid the same lines; it
+ // may be a separate, honest payment, so the screen asks differently.
+ this.lastSecond = { proposal: activeId, firstExpiry: decision.firstExpiry, firstUntil: decision.untilUnix ?? 0, untilHeight: decision.untilHeight, byEpoch: decision.byEpoch, sighash: h, sameOutputs: earlierList !== null }
return 'second'
}
const answered = this.again?.proposal === activeId && this.again.sighash === h
@@ -98,7 +100,7 @@ export class OwnerSigning {
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) }
+ this.lastLapsed = { proposal: activeId, sighash: h, earlier: list.map((t) => t.e), hashes: list.map((t) => t.h), sameOutputs: earlierList !== null }
return 'lapsed'
}
if (!claimConsent(this.consent, activeId, this.now(), sighashHex)) return 'refused'
diff --git a/ui/src/screens/NetVault.tsx b/ui/src/screens/NetVault.tsx
index 1c45473e..5a1c42de 100644
--- a/ui/src/screens/NetVault.tsx
+++ b/ui/src/screens/NetVault.tsx
@@ -276,8 +276,8 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: {
const [balance, setBalance] = useState<{ spend: string; total: string } | null>(null)
const [balanceBusy, setBalanceBusy] = useState(false)
// Ceremony trail (A3): the auditable record of every spend the helper drove for this vault.
- // 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.
+ // Proposals: proposing, voting and signing live in the app (the proposal's signing panel). /net
+ // only lists the READY ones and opens each there.
const [proposals, setProposals] = useState(null)
const [signPhase, setSignPhase] = useState<'none' | 'signing' | 'signed'>('none')
const [signature, setSignature] = useState('')
@@ -497,8 +497,11 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: {
// background signer have diverged three times (#424, #425, #363) because a rule lived in one
// and not the other. It cannot silently miss this one - it has to say so, here.
decideApproval: () => 'match',
- // /net binds no consent to a transaction (Known limits, with its missing content gate).
- claim: () => true,
+ // /net binds no consent to a transaction and keeps no record, so it contributes to no
+ // coordinator's signing request: a proposal is signed from its signing panel (VaultSigner),
+ // which does both. This is the only place a request reaches a share here, so /net signs none.
+ // (Creation does not go through it.)
+ claim: () => false,
// Nor does it keep a record of what it signed: /net has no proposal to key it by (Known limits).
record: async () => {},
seatOf: (tag) => seatByTagRef.current.get(tag),
@@ -1615,7 +1618,7 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: {
className="net-btn primary"
onClick={() => { setSelectedVault(groupVk); nav('/proposal', { state: { id: p.id } }) }}
>
- {pe('Abrir para assinar', 'Open to sign')}
+ {tt('dashboard.goSign')}
diff --git a/ui/src/screens/SigningPanel.tsx b/ui/src/screens/SigningPanel.tsx
index 9a376923..207d914a 100644
--- a/ui/src/screens/SigningPanel.tsx
+++ b/ui/src/screens/SigningPanel.tsx
@@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'
import { Dialog } from '../components'
import { Identicon } from '../avatar'
import { useI18n, useT, type TFn } from '../i18n'
+import CopyButton from '../CopyButton'
import { useToast } from '../toast'
import { fmtZecExact, zatToZec } from '../format'
import { useVaultSigner } from '../VaultSigner'
@@ -104,7 +105,7 @@ function PlanLines({ lines: rows }: { lines: ApprovedPlan['lines'] }) {
)
}
-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 }: {
+export function SignConfirm({ payrollLines, destPublic = false, again, firstUntil, byEpoch = false, untilHeight = null, lapsedAt = null, lapsedTxid = null, lapsedOf, sameOutputs = false, 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
@@ -120,6 +121,9 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
lapsedTxid?: string | null
/** How many earlier attempts there are, and how many of them the screen could identify. */
lapsedOf?: { k: number; n: number }
+ /** The earlier signature was for ANOTHER proposal that paid exactly these lines: it may be a
+ * separate payment, so the owner is asked whether this one is the same payment again. */
+ sameOutputs?: boolean
locale: 'pt-BR' | 'en'
amt: string
usd?: string | null
@@ -140,8 +144,8 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
}) {
const [checked, setChecked] = useState(false)
const title = again ? t('signing.againTitle') : iWouldBeLast ? t('signing.confirmTitle') : t('signing.confirmSignTitle')
- // The rule's own turning point: after it, a new attempt is signed without this confirm. Nothing
- // is said when it is unknown or already past (the confirm then no longer applies).
+ // The rule's own turning point: until then the earlier attempt may still go out. After it the
+ // device still asks (a lapse, below); nothing is said here when it is unknown or already past.
// 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 = lapsedAt !== null ? '' : firstUntil * 1000 > now
@@ -157,7 +161,7 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
className="modal-overlay"
cardClassName="modal-card danger sign-confirm"
labelledBy="sign-confirm-title"
- describedBy={again ? (until ? 'sign-again-body sign-again-until sign-confirm-what' : 'sign-again-body sign-confirm-what') : 'sign-confirm-what'}
+ describedBy={again ? ['sign-again-body', until && 'sign-again-until', lapsedAt !== null && 'sign-again-txid', 'sign-confirm-what'].filter(Boolean).join(' ') : 'sign-confirm-what'}
focusCard={again}
onClose={onCancel}
>
@@ -173,20 +177,27 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
{again ? (
<>
- {lapsedAt !== null ? t('signing.againLapsedBody', { amt, block: lapsedAt }) : t('signing.againBody', { amt })}
+ {sameOutputs
+ ? t('signing.againSameBody', { amt })
+ : lapsedAt !== null
+ ? t((lapsedOf?.n ?? 1) > 1 ? 'signing.againLapsedBodyMany' : 'signing.againLapsedBody', { amt, block: lapsedAt, n: lapsedOf?.n ?? 1 })
+ : t('signing.againBody', { amt })}
{iWouldBeLast && <> {t('signing.confirmBody')}>}
{until && {until}
}
{lapsedAt !== null && (
-
- {lapsedTxid
- ? <>{t('signing.againLapsedTxid', { k: lapsedOf?.k ?? 1, n: lapsedOf?.n ?? 1 })} {lapsedTxid}>
- : t('signing.againLapsedNoTxid')}
-
+ <>
+ {/* Announced without the 64-character id itself; the id follows, with a copy button. */}
+
+ {lapsedTxid ? t('signing.againLapsedTxid') : t('signing.againLapsedNoTxid')}
+ {lapsedTxid && (lapsedOf?.k ?? 1) < (lapsedOf?.n ?? 1) && <> {t('signing.againLapsedPartial', { k: lapsedOf?.k ?? 1, n: lapsedOf?.n ?? 1 })}>}
+
+ {lapsedTxid && {lapsedTxid}
}
+ >
)}
setChecked(e.target.checked)} />
- {lapsedAt !== null ? t('signing.againLapsedChecked') : t('signing.againChecked')}
+ {sameOutputs ? t('signing.againSameChecked') : lapsedAt !== null ? t('signing.againLapsedChecked', { block: lapsedAt }) : t('signing.againChecked')}
>
) : (
@@ -992,6 +1003,7 @@ export default function SigningPanel() {
lapsedAt={signedBefore?.lapsedAt ?? null}
lapsedTxid={signedBefore?.lapsedTxid ?? null}
lapsedOf={signedBefore?.lapsedOf}
+ sameOutputs={signedBefore?.sameOutputs ?? false}
fee={amountZat !== null && feeKnown ? fmtZecExact(feeZat / 1e8) : undefined}
total={amountZat !== null && feeKnown ? fmtZecExact((amountZat + feeZat) / 1e8) : undefined}
locale={locale}
diff --git a/ui/src/send-attempt.test.ts b/ui/src/send-attempt.test.ts
index 983035a9..853be744 100644
--- a/ui/src/send-attempt.test.ts
+++ b/ui/src/send-attempt.test.ts
@@ -63,11 +63,3 @@ describe('the panel goes through the send memory', () => {
expect(button).not.toMatch(/doSend/)
})
})
-
-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/)
- })
-})
diff --git a/ui/src/signed-record.test.ts b/ui/src/signed-record.test.ts
index 24f93991..930d907c 100644
--- a/ui/src/signed-record.test.ts
+++ b/ui/src/signed-record.test.ts
@@ -191,8 +191,10 @@ describe('what a new transaction is, by the rule in WASM', () => {
describe('txidIsOurs', () => {
it('matches a txid to the record entry of its sighash, and nothing else', async () => {
const { txidIsOurs, hashSighash: hs } = await import('./signed-record')
- const sighash = '01' + 'ab'.repeat(30) + 'ff'
- const txid = sighash.match(/../g)!.reverse().join('')
+ // Oracle values from another implementation: the repo's Ironwood vector, sighash from
+ // `konclave-signer extract`, txid from `zcash-devtool pczt inspect` (orchestrator/src/pczt.rs).
+ const sighash = '332de126200c22131337474ae50367218ec87815c23d297dcdc8278ecb8903b0'
+ const txid = 'b00389cb8e27c8cd7d293dc21578c88e216703e54a47371313220c2026e12d33'
const h = await hs(sighash)
expect(await txidIsOurs(txid, [h])).toBe(true)
expect(await txidIsOurs(txid.toUpperCase(), [h])).toBe(true)
@@ -201,3 +203,15 @@ describe('txidIsOurs', () => {
expect(await txidIsOurs('zz', [h])).toBe(false)
})
})
+
+describe('the record refuses an entry it cannot read, the new fields included', () => {
+ it('a payment key that is not text, or a time that is not a whole number, is unreadable', async () => {
+ const { readSigned, readByOutputs } = await import('./signed-record')
+ for (const bad of [{ h: 'x', e: 1, o: 5 }, { h: 'x', e: 1, o: 'k', t: 'soon' }, { h: 'x', e: 1, t: 1.5 }]) {
+ const data = new Map([['konclave.signed.v1', JSON.stringify({ p1: [bad] })]])
+ const store = { getItem: (k: string) => data.get(k) ?? null, setItem: () => {} }
+ expect(() => readSigned(store, 'v1', 'p1')).toThrow('unreadable')
+ expect(() => readByOutputs(store, 'v1', 'k', 0)).toThrow('unreadable')
+ }
+ })
+})
diff --git a/ui/src/signed-record.ts b/ui/src/signed-record.ts
index 04d08ad5..a158d11c 100644
--- a/ui/src/signed-record.ts
+++ b/ui/src/signed-record.ts
@@ -16,7 +16,9 @@
// A hash of the sighash, not the sighash. It is NOT a privacy boundary: the hash is unsalted and sits
// beside the expiry, so whoever reads this storage can hash the public txids of each expiry window and
// find which transactions this device signed (the txid is the sighash byte-reversed). It is kept as a
-// hash because the device only needs to compare. A keyed record is a separate item. The record lives in
+// hash because the device only needs to compare. The same holds for `o`, an unsalted hash of what a
+// payment paid (receivers, amounts, memos): it is a local record, readable by whoever reads this
+// storage. A keyed record is a separate item. The record lives in
// the browser's own storage so it outlives a reload, which a record in memory did not.
import { approvalDecision } from './wasm-pkg/konclave_wasm.js'
diff --git a/ui/src/signing-panel.test.tsx b/ui/src/signing-panel.test.tsx
index cff7c9c8..a193f3a7 100644
--- a/ui/src/signing-panel.test.tsx
+++ b/ui/src/signing-panel.test.tsx
@@ -289,7 +289,7 @@ describe('the confirm for signing again', () => {
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).toContain('signing.againLapsedChecked block=3600123')
expect(html).not.toContain('signing.againBody')
expect(html).not.toContain('signing.againUntil')
expect(html).not.toContain('signing.againNoTime')
@@ -298,12 +298,25 @@ describe('the confirm for signing again', () => {
})
it('after a lapse, shows an earlier txid only as one this device checked, and says so when it cannot', () => {
const tx = 'ab'.repeat(32)
- const shown = renderToStaticMarkup( )
- expect(shown).toContain('signing.againLapsedTxid k=1 n=2')
+ const shown = renderToStaticMarkup( )
+ expect(shown).toContain('signing.againLapsedTxid')
+ expect(shown).toContain('signing.againLapsedPartial k=1 n=2')
+ expect(shown).toMatch(/aria-describedby="[^"]*sign-again-txid/)
expect(shown).toContain(tx)
const none = renderToStaticMarkup( )
expect(none).toContain('signing.againLapsedNoTxid')
expect(none).not.toContain('signing.againLapsedTxid')
+ expect(none).not.toContain('signing.againLapsedPartial')
+ })
+ it('several earlier attempts are worded as several, and every one is shown as identified only when it is', () => {
+ const html = renderToStaticMarkup( )
+ expect(html).toContain('signing.againLapsedBodyMany amt=1 block=9 n=3')
+ })
+ it('the same lines under another proposal ask whether this is a separate payment', () => {
+ const html = renderToStaticMarkup( )
+ expect(html).toContain('signing.againSameBody amt=1')
+ expect(html).toContain('signing.againSameChecked')
+ expect(html).not.toContain('signing.againBody')
})
it('claims no time that has already passed', () => {
const html = renderToStaticMarkup( )
diff --git a/ui/src/vault-signer.test.tsx b/ui/src/vault-signer.test.tsx
index 08280562..2d07a67e 100644
--- a/ui/src/vault-signer.test.tsx
+++ b/ui/src/vault-signer.test.tsx
@@ -97,6 +97,18 @@ describe("the owner's claim and record reach the signer", () => {
expect(src).toMatch(/\(sighashHex: string, expiry: number, branch: number\) => owner\.current\.record\(sighashHex, expiry, branch\)/)
expect(src).toMatch(/return r === 'ok'/)
})
+ // After a lapse: the screen is told, with the block, and an earlier txid only when this device
+ // checked it signed it (a coordinator's txid is never shown on its word).
+ it('a lapse reaches the screen, and a txid is shown only when the device confirmed it', () => {
+ const src = codeOf('./VaultSigner.tsx')
+ expect(src).toMatch(/if \(r === 'lapsed' && lapsed\)/)
+ expect(src).toMatch(/const ours = cand \? await txidIsOurs\(cand, lapsed\.hashes \?\? \[\]\) : false/)
+ expect(src).toMatch(/lapsedTxid: ours \? cand : null/)
+ expect(src).not.toMatch(/lapsedTxid: cand\b/)
+ const panel = codeOf('./screens/SigningPanel.tsx')
+ expect(panel).toMatch(/lapsedAt=\{signedBefore\?\.lapsedAt \?\? null\}/)
+ expect(panel).toMatch(/lapsedTxid=\{signedBefore\?\.lapsedTxid \?\? null\}/)
+ })
it('the hook hands them to the session unchanged', () => {
expect(codeOf('./useBackgroundSigner.ts')).toMatch(/new BackgroundSession\(\{\s*decideApproval,\s*claim,\s*record,\s/)
})
From d32f9a840535c34ddce60087d6c9b133dfa9f624 Mon Sep 17 00:00:00 2001
From: Daniel Gorgonha
Date: Thu, 12 Nov 2026 21:40:29 -0300
Subject: [PATCH 07/12] security(ui): word the same-payment check from the
device's own record
Co-Authored-By: Claude
---
ui/src/VaultSigner.tsx | 8 ++++----
ui/src/i18n/en.ts | 10 +++++-----
ui/src/i18n/pt-BR.ts | 10 +++++-----
ui/src/net-execute.test.ts | 4 ++++
ui/src/owner-signing.test.ts | 3 ++-
ui/src/owner-signing.ts | 15 +++++++++++----
ui/src/screens/NetVault.tsx | 7 ++++---
ui/src/screens/SigningPanel.tsx | 15 ++++++++++-----
ui/src/signing-panel.test.tsx | 11 +++++++++--
9 files changed, 54 insertions(+), 29 deletions(-)
diff --git a/ui/src/VaultSigner.tsx b/ui/src/VaultSigner.tsx
index 1b1feca7..e8b0a42b 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; lapsedAt?: number | null; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number }; sameOutputs?: boolean } | null
+ signedBefore: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number }; sameOutputs?: boolean; sameAt?: 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,
@@ -191,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; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number }; sameOutputs?: boolean } | 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 }; sameOutputs?: boolean; sameAt?: number } | null>(null)
const gate = useMemo(
() => makeSigningGate({
mode: () => 'manual',
@@ -219,7 +219,7 @@ 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, lapsedAt: null, sameOutputs: !!last.sameOutputs })
+ if (r === 'second' && last) setSignedBefore({ proposal: last.proposal, firstExpiry: last.firstExpiry, firstUntil: last.firstUntil, untilHeight: last.untilHeight, byEpoch: last.byEpoch, lapsedAt: null, sameOutputs: !!last.sameOutputs, sameAt: last.sameAt })
// 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) {
@@ -228,7 +228,7 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) {
// 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 }, sameOutputs: !!lapsed.sameOutputs })
+ 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 }, sameOutputs: !!lapsed.sameOutputs, sameAt: lapsed.sameAt })
}
return r === 'ok'
},
diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts
index 18993b5f..e7ddee4d 100644
--- a/ui/src/i18n/en.ts
+++ b/ui/src/i18n/en.ts
@@ -1084,12 +1084,12 @@ 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. By this device\'s clock it cannot go out after block {block}, but the device cannot tell whether it went out before then. If it did, signing again pays {amt} ZEC a second time.',
'signing.againLapsedBodyMany': 'This device already signed {n} earlier attempts at this payment. By this device\'s clock none of them can go out after block {block}, but the device cannot tell whether one went out before then. If one did, signing again pays {amt} ZEC a second time.',
- 'signing.againLapsedPartial': 'It could identify {k} of its {n} earlier attempts. The others cannot be checked here: ask the other members whether the payment went out.',
- 'signing.againSameBody': 'Recently, this device signed a payment of exactly these recipients, amounts and memos under another proposal. If this is that same payment again, signing pays {amt} ZEC a second time. If it is a separate payment, you can sign.',
- 'signing.againSameChecked': 'This is a separate payment, not the earlier one again.',
+ 'signing.againLapsedPartial': 'This device could identify {k} of its {n} earlier attempts. The others cannot be checked here: ask the other members whether the payment went out.',
+ 'signing.againSameBody': 'On {when}, this device signed a payment of exactly these recipients, amounts and memos, under another proposal. If that payment went out and this is the same payment again, signing pays {amt} ZEC a second time. Sign only if this is a separate payment, or if you checked that the earlier one did not go out.',
+ 'signing.againSameChecked': 'This is a separate payment, or I checked that the earlier one did not go out.',
'signing.againLapsedChecked': 'I checked: no earlier attempt at this payment went out, and the explorer\'s latest block is past {block}. If I am wrong, this pays it twice.',
'signing.againLapsedTxid': 'This device checked that it signed the transaction below. Look it up in a block explorer: if the explorer shows it, the payment went out, so do not sign.',
- 'signing.againLapsedNoTxid': 'This device cannot show the transaction id of the earlier attempt, so it cannot point you to it. Ask the other members whether the payment went out before signing again.',
+ 'signing.againLapsedNoTxid': 'This device cannot show the transaction id of an earlier attempt, so it cannot point you to it. Ask the other members whether the payment went out before signing again.',
'signing.againCta': 'Sign again: {amt} ZEC',
'signing.readAgainHeading': 'What the vault built this time',
'signing.againCtaSend': 'Sign again and send: {amt} ZEC',
@@ -1375,7 +1375,7 @@ export const en: Record = {
'net.err.unreadableSpends': 'Refused to sign: this device could not read which parts of the transaction need its signature. Nothing was signed. The reason follows.',
'net.err.relayDown': 'Could not reach the relay (the network went down). Reload both tabs and try again.',
'net.err.notApproved': 'Refused to sign: this transaction does not pay exactly what the group approved. Nothing was signed. What this device read is below; memos are checked too, but not shown. Do not try again, and tell the other members.',
- 'net.err.signedAnother': 'Not signed: this device already signed this payment once, and the vault has since built it again. Nothing was signed this time. Check first that the payment did not go out. If it did, stop here. If it did not, you can sign again.',
+ 'net.err.signedAnother': 'Not signed: this device already signed this payment once, and the vault has since built it again. Nothing was signed this time. First check whether the payment went out. If it did, stop here. If it did not, you can sign again.',
'net.err.approvalUnknown': 'Refused to sign: this device could not load what the group approved, or its own record of the vault, so it did not check this transaction against them. Nothing was signed. This alone does not show tampering, but the coordinator can cause it: reload Konclave on this device and open the proposal again. If it keeps happening, do not sign, and tell the other members.',
'net.err.feeClock': "Refused to sign: the network fee in this transaction is lower than this device accepts for its date and time. That can mean this device's date or time is set too early, or that the transaction was built at a rate this vault does not use yet. Nothing was signed. If this device's date or time is wrong, correct it, but never set it ahead of the real time to make it sign. If they are right, do not try again, and tell the other members.",
'net.err.feeTooHigh': 'Refused to sign: the network fee in this transaction is not the standard fee for its size. Higher, the difference would leave the vault; lower, the network may not relay it. Nothing was signed. This can mean the request was tampered with: do not try again, and tell the other members.',
diff --git a/ui/src/i18n/pt-BR.ts b/ui/src/i18n/pt-BR.ts
index 97256c44..39958f88 100644
--- a/ui/src/i18n/pt-BR.ts
+++ b/ui/src/i18n/pt-BR.ts
@@ -1080,12 +1080,12 @@ 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. Pelo relógio do aparelho, ela não pode mais sair depois do bloco {block}, mas o aparelho não sabe dizer se ela saiu antes disso. Se saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
'signing.againLapsedBodyMany': 'Este aparelho já assinou {n} tentativas anteriores deste pagamento. Pelo relógio do aparelho, nenhuma delas pode sair depois do bloco {block}, mas o aparelho não sabe dizer se alguma saiu antes disso. Se saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
- 'signing.againLapsedPartial': 'Foi possível identificar {k} das {n} tentativas anteriores. As outras não podem ser conferidas aqui: pergunte aos outros membros se o pagamento saiu.',
- 'signing.againSameBody': 'Há pouco tempo, este aparelho assinou um pagamento com exatamente estes destinatários, valores e memos, em outra proposta. Se este for o mesmo pagamento de novo, assinar paga {amt} ZEC uma segunda vez. Se for um pagamento separado, você pode assinar.',
- 'signing.againSameChecked': 'Este é um pagamento separado, não o anterior de novo.',
+ 'signing.againLapsedPartial': 'Este aparelho identificou {k} das {n} tentativas anteriores. As outras não podem ser conferidas aqui: pergunte aos outros membros se o pagamento saiu.',
+ 'signing.againSameBody': 'Em {when}, este aparelho assinou um pagamento com exatamente estes destinatários, valores e memos, em outra proposta. Se aquele pagamento saiu e este for o mesmo pagamento de novo, assinar paga {amt} ZEC uma segunda vez. Assine só se este for um pagamento separado, ou se você conferiu que o anterior não saiu.',
+ 'signing.againSameChecked': 'Este é um pagamento separado, ou conferi que o anterior não saiu.',
'signing.againLapsedChecked': 'Conferi: nenhuma tentativa anterior deste pagamento saiu, e o último bloco do explorador já passou de {block}. Se eu tiver me enganado, isto paga duas vezes.',
'signing.againLapsedTxid': 'Este aparelho conferiu que assinou a transação abaixo. Procure-a num explorador de blocos: se ela aparecer lá, o pagamento saiu, então não assine.',
- 'signing.againLapsedNoTxid': 'Este aparelho não consegue mostrar o id da transação anterior, então não pode indicá-la. Pergunte aos outros membros se o pagamento saiu antes de assinar de novo.',
+ 'signing.againLapsedNoTxid': 'Este aparelho não consegue mostrar o id da transação de uma tentativa anterior, então não pode indicá-la. Pergunte aos outros membros se o pagamento saiu antes de assinar de novo.',
'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',
@@ -1371,7 +1371,7 @@ export const ptBR: Record = {
'net.err.unreadableSpends': 'Assinatura recusada: este aparelho não conseguiu ler quais partes da transação precisam da assinatura dele. Nada foi assinado. O motivo vem a seguir.',
'net.err.relayDown': 'Não consegui falar com o relay (a rede caiu). Recarregue as duas abas e refaça.',
'net.err.notApproved': 'Assinatura recusada: esta transação não paga exatamente o que o grupo aprovou. Nada foi assinado. O que este aparelho leu está abaixo; os memos também são conferidos, mas não aparecem. Não tente de novo e avise os outros membros.',
- 'net.err.signedAnother': 'Não assinado: este aparelho já assinou este pagamento uma vez, e o cofre o montou de novo. Nada foi assinado desta vez. Confira antes que o pagamento não saiu. Se saiu, pare aqui. Se não saiu, você pode assinar de novo.',
+ 'net.err.signedAnother': 'Não assinado: este aparelho já assinou este pagamento uma vez, e o cofre o montou de novo. Nada foi assinado desta vez. Primeiro confira se o pagamento saiu. Se saiu, pare aqui. Se não saiu, você pode assinar de novo.',
'net.err.approvalUnknown': 'Assinatura recusada: este aparelho não conseguiu carregar o que o grupo aprovou, ou o próprio registro do cofre, então não conferiu esta transação com eles. Nada foi assinado. Isso sozinho não mostra adulteração, mas o coordenador pode provocá-lo: recarregue o Konclave neste aparelho e abra a proposta de novo. Se continuar acontecendo, não assine e avise os outros membros.',
'net.err.feeClock': 'Assinatura recusada: a taxa da rede nesta transação é menor do que este aparelho aceita para a data e a hora dele. Isso pode querer dizer que a data ou a hora deste aparelho está atrasada, ou que a transação foi montada numa taxa que este cofre ainda não usa. Nada foi assinado. Se a data ou a hora deste aparelho estiver errada, corrija, mas nunca adiante além da hora real para ele assinar. Se estiverem certas, não tente de novo e avise os outros membros.',
'net.err.feeTooHigh': 'Assinatura recusada: a taxa da rede nesta transação não é a taxa padrão para o tamanho dela. Maior, a diferença sairia do cofre; menor, a rede pode não transmiti-la. Nada foi assinado. Isso pode significar um pedido adulterado: não tente de novo e avise os outros membros.',
diff --git a/ui/src/net-execute.test.ts b/ui/src/net-execute.test.ts
index 4a0733c0..6572068d 100644
--- a/ui/src/net-execute.test.ts
+++ b/ui/src/net-execute.test.ts
@@ -18,4 +18,8 @@ describe('/net does not execute a proposal where a coordinator is configured', (
expect(src).not.toMatch(/'Sign & send'/)
expect(src).toMatch(/nav\('\/proposal', \{ state: \{ id: p\.id \} \}\)/)
})
+ it('a coordinator request is consumed before the signer sees it', () => {
+ expect(src).toMatch(/if \(parseSignRequest\(reqData\)\) return true/)
+ expect(src).not.toMatch(/tryHelperRequest/)
+ })
})
diff --git a/ui/src/owner-signing.test.ts b/ui/src/owner-signing.test.ts
index 3951ef0c..0561d51f 100644
--- a/ui/src/owner-signing.test.ts
+++ b/ui/src/owner-signing.test.ts
@@ -59,7 +59,7 @@ describe('the owner signing on this device', () => {
reloaded.give('p1', Date.now())
// ...does not cover another transaction for the same approval while the first is alive.
expect(await reloaded.claim('v1', 'p1', B, ALIVE)).toEqual('second')
- expect(reloaded.lastSecond).toEqual({ proposal: 'p1', firstExpiry: ALIVE, firstUntil: expect.any(Number), untilHeight: null, byEpoch: false, sighash: B, sameOutputs: false })
+ expect(reloaded.lastSecond).toEqual({ proposal: 'p1', firstExpiry: ALIVE, firstUntil: expect.any(Number), untilHeight: null, byEpoch: false, sighash: B, sameOutputs: false, sameAt: undefined })
expect(reloaded.allows('p1'), 'the refused click is spent').toBe(false)
})
@@ -235,6 +235,7 @@ describe('the owner signing on this device', () => {
o.give('p2', Date.now())
expect(await o.claim('v1', 'p2', B, ALIVE, 'K')).toBe('second')
expect(o.lastSecond?.sameOutputs).toBe(true)
+ expect(o.lastSecond?.sameAt, 'the device\'s own time of the earlier signature').toEqual(expect.any(Number))
o.give('p1', Date.now())
expect(await o.claim('v1', 'p1', B, ALIVE, 'K')).toBe('second')
expect(o.lastSecond?.sameOutputs, 'under its own proposal it is not the bridge').toBe(false)
diff --git a/ui/src/owner-signing.ts b/ui/src/owner-signing.ts
index 5ead7fa1..005e8b94 100644
--- a/ui/src/owner-signing.ts
+++ b/ui/src/owner-signing.ts
@@ -12,6 +12,13 @@ import { decideAmong, decideFor, readByOutputs, readSigned, recordSigned, type R
/** 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
+/** When this device itself recorded the latest of `list` (ms), from its own record: the coordinator
+ * cannot set it, so the screen can quote it. */
+function lastAt(list: SignedTx[] | null): number | undefined {
+ const ts = (list ?? []).map((t) => t.t).filter((t): t is number => typeof t === 'number')
+ return ts.length ? Math.max(...ts) : undefined
+}
+
export type Claim = 'ok' | 'second' | 'lapsed' | 'refused' | 'unreadable'
export class OwnerSigning {
@@ -24,12 +31,12 @@ export class OwnerSigning {
* 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; 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; sameOutputs?: boolean } | null = null
+ lastSecond: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; sighash: string; sameOutputs?: boolean; sameAt?: number } | 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[]; hashes?: string[]; sameOutputs?: boolean } | null = null
+ lastLapsed: { proposal: string; sighash: string; earlier: number[]; hashes?: string[]; sameOutputs?: boolean; sameAt?: number } | null = null
private readonly store: RecordStore
private readonly now: () => number
@@ -89,7 +96,7 @@ export class OwnerSigning {
this.consent.spent = true
// `sameOutputs`: the earlier transaction was under ANOTHER proposal that paid the same lines; it
// may be a separate, honest payment, so the screen asks differently.
- this.lastSecond = { proposal: activeId, firstExpiry: decision.firstExpiry, firstUntil: decision.untilUnix ?? 0, untilHeight: decision.untilHeight, byEpoch: decision.byEpoch, sighash: h, sameOutputs: earlierList !== null }
+ this.lastSecond = { proposal: activeId, firstExpiry: decision.firstExpiry, firstUntil: decision.untilUnix ?? 0, untilHeight: decision.untilHeight, byEpoch: decision.byEpoch, sighash: h, sameOutputs: earlierList !== null, sameAt: lastAt(earlierList) }
return 'second'
}
const answered = this.again?.proposal === activeId && this.again.sighash === h
@@ -100,7 +107,7 @@ export class OwnerSigning {
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), sameOutputs: earlierList !== null }
+ this.lastLapsed = { proposal: activeId, sighash: h, earlier: list.map((t) => t.e), hashes: list.map((t) => t.h), sameOutputs: earlierList !== null, sameAt: lastAt(earlierList) }
return 'lapsed'
}
if (!claimConsent(this.consent, activeId, this.now(), sighashHex)) return 'refused'
diff --git a/ui/src/screens/NetVault.tsx b/ui/src/screens/NetVault.tsx
index 5a1c42de..182bf0e1 100644
--- a/ui/src/screens/NetVault.tsx
+++ b/ui/src/screens/NetVault.tsx
@@ -559,9 +559,10 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: {
} catch {
/* no signing material yet */
}
- if (await machine.tryHelperRequest(reqData)) return true
- // A request whose coordinator is not seated here yet waits for the rejoin that seats it.
- if (parseSignRequest(reqData)) return false
+ // A coordinator's signing request is not this page's to answer: proposals are signed from their
+ // signing panel. Consumed here, so it is neither signed nor shown as a refusal that does not
+ // apply (the machine's claim refuses as well, should one ever reach it).
+ if (parseSignRequest(reqData)) return true
// ---- the creation ceremony: these go to DkgCeremony, which owns the rules ----
if (parsed.type === 'config' || parsed.type === 'hello' || parsed.type === 'commit' || parsed.type === 'reveal'
|| parsed.type === 'r1' || parsed.type === 'r2' || parsed.type === 'done') {
diff --git a/ui/src/screens/SigningPanel.tsx b/ui/src/screens/SigningPanel.tsx
index 207d914a..2ec18dca 100644
--- a/ui/src/screens/SigningPanel.tsx
+++ b/ui/src/screens/SigningPanel.tsx
@@ -105,7 +105,7 @@ function PlanLines({ lines: rows }: { lines: ApprovedPlan['lines'] }) {
)
}
-export function SignConfirm({ payrollLines, destPublic = false, again, firstUntil, byEpoch = false, untilHeight = null, lapsedAt = null, lapsedTxid = null, lapsedOf, sameOutputs = false, 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, sameOutputs = false, sameAt, 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
@@ -124,6 +124,8 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
/** The earlier signature was for ANOTHER proposal that paid exactly these lines: it may be a
* separate payment, so the owner is asked whether this one is the same payment again. */
sameOutputs?: boolean
+ /** When this device signed that earlier payment (ms), from its own record. */
+ sameAt?: number
locale: 'pt-BR' | 'en'
amt: string
usd?: string | null
@@ -148,7 +150,9 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
// device still asks (a lapse, below); nothing is said here when it is unknown or already past.
// 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 = lapsedAt !== null ? '' : firstUntil * 1000 > now
+ // The "until" line is about an earlier attempt at THIS proposal: not shown for a lapse, nor for the
+ // same lines under another proposal (a separate payment there is expected to have gone out).
+ const until = lapsedAt !== null || sameOutputs ? '' : firstUntil * 1000 > now
? byEpoch && untilHeight !== null
? t('signing.againUntilEpoch', { when: fmtWhen(firstUntil, locale), block: untilHeight })
: t('signing.againUntil', { when: fmtWhen(firstUntil, locale) })
@@ -161,7 +165,7 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
className="modal-overlay"
cardClassName="modal-card danger sign-confirm"
labelledBy="sign-confirm-title"
- describedBy={again ? ['sign-again-body', until && 'sign-again-until', lapsedAt !== null && 'sign-again-txid', 'sign-confirm-what'].filter(Boolean).join(' ') : 'sign-confirm-what'}
+ describedBy={again ? ['sign-again-body', until && 'sign-again-until', lapsedAt !== null && !sameOutputs && 'sign-again-txid', 'sign-confirm-what'].filter(Boolean).join(' ') : 'sign-confirm-what'}
focusCard={again}
onClose={onCancel}
>
@@ -178,14 +182,14 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
<>
{sameOutputs
- ? t('signing.againSameBody', { amt })
+ ? t('signing.againSameBody', { amt, when: sameAt ? fmtWhen(Math.floor(sameAt / 1000), locale) : '?' })
: lapsedAt !== null
? t((lapsedOf?.n ?? 1) > 1 ? 'signing.againLapsedBodyMany' : 'signing.againLapsedBody', { amt, block: lapsedAt, n: lapsedOf?.n ?? 1 })
: t('signing.againBody', { amt })}
{iWouldBeLast && <> {t('signing.confirmBody')}>}
{until && {until}
}
- {lapsedAt !== null && (
+ {lapsedAt !== null && !sameOutputs && (
<>
{/* Announced without the 64-character id itself; the id follows, with a copy button. */}
@@ -1004,6 +1008,7 @@ export default function SigningPanel() {
lapsedTxid={signedBefore?.lapsedTxid ?? null}
lapsedOf={signedBefore?.lapsedOf}
sameOutputs={signedBefore?.sameOutputs ?? false}
+ sameAt={signedBefore?.sameAt}
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 a193f3a7..53356f04 100644
--- a/ui/src/signing-panel.test.tsx
+++ b/ui/src/signing-panel.test.tsx
@@ -313,11 +313,18 @@ describe('the confirm for signing again', () => {
expect(html).toContain('signing.againLapsedBodyMany amt=1 block=9 n=3')
})
it('the same lines under another proposal ask whether this is a separate payment', () => {
- const html = renderToStaticMarkup( )
- expect(html).toContain('signing.againSameBody amt=1')
+ const html = renderToStaticMarkup( )
+ expect(html).toMatch(/signing\.againSameBody amt=1 when=\d{2}\/\d{2}\/2026/)
expect(html).toContain('signing.againSameChecked')
expect(html).not.toContain('signing.againBody')
})
+ it('the same lines under another proposal, after a lapse, show no lapse txid line and no until line', () => {
+ const html = renderToStaticMarkup( )
+ expect(html).toContain('signing.againSameBody')
+ expect(html).not.toContain('signing.againLapsedTxid')
+ expect(html).not.toContain('signing.againUntil')
+ expect(html).not.toContain('sign-again-txid')
+ })
it('claims no time that has already passed', () => {
const html = renderToStaticMarkup( )
expect(html).not.toContain('signing.againUntil')
From 6a710ddcbd4f82e4cc54e22416bc287ec58964ea Mon Sep 17 00:00:00 2001
From: Daniel Gorgonha
Date: Thu, 12 Nov 2026 21:41:20 -0300
Subject: [PATCH 08/12] docs(ui): a pt-BR wording and a /net comment after
review
Co-Authored-By: Claude
---
ui/src/i18n/pt-BR.ts | 2 +-
ui/src/screens/NetVault.tsx | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/ui/src/i18n/pt-BR.ts b/ui/src/i18n/pt-BR.ts
index 39958f88..494e5562 100644
--- a/ui/src/i18n/pt-BR.ts
+++ b/ui/src/i18n/pt-BR.ts
@@ -1079,7 +1079,7 @@ export const ptBR: Record = {
'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. Pelo relógio do aparelho, ela não pode mais sair depois do bloco {block}, mas o aparelho não sabe dizer se ela saiu antes disso. Se saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
- 'signing.againLapsedBodyMany': 'Este aparelho já assinou {n} tentativas anteriores deste pagamento. Pelo relógio do aparelho, nenhuma delas pode sair depois do bloco {block}, mas o aparelho não sabe dizer se alguma saiu antes disso. Se saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
+ 'signing.againLapsedBodyMany': 'Este aparelho já assinou {n} tentativas anteriores deste pagamento. Pelo relógio do aparelho, nenhuma delas pode sair depois do bloco {block}, mas o aparelho não sabe dizer se alguma saiu antes disso. Se alguma saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
'signing.againLapsedPartial': 'Este aparelho identificou {k} das {n} tentativas anteriores. As outras não podem ser conferidas aqui: pergunte aos outros membros se o pagamento saiu.',
'signing.againSameBody': 'Em {when}, este aparelho assinou um pagamento com exatamente estes destinatários, valores e memos, em outra proposta. Se aquele pagamento saiu e este for o mesmo pagamento de novo, assinar paga {amt} ZEC uma segunda vez. Assine só se este for um pagamento separado, ou se você conferiu que o anterior não saiu.',
'signing.againSameChecked': 'Este é um pagamento separado, ou conferi que o anterior não saiu.',
diff --git a/ui/src/screens/NetVault.tsx b/ui/src/screens/NetVault.tsx
index 182bf0e1..6dd4ff19 100644
--- a/ui/src/screens/NetVault.tsx
+++ b/ui/src/screens/NetVault.tsx
@@ -487,11 +487,11 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: {
if (!machineRef.current) {
machineRef.current = new SigningMachine({
signingMaterial,
- // #281, stated rather than silently skipped. This driver signs a ceremony that is not bound
- // to a proposal at all (#363), so it has no approved payment to compare an output against -
- // there is nothing here that could answer the question honestly. Answering `match` says "this
- // driver does not gate on content", which is the truth and is now visible at the call site;
- // refusing would refuse every /net signature, including vault creation.
+ // #281, stated rather than silently skipped. This driver has no approved payment to compare an
+ // output against. It no longer answers any coordinator request (they are consumed before the
+ // machine sees them, and `claim` below refuses), so this answer decides nothing today; it stays
+ // `match` only because the dependency is required and says, at the call site, that /net does
+ // not gate on content.
//
// The reason the dep is REQUIRED rather than optional is exactly this: /net and the
// background signer have diverged three times (#424, #425, #363) because a rule lived in one
From b143f67fb0b3ea069f20188e7e9c141d40e2c4b8 Mon Sep 17 00:00:00 2001
From: Daniel Gorgonha
Date: Thu, 12 Nov 2026 21:42:05 -0300
Subject: [PATCH 09/12] security(ui): a damaged signing record asks, and the
screen wiring is pinned
Co-Authored-By: Claude
---
ui/src/signed-record.test.ts | 9 +++++++++
ui/src/signed-record.ts | 4 +++-
ui/src/vault-signer.test.tsx | 3 +++
3 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/ui/src/signed-record.test.ts b/ui/src/signed-record.test.ts
index 930d907c..a32cd05e 100644
--- a/ui/src/signed-record.test.ts
+++ b/ui/src/signed-record.test.ts
@@ -215,3 +215,12 @@ describe('the record refuses an entry it cannot read, the new fields included',
}
})
})
+
+describe('a payment key with no time', () => {
+ it('counts as recent: a damaged record asks, it does not pass a payment as a first signature', async () => {
+ const { readByOutputs } = await import('./signed-record')
+ const data = new Map([['konclave.signed.v1', JSON.stringify({ p1: [{ h: 'x', e: 1, b: 1, o: 'k' }] })]])
+ const store = { getItem: (k: string) => data.get(k) ?? null, setItem: () => {} }
+ expect(readByOutputs(store, 'v1', 'k', Date.now())).toHaveLength(1)
+ })
+})
diff --git a/ui/src/signed-record.ts b/ui/src/signed-record.ts
index a158d11c..c499b6f2 100644
--- a/ui/src/signed-record.ts
+++ b/ui/src/signed-record.ts
@@ -93,7 +93,9 @@ export function readByOutputs(store: RecordStore, vault: string, outputs: string
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)
+ // An entry with a payment key and no time is not one this code writes (it writes both): it counts
+ // as recent, so a damaged record asks rather than letting a payment through as a first signature.
+ for (const t of list) if (t.o === outputs && (t.t ?? Number.POSITIVE_INFINITY) >= since) out.push(t)
}
return out
}
diff --git a/ui/src/vault-signer.test.tsx b/ui/src/vault-signer.test.tsx
index 2d07a67e..f09b205e 100644
--- a/ui/src/vault-signer.test.tsx
+++ b/ui/src/vault-signer.test.tsx
@@ -108,6 +108,9 @@ describe("the owner's claim and record reach the signer", () => {
const panel = codeOf('./screens/SigningPanel.tsx')
expect(panel).toMatch(/lapsedAt=\{signedBefore\?\.lapsedAt \?\? null\}/)
expect(panel).toMatch(/lapsedTxid=\{signedBefore\?\.lapsedTxid \?\? null\}/)
+ expect(panel).toMatch(/sameOutputs=\{signedBefore\?\.sameOutputs \?\? false\}/)
+ expect(src).toMatch(/lapsedAt: null, sameOutputs: !!last\.sameOutputs, sameAt: last\.sameAt \}/)
+ expect(src).toMatch(/lapsedOf: \{ k: ours \? 1 : 0, n: lapsed\.earlier\.length \}, sameOutputs: !!lapsed\.sameOutputs, sameAt: lapsed\.sameAt \}/)
})
it('the hook hands them to the session unchanged', () => {
expect(codeOf('./useBackgroundSigner.ts')).toMatch(/new BackgroundSession\(\{\s*decideApproval,\s*claim,\s*record,\s/)
From a35ebed7bae09ad8a0dcc1746e2b0e3039485277 Mon Sep 17 00:00:00 2001
From: Daniel Gorgonha
Date: Thu, 12 Nov 2026 21:47:14 -0300
Subject: [PATCH 10/12] security(ui): keep the until line for the same lines
while the earlier signature lives
Co-Authored-By: Claude
---
ui/src/i18n/en.ts | 10 +++++++---
ui/src/i18n/pt-BR.ts | 10 +++++++---
ui/src/net-execute.test.ts | 3 +++
ui/src/screens/NetVault.tsx | 3 +++
ui/src/screens/SigningPanel.tsx | 14 +++++++++-----
ui/src/signing-panel.test.tsx | 20 ++++++++++++++++++--
ui/src/vault-signer.test.tsx | 1 +
7 files changed, 48 insertions(+), 13 deletions(-)
diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts
index e7ddee4d..81a0379a 100644
--- a/ui/src/i18n/en.ts
+++ b/ui/src/i18n/en.ts
@@ -1085,11 +1085,15 @@ export const en: Record = {
'signing.againLapsedBody': 'This device already signed an earlier attempt at this payment. By this device\'s clock it cannot go out after block {block}, but the device cannot tell whether it went out before then. If it did, signing again pays {amt} ZEC a second time.',
'signing.againLapsedBodyMany': 'This device already signed {n} earlier attempts at this payment. By this device\'s clock none of them can go out after block {block}, but the device cannot tell whether one went out before then. If one did, signing again pays {amt} ZEC a second time.',
'signing.againLapsedPartial': 'This device could identify {k} of its {n} earlier attempts. The others cannot be checked here: ask the other members whether the payment went out.',
- 'signing.againSameBody': 'On {when}, this device signed a payment of exactly these recipients, amounts and memos, under another proposal. If that payment went out and this is the same payment again, signing pays {amt} ZEC a second time. Sign only if this is a separate payment, or if you checked that the earlier one did not go out.',
- 'signing.againSameChecked': 'This is a separate payment, or I checked that the earlier one did not go out.',
+ 'signing.againSameBody': '{whenPhrase}, this device signed a payment of exactly these recipients, amounts and memos, under another proposal, and that signature can still go out. If this is the same payment again, signing pays {amt} ZEC a second time. Sign only if this is a separate payment.',
+ 'signing.againSameChecked': 'This is a separate payment, not the earlier one again.',
+ 'signing.againSameLapsedBody': '{whenPhrase}, this device signed a payment of exactly these recipients, amounts and memos, under another proposal. If that payment went out and this is the same payment again, signing pays {amt} ZEC a second time. Sign only if this is a separate payment, or if you checked that the earlier one did not go out.',
+ 'signing.againSameLapsedChecked': 'This is a separate payment, or I checked that the earlier one did not go out and the explorer\'s latest block is past {block}.',
+ 'signing.sameOn': 'On {when}',
+ 'signing.sameEarlier': 'Earlier',
'signing.againLapsedChecked': 'I checked: no earlier attempt at this payment went out, and the explorer\'s latest block is past {block}. If I am wrong, this pays it twice.',
'signing.againLapsedTxid': 'This device checked that it signed the transaction below. Look it up in a block explorer: if the explorer shows it, the payment went out, so do not sign.',
- 'signing.againLapsedNoTxid': 'This device cannot show the transaction id of an earlier attempt, so it cannot point you to it. Ask the other members whether the payment went out before signing again.',
+ 'signing.againLapsedNoTxid': 'This device cannot show the transaction id of any earlier attempt, so it cannot point you to it. Ask the other members whether the payment went out before signing again.',
'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 494e5562..adf8e870 100644
--- a/ui/src/i18n/pt-BR.ts
+++ b/ui/src/i18n/pt-BR.ts
@@ -1081,11 +1081,15 @@ export const ptBR: Record = {
'signing.againLapsedBody': 'Este aparelho já assinou uma tentativa anterior deste pagamento. Pelo relógio do aparelho, ela não pode mais sair depois do bloco {block}, mas o aparelho não sabe dizer se ela saiu antes disso. Se saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
'signing.againLapsedBodyMany': 'Este aparelho já assinou {n} tentativas anteriores deste pagamento. Pelo relógio do aparelho, nenhuma delas pode sair depois do bloco {block}, mas o aparelho não sabe dizer se alguma saiu antes disso. Se alguma saiu, assinar de novo paga {amt} ZEC uma segunda vez.',
'signing.againLapsedPartial': 'Este aparelho identificou {k} das {n} tentativas anteriores. As outras não podem ser conferidas aqui: pergunte aos outros membros se o pagamento saiu.',
- 'signing.againSameBody': 'Em {when}, este aparelho assinou um pagamento com exatamente estes destinatários, valores e memos, em outra proposta. Se aquele pagamento saiu e este for o mesmo pagamento de novo, assinar paga {amt} ZEC uma segunda vez. Assine só se este for um pagamento separado, ou se você conferiu que o anterior não saiu.',
- 'signing.againSameChecked': 'Este é um pagamento separado, ou conferi que o anterior não saiu.',
+ 'signing.againSameBody': '{whenPhrase}, este aparelho assinou um pagamento com exatamente estes destinatários, valores e memos, em outra proposta, e essa assinatura ainda pode sair. Se este for o mesmo pagamento de novo, assinar paga {amt} ZEC uma segunda vez. Assine só se este for um pagamento separado.',
+ 'signing.againSameChecked': 'Este é um pagamento separado, não o anterior de novo.',
+ 'signing.againSameLapsedBody': '{whenPhrase}, este aparelho assinou um pagamento com exatamente estes destinatários, valores e memos, em outra proposta. Se aquele pagamento saiu e este for o mesmo pagamento de novo, assinar paga {amt} ZEC uma segunda vez. Assine só se este for um pagamento separado, ou se você conferiu que o anterior não saiu.',
+ 'signing.againSameLapsedChecked': 'Este é um pagamento separado, ou conferi que o anterior não saiu e que o último bloco do explorador já passou de {block}.',
+ 'signing.sameOn': 'Em {when}',
+ 'signing.sameEarlier': 'Antes',
'signing.againLapsedChecked': 'Conferi: nenhuma tentativa anterior deste pagamento saiu, e o último bloco do explorador já passou de {block}. Se eu tiver me enganado, isto paga duas vezes.',
'signing.againLapsedTxid': 'Este aparelho conferiu que assinou a transação abaixo. Procure-a num explorador de blocos: se ela aparecer lá, o pagamento saiu, então não assine.',
- 'signing.againLapsedNoTxid': 'Este aparelho não consegue mostrar o id da transação de uma tentativa anterior, então não pode indicá-la. Pergunte aos outros membros se o pagamento saiu antes de assinar de novo.',
+ 'signing.againLapsedNoTxid': 'Este aparelho não consegue mostrar o id da transação de nenhuma tentativa anterior, então não pode indicá-la. Pergunte aos outros membros se o pagamento saiu antes de assinar de novo.',
'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/net-execute.test.ts b/ui/src/net-execute.test.ts
index 6572068d..43f78efa 100644
--- a/ui/src/net-execute.test.ts
+++ b/ui/src/net-execute.test.ts
@@ -22,4 +22,7 @@ describe('/net does not execute a proposal where a coordinator is configured', (
expect(src).toMatch(/if \(parseSignRequest\(reqData\)\) return true/)
expect(src).not.toMatch(/tryHelperRequest/)
})
+ it('an inline signing request is dropped when a coordinator is configured', () => {
+ expect(src).toMatch(/if \(parsed\.type === 'sreq' && helperConfigured\(\)\) return true/)
+ })
})
diff --git a/ui/src/screens/NetVault.tsx b/ui/src/screens/NetVault.tsx
index 6dd4ff19..436b816f 100644
--- a/ui/src/screens/NetVault.tsx
+++ b/ui/src/screens/NetVault.tsx
@@ -640,6 +640,9 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: {
// ---- signing over the relay (Marco 4): all bytes below are public. The ceremony state and
// its sreq|s1|sp|s2|signed handlers live in the shared SigningMachine (issue #50). ----
+ // With a coordinator configured, a signing request is the app's to answer (the proposal's
+ // signing panel), never this page's: dropped, so it is not met with a refusal that does not apply.
+ if (parsed.type === 'sreq' && helperConfigured()) return true
if (
parsed.type === 'sreq' ||
parsed.type === 's1' ||
diff --git a/ui/src/screens/SigningPanel.tsx b/ui/src/screens/SigningPanel.tsx
index 2ec18dca..2ae3a298 100644
--- a/ui/src/screens/SigningPanel.tsx
+++ b/ui/src/screens/SigningPanel.tsx
@@ -150,9 +150,9 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
// device still asks (a lapse, below); nothing is said here when it is unknown or already past.
// 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").
- // The "until" line is about an earlier attempt at THIS proposal: not shown for a lapse, nor for the
- // same lines under another proposal (a separate payment there is expected to have gone out).
- const until = lapsedAt !== null || sameOutputs ? '' : firstUntil * 1000 > now
+ // The "until" line: while an earlier signature (under this proposal or another with the same lines)
+ // may still go out, the screen says until when. Not shown once it lapsed.
+ 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) })
@@ -182,7 +182,11 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
<>
{sameOutputs
- ? t('signing.againSameBody', { amt, when: sameAt ? fmtWhen(Math.floor(sameAt / 1000), locale) : '?' })
+ ? t(lapsedAt !== null ? 'signing.againSameLapsedBody' : 'signing.againSameBody', {
+ amt,
+ // The device's own time of that signature; without one (a damaged record), no date.
+ whenPhrase: sameAt ? t('signing.sameOn', { when: fmtWhen(Math.floor(sameAt / 1000), locale) }) : t('signing.sameEarlier'),
+ })
: lapsedAt !== null
? t((lapsedOf?.n ?? 1) > 1 ? 'signing.againLapsedBodyMany' : 'signing.againLapsedBody', { amt, block: lapsedAt, n: lapsedOf?.n ?? 1 })
: t('signing.againBody', { amt })}
@@ -201,7 +205,7 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
)}
setChecked(e.target.checked)} />
- {sameOutputs ? t('signing.againSameChecked') : lapsedAt !== null ? t('signing.againLapsedChecked', { block: lapsedAt }) : t('signing.againChecked')}
+ {sameOutputs ? (lapsedAt !== null ? t('signing.againSameLapsedChecked', { block: lapsedAt }) : t('signing.againSameChecked')) : lapsedAt !== null ? t('signing.againLapsedChecked', { block: lapsedAt }) : t('signing.againChecked')}
>
) : (
diff --git a/ui/src/signing-panel.test.tsx b/ui/src/signing-panel.test.tsx
index 53356f04..dd38ca87 100644
--- a/ui/src/signing-panel.test.tsx
+++ b/ui/src/signing-panel.test.tsx
@@ -314,17 +314,33 @@ describe('the confirm for signing again', () => {
})
it('the same lines under another proposal ask whether this is a separate payment', () => {
const html = renderToStaticMarkup( )
- expect(html).toMatch(/signing\.againSameBody amt=1 when=\d{2}\/\d{2}\/2026/)
+ expect(html).toMatch(/signing\.againSameBody amt=1 whenPhrase=signing\.sameOn when=\d{2}\/\d{2}\/2026/)
expect(html).toContain('signing.againSameChecked')
expect(html).not.toContain('signing.againBody')
})
it('the same lines under another proposal, after a lapse, show no lapse txid line and no until line', () => {
const html = renderToStaticMarkup( )
- expect(html).toContain('signing.againSameBody')
+ expect(html).toContain('signing.againSameLapsedBody')
+ expect(html).toContain('signing.againSameLapsedChecked block=9')
expect(html).not.toContain('signing.againLapsedTxid')
expect(html).not.toContain('signing.againUntil')
expect(html).not.toContain('sign-again-txid')
})
+ // R3-A: the same lines under another proposal while that signature can still go out: the screen says
+ // until when, and the only answer offered is "a separate payment" (not "it did not go out": an
+ // explorer cannot show a transaction the coordinator is still holding back).
+ it('the same lines, earlier signature still alive: shows until when, and offers only "a separate payment"', () => {
+ const html = renderToStaticMarkup( )
+ expect(html).toContain('signing.againSameBody')
+ expect(html).toContain('signing.againUntil')
+ expect(html).toContain('signing.againSameChecked')
+ expect(html).not.toContain('signing.againSameLapsedChecked')
+ })
+ it('the same lines with no time in the record: no date, never a "?"', () => {
+ const html = renderToStaticMarkup( )
+ expect(html).toContain('whenPhrase=signing.sameEarlier')
+ expect(html).not.toContain('when=?')
+ })
it('claims no time that has already passed', () => {
const html = renderToStaticMarkup( )
expect(html).not.toContain('signing.againUntil')
diff --git a/ui/src/vault-signer.test.tsx b/ui/src/vault-signer.test.tsx
index f09b205e..b9015bb5 100644
--- a/ui/src/vault-signer.test.tsx
+++ b/ui/src/vault-signer.test.tsx
@@ -109,6 +109,7 @@ describe("the owner's claim and record reach the signer", () => {
expect(panel).toMatch(/lapsedAt=\{signedBefore\?\.lapsedAt \?\? null\}/)
expect(panel).toMatch(/lapsedTxid=\{signedBefore\?\.lapsedTxid \?\? null\}/)
expect(panel).toMatch(/sameOutputs=\{signedBefore\?\.sameOutputs \?\? false\}/)
+ expect(panel).toMatch(/sameAt=\{signedBefore\?\.sameAt\}/)
expect(src).toMatch(/lapsedAt: null, sameOutputs: !!last\.sameOutputs, sameAt: last\.sameAt \}/)
expect(src).toMatch(/lapsedOf: \{ k: ours \? 1 : 0, n: lapsed\.earlier\.length \}, sameOutputs: !!lapsed\.sameOutputs, sameAt: lapsed\.sameAt \}/)
})
From 0d881d0f8b525d51222f923e8b66db479e5f097f Mon Sep 17 00:00:00 2001
From: Daniel Gorgonha
Date: Thu, 12 Nov 2026 21:57:17 -0300
Subject: [PATCH 11/12] fix(ui): the sign-again confirm wraps, keeps focus and
names its copy button
Co-Authored-By: Claude
---
ui/src/App.css | 11 +++++++++++
ui/src/i18n/en.ts | 1 +
ui/src/i18n/pt-BR.ts | 1 +
ui/src/screens/SigningPanel.tsx | 13 +++++++++++--
ui/src/signing-panel.test.tsx | 19 +++++++++++++++++++
5 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/ui/src/App.css b/ui/src/App.css
index bd8c6184..310324dc 100644
--- a/ui/src/App.css
+++ b/ui/src/App.css
@@ -895,6 +895,17 @@ h1.klab,h2.klab,h3.klab{margin:0;font-weight:400}
.modal-card.sign-confirm{max-height:calc(100vh - 48px); max-height:calc(100dvh - 48px); overflow-y:auto; overscroll-behavior:contain; scroll-padding-bottom:72px}
/* bottom: minus the card's padding, so the row sits on the card's edge and covers no text at the end. */
.sign-confirm > .btns{position:sticky; bottom:-26px; margin:16px -26px -26px; padding:14px 26px; background:var(--surface-1); border-top:1px solid var(--line)}
+/* A long CTA (pt-BR "Assinar de novo e enviar: ... ZEC") wraps instead of pushing Cancel off the
+ card's start edge, and the card reflows at 320 px (WCAG 1.4.10, 1.4.12). */
+.sign-confirm > .btns{flex-wrap:wrap}
+.sign-confirm > .btns .btn{white-space:normal; text-align:center; min-width:0; max-width:100%}
+/* Stacked buttons on a phone make the sticky foot taller: a focused checkbox is scrolled clear of it
+ (WCAG 2.4.11). */
+@media(max-width:560px){ .modal-card.sign-confirm{scroll-padding-bottom:170px} }
+@media(max-width:400px){
+ .modal-card.sign-confirm{padding:18px}
+ .sign-confirm > .btns{bottom:-18px; margin:16px -18px -18px; padding:12px 18px}
+}
.modal-h{margin:6px 0 4px; font-size:22px}
.modal-p{color:var(--text-muted); font-size:13.5px; line-height:1.55; margin:0 0 14px}
/* A deliberate acknowledgement before a risky confirm (signing a payment again): the box and its text
diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts
index 81a0379a..af661856 100644
--- a/ui/src/i18n/en.ts
+++ b/ui/src/i18n/en.ts
@@ -1089,6 +1089,7 @@ export const en: Record = {
'signing.againSameChecked': 'This is a separate payment, not the earlier one again.',
'signing.againSameLapsedBody': '{whenPhrase}, this device signed a payment of exactly these recipients, amounts and memos, under another proposal. If that payment went out and this is the same payment again, signing pays {amt} ZEC a second time. Sign only if this is a separate payment, or if you checked that the earlier one did not go out.',
'signing.againSameLapsedChecked': 'This is a separate payment, or I checked that the earlier one did not go out and the explorer\'s latest block is past {block}.',
+ 'signing.copyTxid': 'Copy transaction id',
'signing.sameOn': 'On {when}',
'signing.sameEarlier': 'Earlier',
'signing.againLapsedChecked': 'I checked: no earlier attempt at this payment went out, and the explorer\'s latest block is past {block}. If I am wrong, this pays it twice.',
diff --git a/ui/src/i18n/pt-BR.ts b/ui/src/i18n/pt-BR.ts
index adf8e870..a14a8814 100644
--- a/ui/src/i18n/pt-BR.ts
+++ b/ui/src/i18n/pt-BR.ts
@@ -1085,6 +1085,7 @@ export const ptBR: Record = {
'signing.againSameChecked': 'Este é um pagamento separado, não o anterior de novo.',
'signing.againSameLapsedBody': '{whenPhrase}, este aparelho assinou um pagamento com exatamente estes destinatários, valores e memos, em outra proposta. Se aquele pagamento saiu e este for o mesmo pagamento de novo, assinar paga {amt} ZEC uma segunda vez. Assine só se este for um pagamento separado, ou se você conferiu que o anterior não saiu.',
'signing.againSameLapsedChecked': 'Este é um pagamento separado, ou conferi que o anterior não saiu e que o último bloco do explorador já passou de {block}.',
+ 'signing.copyTxid': 'Copiar id da transação',
'signing.sameOn': 'Em {when}',
'signing.sameEarlier': 'Antes',
'signing.againLapsedChecked': 'Conferi: nenhuma tentativa anterior deste pagamento saiu, e o último bloco do explorador já passou de {block}. Se eu tiver me enganado, isto paga duas vezes.',
diff --git a/ui/src/screens/SigningPanel.tsx b/ui/src/screens/SigningPanel.tsx
index 2ae3a298..a320a6a8 100644
--- a/ui/src/screens/SigningPanel.tsx
+++ b/ui/src/screens/SigningPanel.tsx
@@ -200,7 +200,7 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti
{lapsedTxid ? t('signing.againLapsedTxid') : t('signing.againLapsedNoTxid')}
{lapsedTxid && (lapsedOf?.k ?? 1) < (lapsedOf?.n ?? 1) && <> {t('signing.againLapsedPartial', { k: lapsedOf?.k ?? 1, n: lapsedOf?.n ?? 1 })}>}
- {lapsedTxid && {lapsedTxid}
}
+ {lapsedTxid && {lapsedTxid}
}
>
)}
@@ -382,6 +382,15 @@ export default function SigningPanel() {
// Escape closes it, and focus goes back to what opened it. The scrim and the sheet are not a
// `Dialog` because the sheet keeps its own layout, so these rules are a copy of `Dialog`'s, kept here for now.
const sheetRef = useRef(null)
+ const errRef = useRef(null)
+ // A confirm that closed itself took its opener ("Sign again") with it: focus goes to the message,
+ // or the panel, never the page body (WCAG 2.4.3). Only on the confirm closing, not on the sheet's.
+ const wasConfirming = useRef(false)
+ useEffect(() => {
+ const closed = wasConfirming.current && confirming === null
+ wasConfirming.current = confirming !== null
+ if (closed && typeof document !== 'undefined' && document.activeElement === document.body) (errRef.current ?? sheetRef.current)?.focus()
+ }, [confirming])
const openerRef = useRef(null)
const isOpen = !!active
// Read during render, as `Dialog` does: an autofocused field takes focus at commit.
@@ -864,7 +873,7 @@ export default function SigningPanel() {
{t('signing.openLedger')}
) : errMsg ? (
-
+
{ errRef.current = el; focusOnMount(el) }}>
{errShown.text}
{/* A refusal shows what the device read, so the member can compare it with the
proposal as the message asks; and no Try again, because a refusal is not a failed send. */}
diff --git a/ui/src/signing-panel.test.tsx b/ui/src/signing-panel.test.tsx
index dd38ca87..e9712e62 100644
--- a/ui/src/signing-panel.test.tsx
+++ b/ui/src/signing-panel.test.tsx
@@ -594,3 +594,22 @@ describe('the confirm for signing again, when an upgrade ends the earlier attemp
expect(ptBR['signing.againUntilEpoch']).not.toMatch(/não a aceita mais|recusa/i)
})
})
+
+// Accessibility of the sign-again confirm (WCAG 2.2 AA review): the long pt-BR CTA wraps instead of
+// clipping Cancel, a focused checkbox clears the stacked foot on a phone, focus never falls to the body
+// when the confirm closes itself, and the copy button names what it copies.
+describe('the sign-again confirm, accessibility pins', () => {
+ const css = readFileSync(new URL('./App.css', import.meta.url), 'utf8')
+ it('the buttons wrap and the CTA text wraps', () => {
+ expect(css).toMatch(/\.sign-confirm > \.btns\{flex-wrap:wrap\}/)
+ expect(css).toMatch(/\.sign-confirm > \.btns \.btn\{white-space:normal;/)
+ })
+ it('a phone gets room for the stacked foot', () => {
+ expect(css).toMatch(/@media\(max-width:560px\)\{ \.modal-card\.sign-confirm\{scroll-padding-bottom:170px\} \}/)
+ })
+ it('focus goes to the message or the panel when the confirm closes itself', () => {
+ const src = codeOf('./screens/SigningPanel.tsx')
+ expect(src).toMatch(/if \(closed && typeof document !== 'undefined' && document\.activeElement === document\.body\) \(errRef\.current \?\? sheetRef\.current\)\?\.focus\(\)/)
+ expect(src).toMatch(/
/)
+ })
+})
From ef831cf40111c43455027d440041e3a8438b4fd3 Mon Sep 17 00:00:00 2001
From: Daniel Gorgonha
Date: Thu, 12 Nov 2026 21:59:25 -0300
Subject: [PATCH 12/12] fix(ui): the refusal message takes focus once, not on
every render
Co-Authored-By: Claude
---
ui/src/screens/SigningPanel.tsx | 4 +++-
ui/src/signing-panel.test.tsx | 3 +++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/ui/src/screens/SigningPanel.tsx b/ui/src/screens/SigningPanel.tsx
index a320a6a8..d9dfc7f0 100644
--- a/ui/src/screens/SigningPanel.tsx
+++ b/ui/src/screens/SigningPanel.tsx
@@ -383,6 +383,8 @@ export default function SigningPanel() {
// `Dialog` because the sheet keeps its own layout, so these rules are a copy of `Dialog`'s, kept here for now.
const sheetRef = useRef(null)
const errRef = useRef(null)
+ // Stable, so the message takes focus once when it mounts, not on every render of the panel.
+ const errMount = useCallback((el: HTMLDivElement | null) => { errRef.current = el; el?.focus() }, [])
// A confirm that closed itself took its opener ("Sign again") with it: focus goes to the message,
// or the panel, never the page body (WCAG 2.4.3). Only on the confirm closing, not on the sheet's.
const wasConfirming = useRef(false)
@@ -873,7 +875,7 @@ export default function SigningPanel() {
{t('signing.openLedger')}
) : errMsg ? (
-
{ errRef.current = el; focusOnMount(el) }}>
+
{errShown.text}
{/* A refusal shows what the device read, so the member can compare it with the
proposal as the message asks; and no Try again, because a refusal is not a failed send. */}
diff --git a/ui/src/signing-panel.test.tsx b/ui/src/signing-panel.test.tsx
index e9712e62..dadb09c2 100644
--- a/ui/src/signing-panel.test.tsx
+++ b/ui/src/signing-panel.test.tsx
@@ -611,5 +611,8 @@ describe('the sign-again confirm, accessibility pins', () => {
const src = codeOf('./screens/SigningPanel.tsx')
expect(src).toMatch(/if \(closed && typeof document !== 'undefined' && document\.activeElement === document\.body\) \(errRef\.current \?\? sheetRef\.current\)\?\.focus\(\)/)
expect(src).toMatch(/
/)
+ // An inline ref is a new function each render, so React would call it (and focus) on every commit.
+ expect(src).not.toMatch(/ref=\{\(el\) =>/)
+ expect(src).toMatch(/className="sign-err" tabIndex=\{-1\} ref=\{errMount\}/)
})
})