Skip to content

feat(rules): larger payments wait for vetoes - #38

Merged
USCMig merged 12 commits into
mainfrom
feat/delay-veto
Oct 26, 2026
Merged

USCMig merged 12 commits into
mainfrom
feat/delay-veto

Conversation

@USCMig

@USCMig USCMig commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #37 (group rules), and through it on the earlier PRs. Merge those first; until then this diff also shows their changes.

Summary

Delay with veto, a new group rule. Payments of at least an amount (together, in one transaction) have to be announced first, and can be sent only after a wait in which no member vetoed them.

  1. Announce. The send form offers Announce it instead of Review payment for such a payment. That starts a payment poll, reusing pay if agreed.
  2. Veto. Any member vetoes by choosing Don't pay, and the veto holds for as long as they keep that answer. Nobody has to choose Pay.
  3. Send. Once the wait (1 hour to 30 days) has passed with no veto, the member who announced it sends it from Votes, with the usual approvals.

The wait runs on each member's own clock

  • Arrival times are recorded. The group log now notes when each entry arrived (GroupLog.received).
  • An announcement dates from when this app saw it. PollView.announced_at is the later of that arrival time and the time its author wrote on it.
  • Every member gets the full wait. Each app counts the wait from when it received the announcement, so backdating gains nothing.
  • Approvers who see it later wait longer. An approver whose app received the announcement later refuses until its own wait has passed, which is the point.

How it's enforced

  • One decision, commands::rules::poll_permits, says whether a poll's payments can be sent. Under a delay rule, it requires the wait to have passed with no veto (rules::announcement_ready). Otherwise it requires the group's agreement, as before.
  • Sender (wallet_send): refuses a poll that isn't ready, and a large payment without a poll (Broken::NotAnnounced).
  • Approver (the gate): refuses an announcement that isn't ready on its own clock, and an unannounced large payment, including one whose poll it doesn't have.
  • Approvals screen: shows "Announced … 30 hours ago, past the group's 1 day wait, and no member vetoed it."

Changes

  • Core
    • rules.rs: Delay, delay_for, announcement_ready and NotYet; Broken::NotAnnounced; check takes an announced flag; validity limits on the delay.
    • polls.rs: GroupLog.received, insert_received and received_at; PollView.announced_at.
    • polls.rs: the poll's rules are boxed. They pushed Message::Record over clippy's large-variant limit; serialization is unchanged.
  • App
    • polls.rs: records arrival times when writing and receiving.
    • rules.rs: poll_permits.
    • wallet.rs: sends a poll only when permitted, and tells the rules check it's announced.
    • signing.rs: the delay check in check_poll, and announced passed to the rules check.
  • Frontend
    • The delay option in Propose rules.
    • Announce it in the single and batch send forms, and Send all disabled under a delay.
    • Announcement states in Votes: waiting (and until when), vetoed (and by whom), or ready.
    • The announcement line in Approvals.
  • Docs: user guide (Rules), VOTING.md, README, CHANGELOG, TODO.md.

Testing

  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace
  • tsc, then vitest (44 tests)
  • Core tests:
    • large_payments_wait_for_their_announcement_and_any_veto_stops_them
    • a_poll_is_announced_when_this_device_got_it_whatever_time_its_author_put_on_it
    • Delay validity limits.
  • App test: an_announced_payment_waits_out_its_delay_and_any_veto_stops_it.
  • Devnet (NU7), tests/devnet_send.rs, which passed:
    1. A one-hour wait for payments of 0.5 ZEC or more is adopted by poll.
    2. 0.6 ZEC without an announcement is refused.
    3. The announced 0.6 ZEC is refused as too soon ("can be sent in 60 minutes").
    4. After Bob vetoes, it's refused as vetoed.
    5. A rules poll replacing those takes effect, and the rest of the run passes.
    • Waiting out a real hour isn't practical in the test, so the ready path is covered by the unit tests.
  • Frontend tests:
    • Proposing a delay rule.
    • Announce it replacing review.
    • An announcement waiting, then vetoed, then ready to send.
    • The announcement line in Approvals.

🤖 Generated with Claude Code

USCMig and others added 7 commits October 25, 2026 22:59
Payees are a group's shared list of the addresses it pays, by name. They
travel as group records alongside polls (core/src/payees.rs works them
out). A payee belongs to the member who added it: only their records can
change or remove it, so one member can't swap the address behind a name
another saved. The app also refuses a second payee with the same name or
address, and only shows payees valid on the configured network.

An approver's app names a payment from its own copy of the list, by the
address the transaction verifiably pays. verify::address_forms gives a
payee's address both as given and in the Orchard-only form a verified
payment shows, so a unified address with several receivers still matches.

A reason says what a payment is for, for the group only. It goes to
approvers in SigningContext.reason (older apps ignore the field) and,
once the payment is broadcast, into a PaymentReason record keyed by txid,
so every member's history shows it. Any member can add theirs to any
transaction. Try sending again records it after a successful rebroadcast.

The interface: a payee picker and "Save as a group payee" in the send
form, a Reason field, payee names and reasons in the review, the
approval and Activity, and a Payees card on Home.

SigningContext is boxed in ParticipantEvent::AwaitingApproval, which the
new field pushed over clippy's large-variant limit; it serializes the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
User guide section, README features and security model, CHANGELOG,
PRIVACY.md, and the rest of the Safe-style list in TODO.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Up to 20 payments in one transaction, for payroll, grants and invoices.
The send form's "Pay several at once" takes rows (recipient or saved
payee, amount, memo) or a pasted list, with one reason for the lot.

