Skip to content

feat(polls): pay if agreed - #36

Merged
USCMig merged 8 commits into
mainfrom
feat/pay-if-agreed
Oct 26, 2026
Merged

USCMig merged 8 commits into
mainfrom
feat/pay-if-agreed

Conversation

@USCMig

@USCMig USCMig commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #35 (batch payments), and through it on #34, #33 and #32. Merge those first; until then this diff also shows their changes.

Summary

Pay if agreed. A group poll can propose payments, so the group agrees on exactly what to pay before anyone is asked to sign. This connects discussion, vote and payment.

  1. Propose. In New poll, tick Propose a payment and enter the payments. It uses the same rows as batch payments: an address or saved payee, an amount and a memo, up to 20. The options become Pay and Don't pay.
  2. Vote. Members answer as on any poll. Pay is agreed once as many members choose it as a payment needs.
  3. Send. The member who proposed it gets Send payment, with members to ask preselected (those who chose Pay). The backend builds the transaction from the poll itself and uses the poll's question as the reason.
  4. Approve. Each approver's app checks the request against its own copy of the poll.
  5. Paid. After broadcast, the poll shows Paid and can't be sent again.

Safeguards

  • The sender checks the poll first. wallet_send with a poll_id refuses unless this member proposed the poll, the group agreed to pay, and the poll hasn't been paid. The payments come from the poll, never from the caller.
  • Approvers check it against their own copy. check_poll refuses automatically when:
    • the request is sent by anyone but the poll's author;
    • the transaction doesn't make exactly the proposed payments (an unordered match on address, amount and memo, using the same address forms as payees);
    • the poll was already paid.
  • Approvers see where the poll stands. The app shows "Agreed in the poll …: 2 chose Pay, 2 needed", or warns that it isn't agreed, was declined, or isn't on this device yet.
  • Who asked is authenticated. The coordinator's key now comes with the approval event; the request's Noise encryption authenticates it.
  • A poll pays once. Only the poll's author's PollPaid record counts. The sender's app refuses a second send, and approvers' apps refuse to sign one.

Not in this PR

Having the apps of members who chose Pay approve automatically is left for the spending-limit rules in TODO.md. For now they approve as usual, with the poll check shown.

Changes

  • Core
    • polls.rs: new ProposedPayment, Poll.payments, PollPaid and their limits in check(); PollView.payments and paid_txid.
    • events.rs: SigningContext.poll_id, and AwaitingApproval.coordinator.
    • signing.rs: passes the coordinator through.
    • wallet.rs: is_transparent_address.
  • App
    • commands/polls.rs: create_poll takes payments and checks them as a send would; new pays_what_was_proposed.
    • commands/wallet.rs: wallet_send takes poll_id, then records PollPaid and the reason after broadcast.
    • commands/signing.rs: check_poll in the gate, and a poll annotation on the approval event.
  • Frontend
    • Votes: the proposal form; the proposed payments on the poll card; and the outcome, which is either Send payment with signer picker, "Alice can send it now", "decided not to pay", or "Paid".
    • Approvals: the poll status.
  • Docs: a "Propose a payment" section in docs/VOTING.md, plus the user guide, README, CHANGELOG and TODO.md.

Testing

  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace
  • tsc, then vitest (34 tests)
  • Core tests:
    • a_poll_proposes_payments_and_only_its_author_marks_it_paid
    • a_poll_proposing_payments_is_refused_unless_well_formed
  • App test: a_transaction_pays_what_was_proposed_only_if_it_makes_exactly_those_payments. It covers any order, a missing payment, a changed amount, a different memo, another recipient, an extra payment, and each proposed payment matching only once.
  • Devnet (NU7), tests/devnet_send.rs, which passed. After the batch:
    1. Alice proposes a payment, and Bob's app gets the poll with options Pay / Don't pay.
    2. Sending before the group agrees is refused.
    3. Both vote Pay.
    4. Bob can't send it.
    5. Alice sends it; Bob's app reports the poll as found and agreed, and the transaction is mined.
    6. Bob's app shows it paid, and a second send is refused.
  • Frontend tests:
    • Proposing a payment, with fixed options and payee picking.
    • The author sending it, with signers defaulting to the Pay voters and poll_id and reason passed through.
    • Other members seeing who can send it, then "Paid".
    • The approver seeing the poll agreement.
  • Screenshots of the proposal form, poll card and approval were checked against a production build.

🤖 Generated with Claude Code

USCMig and others added 5 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>
@USCMig
USCMig merged commit dce89e2 into main Oct 26, 2026
1 check passed
@USCMig
USCMig deleted the feat/pay-if-agreed branch October 26, 2026 03:46
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