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..2b16a1bc --- /dev/null +++ b/ui/changelog.d/known-limits/signing-record-residuals.md @@ -0,0 +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. 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..c55b278c --- /dev/null +++ b/ui/changelog.d/security/device-signing-record.md @@ -0,0 +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 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/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/VaultSigner.tsx b/ui/src/VaultSigner.tsx index ac72c4b7..e8b0a42b 100644 --- a/ui/src/VaultSigner.tsx +++ b/ui/src/VaultSigner.tsx @@ -10,13 +10,13 @@ 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' 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 } | 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, @@ -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 } | 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', @@ -204,7 +207,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 @@ -212,7 +219,17 @@ 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, 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) { + const at = lapsed.earlier.length ? Math.max(...lapsed.earlier) : 0 + // 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 }, sameOutputs: !!lapsed.sameOutputs, sameAt: lapsed.sameAt }) + } return r === 'ok' }, [], 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..2621c4ba 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.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 cfb05b83..af661856 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,19 @@ 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. 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': '{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.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.', + '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 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', @@ -1367,7 +1380,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. 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 63068d20..a14a8814 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,19 @@ 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. 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': '{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.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.', + '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 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', @@ -1363,7 +1376,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. 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 new file mode 100644 index 00000000..43f78efa --- /dev/null +++ b/ui/src/net-execute.test.ts @@ -0,0 +1,28 @@ +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('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 \} \}\)/) + }) + it('a coordinator request is consumed before the signer sees it', () => { + 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/owner-signing.test.ts b/ui/src/owner-signing.test.ts index 29de8f4c..0561d51f 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. @@ -23,6 +25,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. @@ -55,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, sameAt: undefined }) expect(reloaded.allows('p1'), 'the refused click is spent').toBe(false) }) @@ -144,4 +148,111 @@ 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).toMatchObject({ 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') + }) + + // 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) + // 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('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) + 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) + }) +}) + +// 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 92676e38..005e8b94 100644 --- a/ui/src/owner-signing.ts +++ b/ui/src/owner-signing.ts @@ -7,9 +7,19 @@ // 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 { decideAmong, decideFor, readByOutputs, readSigned, recordSigned, type RecordStore, type SignedTx } from './signed-record' -export type Claim = 'ok' | 'second' | 'refused' | 'unreadable' +/** 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 { readonly consent: OwnerConsent = newConsent() @@ -19,9 +29,14 @@ 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 + 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; sameAt?: number } | null = null private readonly store: RecordStore private readonly now: () => number @@ -34,9 +49,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 { @@ -56,11 +73,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". @@ -70,12 +94,25 @@ 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, sameAt: lastAt(earlierList) } 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 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, sameAt: lastAt(earlierList) } + return 'lapsed' + } if (!claimConsent(this.consent, activeId, this.now(), sighashHex)) return 'refused' - if (decision.kind === 'second') this.again = null - this.claimed = { vault, proposal: activeId, sighash: sighashHex.trim().toLowerCase() } + if (decision.kind === 'second' || decision.kind === 'afterExpiry') this.again = null + this.claimed = { vault, proposal: activeId, sighash: sighashHex.trim().toLowerCase(), outputs } return 'ok' } @@ -90,6 +127,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/screens/NetVault.tsx b/ui/src/screens/NetVault.tsx index 256ce7e3..436b816f 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' @@ -280,16 +276,9 @@ 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 [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) @@ -498,18 +487,21 @@ 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 // 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), @@ -567,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') { @@ -647,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' || @@ -876,44 +872,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 +1590,6 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: { - {propMsg && ( -

{propMsg}

- )} {(() => { const ready = (proposals ?? []).filter((p) => p.state === 'ready') if (proposals && ready.length === 0) { @@ -1661,17 +1616,13 @@ export default function NetVault({ embedded, initialJoin, leaveAsk, onLeave }: {
+ {/* Signed from the proposal's own signing panel, which records what this device + signed and checks the approval; /net's driver does neither. */}
diff --git a/ui/src/screens/SigningPanel.tsx b/ui/src/screens/SigningPanel.tsx index 333519f4..d9dfc7f0 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, 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 @@ -113,6 +114,18 @@ 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 + /** 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 } + /** 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 @@ -133,11 +146,13 @@ 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 = 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) }) @@ -150,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 ? (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 && !sameOutputs && 'sign-again-txid', 'sign-confirm-what'].filter(Boolean).join(' ') : 'sign-confirm-what'} focusCard={again} onClose={onCancel} > @@ -166,13 +181,31 @@ export function SignConfirm({ payrollLines, destPublic = false, again, firstUnti {again ? ( <>

- {t('signing.againBody', { amt })} + {sameOutputs + ? 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 })} {iWouldBeLast && <> {t('signing.confirmBody')}}

{until &&

{until}

} + {lapsedAt !== null && !sameOutputs && ( + <> + {/* 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}

} + + )} ) : ( @@ -349,6 +382,17 @@ 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) + // 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) + 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. @@ -831,7 +875,7 @@ export default function SigningPanel() { {t('signing.openLedger')} ) : errMsg ? ( -
+
{/* 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. */} @@ -975,6 +1019,11 @@ export default function SigningPanel() { firstUntil={signedBefore?.firstUntil ?? 0} byEpoch={signedBefore?.byEpoch ?? false} untilHeight={signedBefore?.untilHeight ?? null} + lapsedAt={signedBefore?.lapsedAt ?? null} + 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/send-attempt.test.ts b/ui/src/send-attempt.test.ts index 9dcb3c9b..853be744 100644 --- a/ui/src/send-attempt.test.ts +++ b/ui/src/send-attempt.test.ts @@ -63,13 +63,3 @@ describe('the panel goes through the send memory', () => { expect(button).not.toMatch(/doSend/) }) }) - -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\./) - }) -}) diff --git a/ui/src/signed-record.test.ts b/ui/src/signed-record.test.ts index 189fce60..a32cd05e 100644 --- a/ui/src/signed-record.test.ts +++ b/ui/src/signed-record.test.ts @@ -184,3 +184,43 @@ 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') + // 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) + expect(await txidIsOurs(sighash, [h]), 'not reversed').toBe(false) + expect(await txidIsOurs(txid, [])).toBe(false) + 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') + } + }) +}) + +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 bdeb6fdf..c499b6f2 100644 --- a/ui/src/signed-record.ts +++ b/ui/src/signed-record.ts @@ -13,16 +13,23 @@ // 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. 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' 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' } @@ -47,6 +54,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. @@ -60,15 +77,32 @@ 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') + // 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 +} + /** 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 +111,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/signing-panel.test.tsx b/ui/src/signing-panel.test.tsx index 2cb4d869..dadb09c2 100644 --- a/ui/src/signing-panel.test.tsx +++ b/ui/src/signing-panel.test.tsx @@ -283,6 +283,64 @@ 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 block=3600123') + 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 tx = 'ab'.repeat(32) + 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).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.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') @@ -536,3 +594,25 @@ 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(//) + // 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\}/) + }) +}) diff --git a/ui/src/vault-signer.test.tsx b/ui/src/vault-signer.test.tsx index 75e34a29..b9015bb5 100644 --- a/ui/src/vault-signer.test.tsx +++ b/ui/src/vault-signer.test.tsx @@ -91,10 +91,28 @@ 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'/) }) + // 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\}/) + 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 \}/) + }) it('the hook hands them to the session unchanged', () => { expect(codeOf('./useBackgroundSigner.ts')).toMatch(/new BackgroundSession\(\{\s*decideApproval,\s*claim,\s*record,\s/) })