wallet::prepare_payments builds them with propose_transfer and a ZIP 321
request; prepare_send is now a batch of one through the same path (the
one-payment wrapper it used is exactly that). A memo too long to encode
is an error instead of being dropped, and memos are trimmed, as they
read back. Within a batch, the group's own address is refused: it would
read as change to co-signers.

Co-signers get every payment in SigningContext.payments (without memos,
which they read from the transaction). check_coordinator_claims matches
them to the verified payments as an unordered set, since Orchard
shuffles outputs: each described payment matches a different one, and
there are no others.

Signing requests are now sized to fit one frostd message. The context
travels hex-encoded inside the signing package, which the old 48 KiB
limit on the attached PCZT didn't count, so a large enough transaction
failed with "cryptography error from snow: input error".
signing::signer_context_budget gives the real room for the chosen
signers (tested against a serialized SendSigningPackageArgs for 2 to 40
signers), and the PCZT gets what the rest of the context leaves. One that
can't fit is refused at review, saying to make fewer payments.

History lists a send's external payments (TxRecord.payments). The wallet
learns of its own sends only by scanning, without their fee or outputs,
so the sending device shows its sends as it built them; recording them
in the wallet is in TODO.md.

Tested on an NU7 devnet: 20 payments, three with memos, approved and
mined; 20 with full memos refused at review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A group poll can propose payments (Poll.payments, up to 20), answered
"Pay" or "Don't pay". Once Pay is agreed, the member who proposed it
sends them with Send payment: wallet_send with poll_id builds the
transaction from the poll itself, refusing unless this member asked it,
the group agreed to pay, and it hasn't been paid. After broadcast, a
PollPaid record (only the poll's author's counts) marks it paid, so it
can't be sent twice, and the poll's question becomes the reason.

Each approver's app checks a request naming a poll (SigningContext.poll_id)
against its own copy of the poll and refuses one sent by a member other
than its author, one that doesn't make exactly the proposed payments (an
unordered match on address, amount and memo), or one already paid. It
shows whether the group agreed, or warns that it hasn't, or that it
doesn't have the poll yet. The coordinator's key, which the request's
encryption authenticates, now comes with the approval event for this.

Members who chose Pay are asked to approve by default. Approving
automatically for them is left for the spending-limit rules.

Tested on an NU7 devnet: refused before agreement and for Bob, sent by
Alice and checked by Bob's app, then shown paid on both and refused a
second time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A group can adopt rules for its payments, which the sender's app and each
approver's app apply when deciding whether to sign (core/src/rules.rs):

- Pay only trusted payees. A payee is trusted once threshold members
  trust its current address: its author, and others who said so in a
  PayeeTrust record. Changing the address leaves only the author's trust.
- Larger payments need more approvals: payments of at least an amount,
  together in one transaction, need more signers than the threshold. An
  approver counts the signers committed in the signing package
  (AwaitingApproval.signers), each of whose shares the signature needs.

Rules are adopted by poll (Poll.rules, options Adopt / Don't adopt), and
the latest set the group agreed to adopt is in effect, so changing them
takes as many members as spending. create_poll refuses rules this group
can't meet (more approvals than members, or no more than the threshold).

commands::rules::check runs the same check on both sides: the sender's
wallet_send verifies its own draft as approvers will and refuses before
asking anyone; the approval gate refuses a request that breaks the rules,
or whose rules it can't read, and tells the approver which rules it met.
A transfer within the group makes no payments, so the rules don't apply.

Interface: a Rules card on Home (in effect, and Propose rules), Trust on
the Payees card, rule polls in Votes, rule errors and approval counts in
the send forms, and the rules a request met in Approvals.

Tested on an NU7 devnet: with "trusted payees only" adopted by poll, a
payment to an untrusted address is refused before anyone is asked, and
one to the trusted auditor goes through with Bob's app checking it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new rule, GroupRules.delay: payments of at least an amount, together
in one transaction, are announced in a payment poll and can be sent only
once a wait (an hour to 30 days) has passed with no current member
choosing Don't pay. Agreement isn't needed; one member's veto stops it
for as long as they keep that answer.

Each app counts the wait from when it received the announcement: the
group log now records when each record arrived (GroupLog.received), and
PollView.announced_at is the later of that and the time its author put
on it. A backdated announcement gains nothing, and every member has the
whole wait to object.

commands::rules::poll_permits decides whether a poll's payments can be
sent: under the delay, once ready (rules::announcement_ready); otherwise,
once agreed. The sender's wallet_send refuses a poll that isn't, and a
payment over the amount without one (Broken::NotAnnounced). The approval
gate refuses an announcement that isn't ready on its own clock, and an
unannounced large payment, including one whose poll it doesn't have.

The send forms offer "Announce it" instead of review for such a payment;
Votes shows an announcement as waiting, vetoed (and by whom), or ready
for its author to send; Approvals shows how long ago it was announced.

Message::Record's poll grew past clippy's large-variant limit, so its
rules are boxed; serialization is unchanged.

Tested on an NU7 devnet: with a one-hour wait adopted by poll, a large
payment is refused unannounced, too soon after its announcement, and
once Bob vetoes; then rules replacing those take effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@USCMig
USCMig merged commit f33b0ee into main Oct 26, 2026
1 check passed
@USCMig
USCMig deleted the feat/delay-veto branch October 26, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant