Repository navigation
feat(rules): group rules, trusted payees and tiered approvals - #37
Merged
Merged
Conversation
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>
3 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #36 (pay if agreed), and through it on #35, #34, #33 and #32. Merge those first; until then this diff also shows their changes.
Summary
Group rules for payments, adopted by poll and enforced by every member's app.
How they're enforced
Zcash has no contracts for shielded funds, so nothing on chain enforces these. Each app refuses to sign a payment that breaks them.
Design
Poll.rules, options Adopt / Don't adopt), andpolls::rules_in_effectpicks the latest set the group agreed to adopt.create_pollrefuses a rule this group can't meet: needing more approvals than it has members, or no more than its threshold.commands::rules::checktakes the payments a verified transaction makes.wallet_sendverifies its own draft exactly as approvers will, then refuses before asking anyone.AwaitingApproval.signers). A FROST signature needs a share from each of them, so the coordinator can't overstate how many will approve.Changes
rules.rs:GroupRules,check,approvals_needed.polls.rs:Poll.rules,PayeeTrustandrules_in_effect.payees.rs:trusted_byandtrusted.events.rsandsigning.rs: the signer count on the approval event.commands/rules.rs:group_rulesandcheck.payees.rs: newtrust_payeecommand.polls.rs:create_pollaccepts and checks rules.wallet.rs: the sender's rules check.signing.rs: the gate's rules check and annotation. Its arguments are grouped into aWalletRequeststruct to stay within clippy's limit.Testing
cargo clippy --workspace --all-targets -- -D warningscargo test --workspacetsc, thenvitest(40 tests)rules::tests: no rules, trusted payees only, large payments counted together, malformed rules.the_latest_rules_the_group_agreed_to_are_in_effecta_poll_proposing_rules_is_refused_unless_well_formeda_payee_is_trusted_once_enough_members_trust_its_address, including a changed address and trust taken back.tests/group_payees.rs, extended:tests/devnet_send.rs, which passed:Next
Delay with veto, and spending limits (automatic approval), will build on these rules.
🤖 Generated with Claude Code