From 7b5246554e68796b886f643506362737e3925553 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sun, 27 Sep 2026 08:34:46 -0400 Subject: [PATCH 1/3] Apply a pledge's payee list before the first note, and ask for one wait. Creation writes the list and the shorter delay before minting, and both deposit paths collect names that can be paid immediately. The pledge page edits what future notes inherit. --- TODO.md | 4 +- .../contracts/delegation/RecurringPledges.sol | 39 ++- hardhat/test/RecurringPledges.test.js | 24 +- indexer/abis/RecurringPledgesAbi.ts | 29 +++ sdk/abis/RecurringPledgesAbi.ts | 29 +++ sdk/src/subsystems/delegation/actions.ts | 75 +++--- .../delegation/recurring-pledges.ts | 55 ++++ ...pledge-classification-before-first-note.md | 34 +++ specs/decisions/README.md | 1 + specs/glossary.md | 2 +- .../delegation/spend-classification.md | 22 +- .../subsystems/delegation/waiting-period.md | 2 + .../shell/SuspiciousSpendBanner.tsx | 2 +- .../components/DonorPendingSpends.tsx | 2 +- .../components/FineListPanel.test.ts | 4 +- .../delegation/components/FineListPanel.tsx | 6 +- .../components/PendingSpendCard.tsx | 2 +- .../components/SpendPolicyPanel.tsx | 45 ++-- ui/src/delegation/pages/DepositPage.test.tsx | 18 +- ui/src/delegation/pages/DepositPage.tsx | 133 +++++++++- ui/src/delegation/pages/MyNotesPage.tsx | 12 +- ui/src/delegation/pages/PledgePage.tsx | 234 ++++++++++++++++++ ui/src/delegation/pages/index.ts | 1 + ui/src/delegation/spendClass.test.ts | 8 +- ui/src/delegation/spendClass.ts | 12 +- ui/src/domains/CrossDomainSmoke.test.tsx | 2 +- ui/src/domains/CrossLinkCrawler.test.tsx | 1 + ui/src/domains/DomainDeepLinksSmoke.test.tsx | 1 + ui/src/domains/commonality/manifest.tsx | 1 + ui/src/domains/content-funding/manifest.tsx | 1 + ui/src/domains/lazy-giving/manifest.tsx | 1 + 31 files changed, 707 insertions(+), 95 deletions(-) create mode 100644 specs/decisions/0018-pledge-classification-before-first-note.md create mode 100644 ui/src/delegation/pages/PledgePage.tsx diff --git a/TODO.md b/TODO.md index d67366e84..e1ee09ec0 100644 --- a/TODO.md +++ b/TODO.md @@ -20,9 +20,7 @@ Getting **testnet to a two-person shared lab** is also a standing plan, not a pi ---- -- **(Tell)** Send the donor-set waiting-period page. Note and project screens show a pending spend's amount and deadline as money that can still be cancelled, and the note screen has the delay, unsuspicious delay, strict mode, and flaggers. `shouldPageDonor` still only decides once per `(noteId, nonce)`. There is no opt-in store and no email or push sender. Public remarks from people who are not flaggers stay a later UI feature and are not stored on-chain. Rules: [waiting-period.md](specs/tech/subsystems/delegation/waiting-period.md). - -- **(Ask)** Expose recurring pledge fine-list and unsuspicious-delay editing in the SDK and UI. The contract supports `setPledgeFineListed` and `setPledgeUnsuspiciousDelay`, but the product controls currently edit individual notes only. Make clear that pledge edits affect future notes and do not update already-minted notes. Rules: [spend-classification.md](specs/tech/subsystems/delegation/spend-classification.md). +- **(Tell)** Send the donor-set waiting-period page. Note and project screens show a pending spend's amount and deadline as money that can still be cancelled. `shouldPageDonor` still only decides once per `(noteId, nonce)`. There is no opt-in store and no email or push sender. Public remarks from people who are not flaggers stay a later UI feature and are not stored on-chain. Rules: [waiting-period.md](specs/tech/subsystems/delegation/waiting-period.md). - One voice for delegation copy. The donor is authorizing an address to spend a stated amount on projects in Commonality. The delegate promises nothing. A stated intent is public and does not bind the spend. Unspent funds stay revocable by the donor. Commonality does not hold the funds, choose the delegate, or supervise the spending. Remove the steward voice: entrusting money to a scout, program-officer framing, "money under management," and any Commonality ranking whose job is to send people to a delegate. A public history of what an address already funded can stay. "Scout" as the early contributor who may later be reimbursed at cost can stay; do not let that word mean a manager of other people's money. Start with `specs/product/legal/retroactive-funding-redesign.md` (Design 2) and `docs/end-user/lazyGiving/` (`retroactive-funding.md`, `index.md`, `fund-something.md`, `get-your-project-funded.md`). No delegate marketplace. diff --git a/hardhat/contracts/delegation/RecurringPledges.sol b/hardhat/contracts/delegation/RecurringPledges.sol index b1167d9b5..0c08cc49b 100644 --- a/hardhat/contracts/delegation/RecurringPledges.sol +++ b/hardhat/contracts/delegation/RecurringPledges.sol @@ -83,8 +83,8 @@ contract RecurringPledges is ReentrancyGuard { uint256 public nextPledgeId = 1; mapping(uint256 => Pledge) public pledges; mapping(uint256 => address[]) private pledgeFlaggerList; - mapping(uint256 => bytes32[]) private pledgeFineList; - mapping(uint256 => mapping(bytes32 => bool)) public pledgeFineListed; + mapping(uint256 => bytes32[]) private pledgeFineIds; + mapping(uint256 => mapping(bytes32 => bool)) public pledgeFineIdsed; constructor(address delegatableNotesAddress) { if (delegatableNotesAddress == address(0)) revert ZeroAddress(); @@ -98,8 +98,10 @@ contract RecurringPledges is ReentrancyGuard { uint256 period, string calldata causeRef, uint256 spendDelay, + uint256 unsuspiciousDelay, bool strictMode, - address[] calldata flaggers + address[] calldata flaggers, + bytes32[] calldata fineIds ) external nonReentrant returns (uint256 pledgeId, uint256 firstNoteId) { address rootOwner = msg.sender; if (rootOwner == address(0) || delegateTo == address(0) || token == address(0)) revert ZeroAddress(); @@ -122,6 +124,10 @@ contract RecurringPledges is ReentrancyGuard { unsuspiciousDelay: 0, strictMode: strictMode }); + _setUnsuspiciousDelay(pledgeId, pledges[pledgeId], unsuspiciousDelay); + for (uint256 i = 0; i < fineIds.length; i++) { + _setFineListed(pledgeId, fineIds[i], true); + } for (uint256 i = 0; i < flaggers.length; i++) { if (flaggers[i] != address(0) && flaggers[i] != delegateTo) { pledgeFlaggerList[pledgeId].push(flaggers[i]); @@ -174,8 +180,7 @@ contract RecurringPledges is ReentrancyGuard { } emit PledgeSpendPolicyUpdated(pledgeId, spendDelay, strictMode); if (pledge.unsuspiciousDelay > spendDelay) { - pledge.unsuspiciousDelay = spendDelay; - emit PledgeUnsuspiciousDelaySet(pledgeId, spendDelay); + _setUnsuspiciousDelay(pledgeId, pledge, spendDelay); } } @@ -184,6 +189,10 @@ contract RecurringPledges is ReentrancyGuard { if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist(); if (pledge.rootOwner != msg.sender) revert NotPledgeOwner(); if (!pledge.active) revert PledgeInactive(); + _setUnsuspiciousDelay(pledgeId, pledge, unsuspiciousDelay); + } + + function _setUnsuspiciousDelay(uint256 pledgeId, Pledge storage pledge, uint256 unsuspiciousDelay) private { if (unsuspiciousDelay > pledge.spendDelay) revert UnsuspiciousDelayExceedsStanding(); pledge.unsuspiciousDelay = unsuspiciousDelay; emit PledgeUnsuspiciousDelaySet(pledgeId, unsuspiciousDelay); @@ -194,17 +203,21 @@ contract RecurringPledges is ReentrancyGuard { if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist(); if (pledge.rootOwner != msg.sender) revert NotPledgeOwner(); if (!pledge.active) revert PledgeInactive(); + _setFineListed(pledgeId, beneficiaryId, allowed); + } + + function _setFineListed(uint256 pledgeId, bytes32 beneficiaryId, bool allowed) private { if (beneficiaryId == bytes32(0)) revert ZeroAddress(); - if (allowed == pledgeFineListed[pledgeId][beneficiaryId]) { + if (allowed == pledgeFineIdsed[pledgeId][beneficiaryId]) { emit PledgeFineListSet(pledgeId, beneficiaryId, allowed); return; } if (allowed) { - pledgeFineListed[pledgeId][beneficiaryId] = true; - pledgeFineList[pledgeId].push(beneficiaryId); + pledgeFineIdsed[pledgeId][beneficiaryId] = true; + pledgeFineIds[pledgeId].push(beneficiaryId); } else { - pledgeFineListed[pledgeId][beneficiaryId] = false; - bytes32[] storage ids = pledgeFineList[pledgeId]; + pledgeFineIdsed[pledgeId][beneficiaryId] = false; + bytes32[] storage ids = pledgeFineIds[pledgeId]; for (uint256 i = 0; i < ids.length; i++) { if (ids[i] == beneficiaryId) { ids[i] = ids[ids.length - 1]; @@ -222,6 +235,10 @@ contract RecurringPledges is ReentrancyGuard { return pledgeFlaggerList[pledgeId]; } + function pledgeFineList(uint256 pledgeId) external view returns (bytes32[] memory) { + return pledgeFineIds[pledgeId]; + } + function executeDue(uint256 pledgeId) external nonReentrant returns (uint256 noteId) { Pledge storage pledge = pledges[pledgeId]; if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist(); @@ -259,7 +276,7 @@ contract RecurringPledges is ReentrancyGuard { pledge.unsuspiciousDelay, pledge.strictMode, pledgeFlaggerList[pledgeId], - pledgeFineList[pledgeId] + pledgeFineIds[pledgeId] ); emit StandingPledgeExecuted(pledgeId, noteId, executedAt); } diff --git a/hardhat/test/RecurringPledges.test.js b/hardhat/test/RecurringPledges.test.js index 0495fb079..d3a163e4a 100644 --- a/hardhat/test/RecurringPledges.test.js +++ b/hardhat/test/RecurringPledges.test.js @@ -28,12 +28,28 @@ async function deployFixture() { } describe("RecurringPledges", function () { + it("copies the list and shorter delay onto the first note", async function () { + const { alice, bob, notes, recurringPledges, token } = await deployFixture(); + const id = ethers.id("dns:example.org"); + await token.connect(alice).approve(notes.target, 10_000n); + await recurringPledges.connect(alice).createStandingPledge( + bob.address, token.target, 10_000n, 60, "bafy-cause", 100, 20, false, [], [id] + ); + expect(await recurringPledges.pledgeFineList(1)).to.deep.equal([id]); + expect(await notes.fineList(1)).to.deep.equal([id]); + expect((await notes.spendPolicies(1)).unsuspiciousDelay).to.equal(20); + expect((await notes.spendPolicies(1)).delay).to.equal(100); + await expect(recurringPledges.connect(alice).createStandingPledge( + bob.address, token.target, 10_000n, 60, "bafy-cause", 10, 11, false, [], [] + )).to.be.revertedWithCustomError(recurringPledges, "UnsuspiciousDelayExceedsStanding"); + }); + it("copies policy edits only to later notes and enforces owner and delay bounds", async function () { const { alice, bob, carol, notes, recurringPledges, token } = await deployFixture(); const id = ethers.id("dns:example.org"); await token.connect(alice).approve(notes.target, 30_000n); await recurringPledges.connect(alice).createStandingPledge( - bob.address, token.target, 10_000n, 60, "bafy-cause", 100, true, [carol.address] + bob.address, token.target, 10_000n, 60, "bafy-cause", 100, 0, true, [carol.address], [] ); await expect(recurringPledges.connect(bob).setPledgeFineListed(1, id, true)) .to.be.revertedWithCustomError(recurringPledges, "NotPledgeOwner"); @@ -79,7 +95,9 @@ describe("RecurringPledges", function () { period, "bafy-cause", 0, + 0, false, + [], [] ); @@ -119,7 +137,9 @@ describe("RecurringPledges", function () { period, "bafy-cause", 0, + 0, false, + [], [] ); @@ -150,7 +170,9 @@ describe("RecurringPledges", function () { 60, "bafy-cause", 0, + 0, false, + [], [] ); diff --git a/indexer/abis/RecurringPledgesAbi.ts b/indexer/abis/RecurringPledgesAbi.ts index 1451df640..fda219d77 100644 --- a/indexer/abis/RecurringPledgesAbi.ts +++ b/indexer/abis/RecurringPledgesAbi.ts @@ -288,6 +288,11 @@ export const RecurringPledgesAbi = [ "name": "spendDelay", "type": "uint256" }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + }, { "internalType": "bool", "name": "strictMode", @@ -297,6 +302,11 @@ export const RecurringPledgesAbi = [ "internalType": "address[]", "name": "flaggers", "type": "address[]" + }, + { + "internalType": "bytes32[]", + "name": "fineIds", + "type": "bytes32[]" } ], "name": "createStandingPledge", @@ -422,6 +432,25 @@ export const RecurringPledgesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + } + ], + "name": "pledgeFineList", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/sdk/abis/RecurringPledgesAbi.ts b/sdk/abis/RecurringPledgesAbi.ts index 1451df640..fda219d77 100644 --- a/sdk/abis/RecurringPledgesAbi.ts +++ b/sdk/abis/RecurringPledgesAbi.ts @@ -288,6 +288,11 @@ export const RecurringPledgesAbi = [ "name": "spendDelay", "type": "uint256" }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + }, { "internalType": "bool", "name": "strictMode", @@ -297,6 +302,11 @@ export const RecurringPledgesAbi = [ "internalType": "address[]", "name": "flaggers", "type": "address[]" + }, + { + "internalType": "bytes32[]", + "name": "fineIds", + "type": "bytes32[]" } ], "name": "createStandingPledge", @@ -422,6 +432,25 @@ export const RecurringPledgesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + } + ], + "name": "pledgeFineList", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/sdk/src/subsystems/delegation/actions.ts b/sdk/src/subsystems/delegation/actions.ts index 905ace281..1ecb8bd15 100644 --- a/sdk/src/subsystems/delegation/actions.ts +++ b/sdk/src/subsystems/delegation/actions.ts @@ -145,59 +145,78 @@ export async function depositERC20( * }); * ``` */ -export async function delegateNote( +async function readDelegationResult( clients: WriteClients, - delegatableNotesContract: DelegatableNotesContract, - params: { - noteId: bigint; - owners: Address[]; // Delegation chain (leaf first, root last) - delegateTo: Address; - amount: bigint; - } + hash: Hash, + noteId: bigint, ): Promise<{ hash: Hash; delegatedNoteId: bigint; remainderNoteId: bigint }> { - const hash = await clients.walletClient.writeContract({ - address: delegatableNotesContract.address, - abi: delegatableNotesContract.abi, - functionName: 'delegate', - args: [params.noteId, params.owners, params.delegateTo, params.amount], - chain: clients.walletClient.chain, - account: clients.walletClient.account!, - }); - const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }); - - // Parse events to get the delegated note ID - // The event could be a ChainSplit (partial delegation) or NoteDelegated (full delegation) - let delegatedNoteId = params.noteId; // Default to same note for full delegation + let delegatedNoteId = noteId; let remainderNoteId = 0n; - - // Look for ChainSplit event first (partial delegation) const chainSplitLogs = parseEventLogs({ abi: DelegatableNotesAbi, eventName: 'ChainSplit', logs: receipt.logs, }); - if (chainSplitLogs.length > 0) { - // Partial delegation occurred delegatedNoteId = chainSplitLogs[0].args.splitLeafId; remainderNoteId = chainSplitLogs[0].args.remainderLeafId; } else { - // Full delegation - parse NoteDelegated event const noteDelegatedLogs = parseEventLogs({ abi: DelegatableNotesAbi, eventName: 'NoteDelegated', logs: receipt.logs, }); - if (noteDelegatedLogs.length > 0) { delegatedNoteId = noteDelegatedLogs[0].args.childNoteId; } } - return { hash, delegatedNoteId, remainderNoteId }; } +export async function delegateWithDelay( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { + noteId: bigint; + owners: Address[]; + delegateTo: Address; + amount: bigint; + delay: bigint; + }, +): Promise<{ hash: Hash; delegatedNoteId: bigint; remainderNoteId: bigint }> { + const hash = await clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'delegateWithDelay', + args: [params.noteId, params.owners, params.delegateTo, params.amount, params.delay], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); + return readDelegationResult(clients, hash, params.noteId); +} + +export async function delegateNote( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { + noteId: bigint; + owners: Address[]; // Delegation chain (leaf first, root last) + delegateTo: Address; + amount: bigint; + } +): Promise<{ hash: Hash; delegatedNoteId: bigint; remainderNoteId: bigint }> { + const hash = await clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'delegate', + args: [params.noteId, params.owners, params.delegateTo, params.amount], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); + return readDelegationResult(clients, hash, params.noteId); +} + /** * Root replaces the current delegate. The new delegate receives a new note. * A full replacement retires `noteId`. A partial replacement leaves the diff --git a/sdk/src/subsystems/delegation/recurring-pledges.ts b/sdk/src/subsystems/delegation/recurring-pledges.ts index cd54692d2..723f009db 100644 --- a/sdk/src/subsystems/delegation/recurring-pledges.ts +++ b/sdk/src/subsystems/delegation/recurring-pledges.ts @@ -219,8 +219,10 @@ export async function createStandingPledge( period: bigint; causeRef: string; spendDelay?: bigint; + unsuspiciousDelay?: bigint; strictMode?: boolean; flaggers?: Address[]; + fineIds?: `0x${string}`[]; }, ): Promise<{ hash: Hash; pledgeId: bigint; firstNoteId: bigint }> { const hash = await clients.walletClient.writeContract({ @@ -234,8 +236,10 @@ export async function createStandingPledge( params.period, params.causeRef, params.spendDelay ?? 0n, + params.unsuspiciousDelay ?? 0n, params.strictMode ?? false, params.flaggers ?? [], + params.fineIds ?? [], ], chain: clients.walletClient.chain, account: clients.walletClient.account!, @@ -284,3 +288,54 @@ export async function executeDueStandingPledge( if (executed.length === 0) throw new Error('Failed to find StandingPledgeExecuted event'); return { hash, noteId: executed[0].args.noteId }; } + +export async function updatePledgeSpendPolicy( + clients: WriteClients, + recurringPledgesContract: RecurringPledgesContract, + params: { pledgeId: bigint; spendDelay: bigint; strictMode: boolean; flaggers: Address[] }, +): Promise { + const hash = await clients.walletClient.writeContract({ + address: recurringPledgesContract.address, + abi: recurringPledgesContract.abi, + functionName: 'updateSpendPolicy', + args: [params.pledgeId, params.spendDelay, params.strictMode, params.flaggers], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); + await clients.publicClient.waitForTransactionReceipt({ hash }); + return hash; +} + +export async function setPledgeUnsuspiciousDelay( + clients: WriteClients, + recurringPledgesContract: RecurringPledgesContract, + params: { pledgeId: bigint; delay: bigint }, +): Promise { + const hash = await clients.walletClient.writeContract({ + address: recurringPledgesContract.address, + abi: recurringPledgesContract.abi, + functionName: 'setPledgeUnsuspiciousDelay', + args: [params.pledgeId, params.delay], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); + await clients.publicClient.waitForTransactionReceipt({ hash }); + return hash; +} + +export async function setPledgeFineListed( + clients: WriteClients, + recurringPledgesContract: RecurringPledgesContract, + params: { pledgeId: bigint; beneficiaryId: `0x${string}`; allowed: boolean }, +): Promise { + const hash = await clients.walletClient.writeContract({ + address: recurringPledgesContract.address, + abi: recurringPledgesContract.abi, + functionName: 'setPledgeFineListed', + args: [params.pledgeId, params.beneficiaryId, params.allowed], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); + await clients.publicClient.waitForTransactionReceipt({ hash }); + return hash; +} diff --git a/specs/decisions/0018-pledge-classification-before-first-note.md b/specs/decisions/0018-pledge-classification-before-first-note.md new file mode 100644 index 000000000..6b1018273 --- /dev/null +++ b/specs/decisions/0018-pledge-classification-before-first-note.md @@ -0,0 +1,34 @@ +# 0018. A standing pledge applies its fine list before the first note, and new delegations prefill a 72 hour wait + +- **Status:** Accepted +- **Date:** 2026-09-27 +- **Related specs:** [`specs/tech/subsystems/delegation/spend-classification.md`](../tech/subsystems/delegation/spend-classification.md), [`specs/tech/subsystems/delegation/waiting-period.md`](../tech/subsystems/delegation/waiting-period.md) + +## Context + +`createStandingPledge` stored the pledge and minted the first note in the same transaction. The fine list and `U` were not arguments, so that note always copied an empty list and `U = 0`. The product also never sent a standing delay, so `T` was 0 and a non-zero `U` could not be saved later. `executeDue` is permissionless, and a pledge with `lastExecuted == 0` is already due. + +## Decision + +Creation still mints the first note in that transaction. Internally it stores the pledge, sets `U`, sets the fine list, then mints, so the first note receives them. `executeDue` remains the later permissionless execution and is not what produces the first note. A `pledgeFineList` getter exposes the list. Editing the pledge still changes only notes minted afterward. + +The deposit screen, for both a new standing pledge and a one-shot delegation, asks for one wait and for names that can be paid immediately. The wait is prefilled at 72 hours. She can clear it, including to 0. A name is paid immediately unless she opens the optional shorter wait. The contract and the SDK still treat an omitted delay as 0. The screen does not lead with a second duration, and it does not use the class names. It says that an immediate payment to a listed name cannot be cancelled, and that the list matches only a payment straight to the wallet that currently controls that name. The note page shows the wait stored on that note. + +## Alternatives considered + +- **Leave creation as it is and tell her the first note is the exception.** Rejected. With `T = 0` the delegate can spend that note before a follow-up transaction. +- **Stop minting inside create, then let her set policy and call `executeDue`.** Rejected. Anyone can call `executeDue` once the pledge is due, so the first note can still be minted empty, ahead of her next transaction. +- **Separate external calls, batched by a multicall contract.** Rejected. The policy setters require `msg.sender` to be the pledge owner. A multicall would fail that check. Internal calls from the one create function keep `msg.sender`. +- **Default `U` to `T`.** Already rejected in [ADR 0017](./0017-spend-classification.md). An empty list makes `U` unused, and adding a name is not a second chore. +- **Change the contract default of `T` to 72 hours.** Rejected. Zero remains a legal delay, and existing callers that omit it keep today's behavior. 72 hours is only the form prefill. +- **Edit the fine list and `U` on the summary card, or copy them onto notes already minted.** Rejected. The card stays a summary and links to a pledge page. Notes already minted are edited on the note page. +- **Collect the list only for a standing pledge.** Rejected. A one-shot delegation is the same decision for one pile of money. The note is the policy, so the deposit screen writes the list onto that note. +- **Leave strict mode and flaggers off the pledge page.** Rejected. The pledge already stores them and copies them onto later notes. The pledge page is everything a future note inherits. On the note page, flaggers stay in their own section, apart from the wait. + +## Consequences + +The create signature gains the initial list and `U`. A one-shot delegation uses `delegateWithDelay` for the prefilled wait, then writes the same list onto the new note. It writes `U` only when she set a non-zero shorter wait. `U = 0` needs no extra call. + +The pledge page edits `T`, `U`, the list, strict mode, and flaggers. The list is saved as she adds or removes a name. The other fields are one save. The note page shows one wait, the names, and an optional shorter wait. Strict mode and flaggers are a separate section. Pending rows say "On your list" or "Not on your list". The reserved class is not labeled suspicious, and nothing in the product produces it yet. + +Revisit the 72 hour prefill if donors treat it as a trap, or routinely clear it to zero. Revisit minting inside create if the first pull should wait a full period. diff --git a/specs/decisions/README.md b/specs/decisions/README.md index c717b47eb..077c6f81b 100644 --- a/specs/decisions/README.md +++ b/specs/decisions/README.md @@ -65,3 +65,4 @@ instance most needs answered and can't get anywhere else. | [0015](./0015-per-project-beneficiary-proceeds.md) | Beneficiary proceeds stay in the project that raised them | Accepted | | [0016](./0016-one-hop-delegation.md) | A delegated note has one delegate, and replacing that delegate mints a new note | Accepted | | [0017](./0017-spend-classification.md) | Delegated spends are classified, and only a current controller is unsuspicious | Accepted | +| [0018](./0018-pledge-classification-before-first-note.md) | A standing pledge applies its fine list before the first note, and new delegations prefill a 72 hour wait | Accepted | diff --git a/specs/glossary.md b/specs/glossary.md index e91414a46..2a0233916 100644 --- a/specs/glossary.md +++ b/specs/glossary.md @@ -43,7 +43,7 @@ wrong (or this file is out of date and needs an ADR — see | **Takeback** | The donor taking a whole delegated note back. The delegate's authority over that note ends. Still the `revoke` call. His handing the note back is the same call and is not a takeback | | **Partial takeback** | The donor taking an amount back from a delegated note. The delegate keeps the rest under the same rules. She pays from the note she then holds. Not an approval of his spend. See [partial takeback](tech/subsystems/delegation/partial-takeback.md) | | **Fine list** | The `beneficiaryId`s a donor has named so a delegate's spend to the current controller of one of them is unsuspicious. Empty until she adds a name. Not an endorsement of a project. See [spend classification](tech/subsystems/delegation/spend-classification.md) | -| **Unsuspicious / unmarked / suspicious** | Classes of a delegate's spend. Unsuspicious shortens the wait. Unmarked keeps the standing delay and is not a warning. Suspicious is a longer wait plus a warning, or a block. See [spend classification](tech/subsystems/delegation/spend-classification.md) | +| **Unsuspicious / unmarked / suspicious** | Contract classes of a delegate's spend. Unsuspicious shortens the wait. Unmarked keeps the standing delay and is not a warning. Suspicious is a longer wait plus a warning, or a block, and no criterion returns it yet. The product does not use these words. A listed payee is "on your list"; anything else waiting is "not on your list". See [spend classification](tech/subsystems/delegation/spend-classification.md) | | **Standing pledge** | A *recurring* funding commitment registered with `RecurringPledges`, executed periodically into a note | | **Fundable-projects board** | The list of aligned work you might fund (heading **Fundable Projects**), inlined on a statement or cause board and also a full page. Code still says `fundingportal*` / `/portal/:statementCid`. Formerly called **portal** and then **cause board**. | diff --git a/specs/tech/subsystems/delegation/spend-classification.md b/specs/tech/subsystems/delegation/spend-classification.md index 98e5466f7..b2267f6a6 100644 --- a/specs/tech/subsystems/delegation/spend-classification.md +++ b/specs/tech/subsystems/delegation/spend-classification.md @@ -1,6 +1,6 @@ # Spend classification -A donor cannot stop a delegate from spending in ways she would not have chosen. That is what the delegation is for. She can, though, attach criteria that sort his spends into classes, and set one treatment per class, so the worst cases take more of her attention and the payees she already accepts take less. Accepted in [ADR 0017](/specs/decisions/0017-spend-classification.md). +A donor cannot stop a delegate from spending in ways she would not have chosen. That is what the delegation is for. She can, though, attach criteria that sort his spends into classes, and set one treatment per class, so the worst cases take more of her attention and the payees she already accepts take less. Accepted in [ADR 0017](/specs/decisions/0017-spend-classification.md). How a standing pledge gets those fields onto its first note, and the 72 hour form prefill, are [ADR 0018](/specs/decisions/0018-pledge-classification-before-first-note.md). This file specifies the beneficiary-identity classification implemented by the note contracts. The donor-set delay itself is [waiting-period.md](./waiting-period.md). A one-payment way through a block is [partial-takeback.md](./partial-takeback.md): she takes that amount back and pays it herself. It is not an approval that lets the delegate break the rule. @@ -26,7 +26,21 @@ On the note, beside the standing delay `T` from [waiting-period.md](./waiting-pe - `U`, the unsuspicious delay, in seconds. `0 ≤ U ≤ T`. The default is `0`. Adding a name does not require her to pick `U`. - No suspicious criterion is implemented. The policy has a slot, unset. Enabling one later requires her, in that same action, to choose either an extended delay `S` with `S ≥ T`, or **block**. There is no default. -She edits these in place. She does not reclaim or redeposit. `splitNote` copies them the way it copies `T`. `replaceDelegate` copies them onto the new note. A recurring pledge stores the same fields and copies them onto each note it mints. Editing the pledge changes later notes only. Notes already minted keep what they have until she edits those notes. +She edits these in place. She does not reclaim or redeposit. `splitNote` copies them the way it copies `T`. `replaceDelegate` copies them onto the new note. + +## Standing pledges + +A standing pledge stores the same fine list and `U`. `createStandingPledge` takes the initial list and `U`. In that transaction it stores the pledge, writes `U`, writes the list, then mints the first note, so the first note copies them. `executeDue` mints later notes only. It is permissionless, and it is not how the first note is created. `pledgeFineList(pledgeId)` returns the list. `U` and `T` are read from the pledge. The indexer does not store the list. + +Editing the pledge changes notes minted afterward. It does not change notes already minted, including that first one once it exists. There is no action that copies the pledge's list or `U` onto those notes. She edits a minted note on the note page. The SDK exposes one call per contract function, `setPledgeFineListed` and `setPledgeUnsuspiciousDelay`. It does not batch them and it does not write existing notes. + +`U` cannot exceed the pledge's `T`. Callers that omit a delay still get 0. See [ADR 0018](/specs/decisions/0018-pledge-classification-before-first-note.md). + +The deposit screen, on both create paths, shows one wait prefilled at 72 hours and a list of names that can be paid immediately. The list is empty until she adds a name. She can clear the wait. A shorter wait for those names is optional and stays hidden while it is zero. The screen says an immediate payment cannot be cancelled, and that a name matches only a payment straight to the wallet that currently controls it. A one-shot delegation writes that list onto the new note. It writes `U` only when the shorter wait is not zero. + +The monthly-pledge card on My Notes stays a summary and links to a pledge page. That page edits what future notes inherit: `T`, `U`, the fine list, strict mode, and flaggers. The list uses the same domain field as the note and saves as she adds or removes a name. `T`, strict mode, and flaggers are one `updateSpendPolicy` save. `U` is `setPledgeUnsuspiciousDelay` after that, so a lower `T` can clamp it and the page shows the clamped value. A pledge already stored shows its stored `T`, not 72 hours. The page says that a save applies to notes minted afterward and not to notes already minted. + +On a note, the same wait and the same names are the spend section. The shorter wait stays behind the same optional control. Strict mode and flaggers are a separate section: someone she names can pause a spend that is already waiting. Pending rows are labeled "On your list" or "Not on your list". The product does not show a suspicious label. No criterion returns that class. An empty fine list never matches. That is not a separate "off" switch. @@ -66,6 +80,6 @@ Classification is different. The schedule stores the time it was created. While ## What she sees -One section on her notes page lists her pending delegate spends. Each row is labeled unmarked, unsuspicious, or suspicious, and suspicious rows stand out. Unmarked and unsuspicious rows are still there. While she is signed in and any spend of hers is suspicious and still pending, one banner at the top of Commonality points at that section. The banner does not follow the notification opt-in. Email or push does. Neither is sent yet. +One section on her notes page lists her pending delegate spends. Each row says "On your list" or "Not on your list". The reserved class is not shown, because no criterion returns it. While she is signed in and any spend of hers is in that reserved class and still pending, one banner at the top of Commonality points at that section. The banner does not follow the notification opt-in. Email or push does. Neither is sent yet. -Immediate unsuspicious spends are not in that pending section. They are in the authorization's history, with the same label. +Immediate payments to a listed name are not in that pending section. They are in the authorization's history, labeled as on her list. diff --git a/specs/tech/subsystems/delegation/waiting-period.md b/specs/tech/subsystems/delegation/waiting-period.md index 5126cd931..50f4a5d82 100644 --- a/specs/tech/subsystems/delegation/waiting-period.md +++ b/specs/tech/subsystems/delegation/waiting-period.md @@ -38,6 +38,8 @@ While `paused` is true, `executeScheduledSpend` reverts and the delegate cannot A standing pledge stores `spendDelay`, `strictMode`, and its own flagger list. Each note it mints copies all three. `updateSpendPolicy` changes what later notes receive. Notes already minted keep the settings they were born with. She can still edit those notes in place. +The deposit screen prefills 72 hours on both ways of creating a delegation, as the one wait before a spend completes. A new standing pledge passes that value as `spendDelay`. A one-shot delegation uses `delegateWithDelay` with that value instead of `delegate`, which would store 0. She can change the prefill before submitting, including to 0. The note page shows the delay stored on the note, not this prefill. The contract default when a caller omits the delay remains 0. Names that skip or shorten that wait, and the pledge page that edits the whole template, are specified in [spend-classification.md](./spend-classification.md). + ## Events | Event | When | diff --git a/ui/src/commonality/shell/SuspiciousSpendBanner.tsx b/ui/src/commonality/shell/SuspiciousSpendBanner.tsx index 9f9313aa1..facff05bd 100644 --- a/ui/src/commonality/shell/SuspiciousSpendBanner.tsx +++ b/ui/src/commonality/shell/SuspiciousSpendBanner.tsx @@ -53,7 +53,7 @@ export function SuspiciousSpendBanner() { if (!show) return null return ( - A delegate spend is suspicious and still waiting. Review it + A delegate spend matched a rule you turned on and is still waiting. Review it ) } diff --git a/ui/src/delegation/components/DonorPendingSpends.tsx b/ui/src/delegation/components/DonorPendingSpends.tsx index 99c3ecd13..ef6773b6b 100644 --- a/ui/src/delegation/components/DonorPendingSpends.tsx +++ b/ui/src/delegation/components/DonorPendingSpends.tsx @@ -84,7 +84,7 @@ export function DonorPendingSpends({ notes }: { notes: Note[] }) { > {formatNoteAmount(row.note)} - + {row.paused && } diff --git a/ui/src/delegation/components/FineListPanel.test.ts b/ui/src/delegation/components/FineListPanel.test.ts index 69cb6e333..9fd9d2de1 100644 --- a/ui/src/delegation/components/FineListPanel.test.ts +++ b/ui/src/delegation/components/FineListPanel.test.ts @@ -3,7 +3,7 @@ import { spendClassLabel } from '../spendClass' describe('spendClassLabel', () => { it('names the two classes the contract can return', () => { - expect(spendClassLabel(1)).toBe('Unsuspicious') - expect(spendClassLabel(0)).toBe('Unmarked') + expect(spendClassLabel(1)).toBe('On your list') + expect(spendClassLabel(0)).toBe('Not on your list') }) }) \ No newline at end of file diff --git a/ui/src/delegation/components/FineListPanel.tsx b/ui/src/delegation/components/FineListPanel.tsx index 4bb87d673..93753d40f 100644 --- a/ui/src/delegation/components/FineListPanel.tsx +++ b/ui/src/delegation/components/FineListPanel.tsx @@ -107,12 +107,12 @@ export function FineListPanel({ return ( - Fine list + Names that can be paid immediately - A delegate spend that pays the current controller of a name here waits U instead of the standing delay. U starts at zero. Claim-later projects stay on the standing delay. + A payment that goes straight to the wallet that currently controls one of these names completes immediately. You cannot cancel it. A project that holds the money to be claimed later, or that pays a different wallet, keeps the ordinary wait. {pendingLabel && ( - + )} {names.length === 0 && No names yet.} diff --git a/ui/src/delegation/components/PendingSpendCard.tsx b/ui/src/delegation/components/PendingSpendCard.tsx index 23e84a56c..a5272fc0c 100644 --- a/ui/src/delegation/components/PendingSpendCard.tsx +++ b/ui/src/delegation/components/PendingSpendCard.tsx @@ -109,7 +109,7 @@ export function PendingSpendCard({ Pending spend - + {row.paused && } {formatCurrencyAmount(row.amount, currency)} diff --git a/ui/src/delegation/components/SpendPolicyPanel.tsx b/ui/src/delegation/components/SpendPolicyPanel.tsx index 5967f8175..260a0ddfb 100644 --- a/ui/src/delegation/components/SpendPolicyPanel.tsx +++ b/ui/src/delegation/components/SpendPolicyPanel.tsx @@ -22,7 +22,8 @@ export function SpendPolicyPanel({ const publicClient = usePublicClient() const clients = useWriteClients() const [delayHours, setDelayHours] = useState('0') - const [unsuspiciousHours, setUnsuspiciousHours] = useState('0') + const [listedWaitHours, setListedWaitHours] = useState('0') + const [showListedWait, setShowListedWait] = useState(false) const [strictMode, setStrict] = useState(false) const [flaggers, setFlaggers] = useState([]) const [flaggerInput, setFlaggerInput] = useState('') @@ -52,7 +53,8 @@ export function SpendPolicyPanel({ }) as Address[] if (cancelled) return setDelayHours(secondsToHourInput(policy[0])) - setUnsuspiciousHours(secondsToHourInput(policy[1])) + setListedWaitHours(secondsToHourInput(policy[1])) + if (policy[1] > 0n) setShowListedWait(true) setStrict(policy[2]) setFlaggers(listed) })().catch(() => { @@ -63,14 +65,16 @@ export function SpendPolicyPanel({ async function saveDelays() { if (!clients) return - const delay = hoursInputToSeconds(delayHours) - const unsuspicious = hoursInputToSeconds(unsuspiciousHours) - if (delay === null || unsuspicious === null) { - setError('Enter the delays in hours') + const delay = hoursInputToSeconds(delayHours.trim() === '' ? '0' : delayHours) + const listedWait = showListedWait + ? hoursInputToSeconds(listedWaitHours.trim() === '' ? '0' : listedWaitHours) + : 0n + if (delay === null || listedWait === null) { + setError('Enter the wait in hours, or leave it empty for none') return } - if (unsuspicious > delay) { - setError('The shorter delay cannot be longer than the standing delay') + if (listedWait > delay) { + setError('The wait for a listed name cannot be longer than the ordinary wait') return } setBusy(true) @@ -78,7 +82,7 @@ export function SpendPolicyPanel({ try { const contract = { address: contractAddress, abi: DelegatableNotesAbi } await waitForUpdate(await setSpendDelay(clients, contract, { noteId, owners, delay })) - await waitForUpdate(await setUnsuspiciousDelay(clients, contract, { noteId, owners, delay: unsuspicious })) + await waitForUpdate(await setUnsuspiciousDelay(clients, contract, { noteId, owners, delay: listedWait })) await onChanged?.() } catch (err) { setError(err instanceof Error ? err.message : 'Could not save the delay') @@ -121,15 +125,28 @@ export function SpendPolicyPanel({ return ( - Delay before a delegate spend completes + Wait before a spend completes - A delay above zero means the delegate schedules the whole note. You can cancel it until it completes. A spend already scheduled keeps the deadline it was given, unless its class changes. + Other payments wait this long, and you can cancel them until they complete. A spend already scheduled keeps the deadline it was given, unless you change who is on your list. - setDelayHours(event.target.value)} /> - setUnsuspiciousHours(event.target.value)} helperText="Must be at most the standing delay. Zero completes in the delegate's transaction." /> - + setDelayHours(event.target.value)} helperText="Empty means no wait." /> + + {!showListedWait && ( + + )} + {showListedWait && ( + setListedWaitHours(event.target.value)} + helperText="Zero pays that name immediately, and you cannot cancel it. It cannot be longer than the ordinary wait." + sx={{ mb: 1 }} + /> + )} + Someone can pause a spend that is waiting { void saveStrict(checked) }} /> Strict mode: a flagger pauses the spend. Off, the countdown continues. diff --git a/ui/src/delegation/pages/DepositPage.test.tsx b/ui/src/delegation/pages/DepositPage.test.tsx index a1065946c..83c1a7788 100644 --- a/ui/src/delegation/pages/DepositPage.test.tsx +++ b/ui/src/delegation/pages/DepositPage.test.tsx @@ -42,6 +42,9 @@ vi.mock('@commonality/sdk/delegation', async () => { ...actual, depositERC20: vi.fn(), delegateNote: vi.fn(), + delegateWithDelay: vi.fn(), + setFineListed: vi.fn(), + setUnsuspiciousDelay: vi.fn(), approveRecurringPledgeToken: vi.fn(), createStandingPledge: vi.fn(), } @@ -58,7 +61,7 @@ vi.mock('@commonality/sdk/machinery', async () => { import { useNavigate, useSearchParams } from 'react-router-dom' import { useAccount, useWalletClient, usePublicClient } from 'wagmi' import { browseStatementsByNewest } from '@commonality/sdk/conceptspace' -import { depositERC20, delegateNote, approveRecurringPledgeToken, createStandingPledge } from '@commonality/sdk/delegation' +import { depositERC20, delegateNote, delegateWithDelay, approveRecurringPledgeToken, createStandingPledge } from '@commonality/sdk/delegation' import { createSDKMachinery } from '@commonality/sdk/machinery' const mockNavigate = vi.fn() @@ -306,6 +309,9 @@ describe('DepositPage', () => { delegateTo: OTHER_ADDR, token: '0x4444444444444444444444444444444444444444', causeRef: TEST_STATEMENT.cid, + spendDelay: 72n * 3600n, + unsuspiciousDelay: 0n, + fineIds: [], }) ) }) @@ -380,9 +386,9 @@ describe('DepositPage', () => { }) describe('Delegation during deposit', () => { - it('calls delegateNote when delegate address is provided', async () => { + it('calls delegateWithDelay when delegate address is provided', async () => { vi.mocked(depositERC20).mockResolvedValue({ noteId: 7n, hash: '0xabc' }) - vi.mocked(delegateNote).mockResolvedValue({ hash: '0xdef' } as any) + vi.mocked(delegateWithDelay).mockResolvedValue({ hash: '0xdef', delegatedNoteId: 7n, remainderNoteId: 0n }) render() fireEvent.change(screen.getByLabelText(/amount \(usdzzz\)/i), { target: { value: '0.5' } }) @@ -390,7 +396,7 @@ describe('DepositPage', () => { fireEvent.click(screen.getByRole('button', { name: 'Deposit' })) await waitFor(() => { - expect(delegateNote).toHaveBeenCalledWith( + expect(delegateWithDelay).toHaveBeenCalledWith( expect.any(Object), expect.any(Object), expect.objectContaining({ @@ -398,6 +404,7 @@ describe('DepositPage', () => { owners: [USER_ADDR], delegateTo: OTHER_ADDR, amount: expect.any(BigInt), + delay: 72n * 3600n, }) ) }) @@ -414,11 +421,12 @@ describe('DepositPage', () => { expect(screen.getByText('Funds Added')).toBeInTheDocument() }) expect(delegateNote).not.toHaveBeenCalled() + expect(delegateWithDelay).not.toHaveBeenCalled() }) it('shows error when delegation fails after successful deposit', async () => { vi.mocked(depositERC20).mockResolvedValue({ noteId: 7n, hash: '0xabc' }) - vi.mocked(delegateNote).mockRejectedValue(new Error('Delegation reverted')) + vi.mocked(delegateWithDelay).mockRejectedValue(new Error('Delegation reverted')) render() fireEvent.change(screen.getByLabelText(/amount \(usdzzz\)/i), { target: { value: '0.5' } }) diff --git a/ui/src/delegation/pages/DepositPage.tsx b/ui/src/delegation/pages/DepositPage.tsx index 33f5ff3fa..64db87584 100644 --- a/ui/src/delegation/pages/DepositPage.tsx +++ b/ui/src/delegation/pages/DepositPage.tsx @@ -10,6 +10,7 @@ import { Card, CardContent, Checkbox, + Chip, FormControlLabel, } from '@mui/material' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -18,7 +19,9 @@ import { parseUnits, isAddress } from 'viem' import { DelegatableNotesAbi, NoteIntentAbi, RecurringPledgesAbi } from '@commonality/sdk/abis' import { browseStatementsByNewest, getStatementWithContent, type StatementListItem } from '@commonality/sdk/conceptspace' import type { IpfsCidV1 } from '@commonality/sdk/utils' -import { depositERC20, delegateNote, attestNoteIntent, approveRecurringPledgeToken, createStandingPledge, type DelegatableNotesContract, type NoteIntentContract, type RecurringPledgesContract } from '@commonality/sdk/delegation' +import { hashBeneficiaryId, normalizeDnsBeneficiary } from '@commonality/sdk/content-funding' +import { depositERC20, delegateNote, delegateWithDelay, attestNoteIntent, approveRecurringPledgeToken, createStandingPledge, setFineListed, setUnsuspiciousDelay, type DelegatableNotesContract, type NoteIntentContract, type RecurringPledgesContract } from '@commonality/sdk/delegation' +import { hoursInputToSeconds } from '../spendClass' import { getDomainUrl, StatementPicker, useMachinery } from '../../shared' import { noteDetailPathFor } from '../utils' import { useWriteClients } from '../../shared' @@ -46,6 +49,7 @@ function getNoteIntentContract(): NoteIntentContract | null { const MONTHLY_PERIOD_SECONDS = 30n * 24n * 60n * 60n const DEFAULT_RECURRING_ALLOWANCE_PERIODS = 12n +const DEFAULT_WAIT_HOURS = '72' export function DepositPage() { const navigate = useNavigate() @@ -60,6 +64,11 @@ export function DepositPage() { const [delegateStatus, setDelegateStatus] = useState('empty') const [selectedStatement, setSelectedStatement] = useState(null) const [isRecurring, setIsRecurring] = useState(false) + const [waitHours, setWaitHours] = useState(DEFAULT_WAIT_HOURS) + const [listedNames, setListedNames] = useState([]) + const [nameInput, setNameInput] = useState('') + const [showListedWait, setShowListedWait] = useState(false) + const [listedWaitHours, setListedWaitHours] = useState('0') const [recurringAllowancePeriods, setRecurringAllowancePeriods] = useState(DEFAULT_RECURRING_ALLOWANCE_PERIODS.toString()) const [statements, setStatements] = useState([]) const [statementsLoading, setStatementsLoading] = useState(false) @@ -185,6 +194,29 @@ export function DepositPage() { return } + const delegating = isRecurring || Boolean(delegateTo) + const waitSeconds = hoursInputToSeconds(waitHours.trim() === '' ? '0' : waitHours) + const listedWaitSeconds = !showListedWait + ? 0n + : hoursInputToSeconds(listedWaitHours.trim() === '' ? '0' : listedWaitHours) + if (delegating && (waitSeconds === null || listedWaitSeconds === null)) { + setError('Enter the wait in hours, or leave it empty for none') + return + } + if (delegating && listedWaitSeconds! > waitSeconds!) { + setError('The wait for a listed name cannot be longer than the ordinary wait') + return + } + let fineIds: `0x${string}`[] = [] + if (delegating && listedNames.length > 0) { + try { + fineIds = listedNames.map((name) => hashBeneficiaryId('dns', name)) + } catch { + setError('One of the names is not a website') + return + } + } + setSubmitting(true) setError(null) @@ -203,6 +235,9 @@ export function DepositPage() { amountPerPeriod: depositAmount, period: MONTHLY_PERIOD_SECONDS, causeRef: selectedStatement!.cid, + spendDelay: waitSeconds!, + unsuspiciousDelay: listedWaitSeconds!, + fineIds, }) setSuccessNoteId(firstNoteId) return @@ -214,12 +249,41 @@ export function DepositPage() { }) if (delegateTo && isAddress(delegateTo)) { - await delegateNote(clients, delegationContract, { - noteId, - owners: [address as `0x${string}`], - delegateTo: delegateTo as `0x${string}`, - amount: depositAmount, - }) + const owners = [address as `0x${string}`] + const delegation = waitSeconds! > 0n + ? await delegateWithDelay(clients, delegationContract, { + noteId, + owners, + delegateTo: delegateTo as `0x${string}`, + amount: depositAmount, + delay: waitSeconds!, + }) + : await delegateNote(clients, delegationContract, { + noteId, + owners, + delegateTo: delegateTo as `0x${string}`, + amount: depositAmount, + }) + const delegatedNoteId = delegation.delegatedNoteId + for (const beneficiaryId of fineIds) { + const hash = await setFineListed(clients, delegationContract, { + noteId: delegatedNoteId, + owners, + beneficiaryId, + allowed: true, + }) + const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new Error('Could not save a name that can be paid immediately') + } + if (listedWaitSeconds! > 0n) { + const hash = await setUnsuspiciousDelay(clients, delegationContract, { + noteId: delegatedNoteId, + owners, + delay: listedWaitSeconds!, + }) + const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new Error('Could not save the wait for a listed name') + } } setSuccessNoteId(noteId) @@ -385,6 +449,61 @@ export function DepositPage() { + {(isRecurring || delegateTo) && ( + + setWaitHours(e.target.value)} + disabled={submitting} + helperText="Suggested: 72. Clear this for no wait. You can cancel a spend during the wait. A name you add below can be paid immediately, and that payment cannot be cancelled." + /> + + {listedNames.map((name) => ( + setListedNames(listedNames.filter((item) => item !== name))} /> + ))} + + + setNameInput(e.target.value)} + placeholder="example.org" + disabled={submitting} + helperText="Only a payment that goes straight to the wallet that currently controls this name. A project that holds the money for later still waits." + /> + + + {!showListedWait && ( + + )} + {showListedWait && ( + setListedWaitHours(e.target.value)} + disabled={submitting} + helperText="Leave this at 0 to pay those names immediately." + /> + )} + + )} + {statementsLoading && requestedStatementCid && Loading the statement from the cause link…} {selectedStatement && ( setSelectedStatement(null)}> diff --git a/ui/src/delegation/pages/MyNotesPage.tsx b/ui/src/delegation/pages/MyNotesPage.tsx index cd0c2c084..38d7abfd0 100644 --- a/ui/src/delegation/pages/MyNotesPage.tsx +++ b/ui/src/delegation/pages/MyNotesPage.tsx @@ -412,7 +412,15 @@ function StandingPledgeCard({ Last executed: {pledge.lastExecuted === '0' ? 'not yet' : formatPledgeDate(pledge.lastExecuted)} - + + + + )} + {showListedWait && ( + setListedWaitHours(event.target.value)} + disabled={!canEdit || busy} + helperText="Zero pays that name immediately, and you cannot cancel it." + sx={{ mt: 1 }} + /> + )} + Names that can be paid immediately + + A payment that goes straight to the wallet that currently controls one of these names uses the listed-name wait. A project that holds the money for later keeps the ordinary wait. Adding or removing a name saves immediately. + + + {names.length === 0 && No names yet.} + {names.map((id) => ( + { void changeName(id, false) }} /> + ))} + + + setNameInput(event.target.value)} placeholder="example.org" disabled={!canEdit || busy} /> + + + Someone can pause a spend that is waiting + + The wait, this switch, and the flaggers are saved together by the button below. + + + setStrict(checked)} /> + Strict mode: a flagger pauses the spend. Off, the countdown continues. + + + {flaggers.length === 0 && No flaggers.} + {flaggers.map((flagger) => ( + setFlaggers(flaggers.filter((item) => item !== flagger))} + /> + ))} + + + setFlaggerInput(event.target.value)} placeholder="0x..." disabled={!canEdit || busy} /> + + + + {error && {error}} + + + ) +} diff --git a/ui/src/delegation/pages/index.ts b/ui/src/delegation/pages/index.ts index ae5598d04..c8e280400 100644 --- a/ui/src/delegation/pages/index.ts +++ b/ui/src/delegation/pages/index.ts @@ -1,3 +1,4 @@ export { MyNotesPage } from './MyNotesPage' export { NoteDetailPage } from './NoteDetailPage' export { DepositPage } from './DepositPage' +export { PledgePage } from './PledgePage' diff --git a/ui/src/delegation/spendClass.test.ts b/ui/src/delegation/spendClass.test.ts index 55e190a81..e020228dd 100644 --- a/ui/src/delegation/spendClass.test.ts +++ b/ui/src/delegation/spendClass.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from 'vitest' import { formatPendingSpendDeadline, hoursInputToSeconds, isSuspiciousClass, secondsToHourInput, spendClassLabel } from './spendClass' describe('spend class labels', () => { - it('names unmarked, unsuspicious, and any later suspicious class', () => { - expect(spendClassLabel(0)).toBe('Unmarked') - expect(spendClassLabel(1)).toBe('Unsuspicious') - expect(spendClassLabel(2)).toBe('Suspicious') + it('names a listed payee, everyone else, and a class no rule produces yet', () => { + expect(spendClassLabel(0)).toBe('Not on your list') + expect(spendClassLabel(1)).toBe('On your list') + expect(spendClassLabel(2)).toBe('Needs attention') expect(isSuspiciousClass(2)).toBe(true) expect(isSuspiciousClass(0)).toBe(false) }) diff --git a/ui/src/delegation/spendClass.ts b/ui/src/delegation/spendClass.ts index 403ae6a39..2e08b70aa 100644 --- a/ui/src/delegation/spendClass.ts +++ b/ui/src/delegation/spendClass.ts @@ -1,12 +1,12 @@ -/** Contract classes: 0 unmarked, 1 unsuspicious. Anything else is the reserved suspicious class. */ -export function spendClassLabel(spendClass: number): 'Unsuspicious' | 'Unmarked' | 'Suspicious' { - if (spendClass === 1) return 'Unsuspicious' - if (spendClass === 0) return 'Unmarked' - return 'Suspicious' +/** Contract classes: 0 not on the list, 1 on the list. Anything else is reserved and not produced yet. */ +export function spendClassLabel(spendClass: number): 'On your list' | 'Not on your list' | 'Needs attention' { + if (spendClass === 1) return 'On your list' + if (spendClass === 0) return 'Not on your list' + return 'Needs attention' } export function isSuspiciousClass(spendClass: number): boolean { - return spendClassLabel(spendClass) === 'Suspicious' + return spendClassLabel(spendClass) === 'Needs attention' } export function secondsToHourInput(seconds: bigint): string { diff --git a/ui/src/domains/CrossDomainSmoke.test.tsx b/ui/src/domains/CrossDomainSmoke.test.tsx index 4d078196c..80dc8cdd4 100644 --- a/ui/src/domains/CrossDomainSmoke.test.tsx +++ b/ui/src/domains/CrossDomainSmoke.test.tsx @@ -179,7 +179,7 @@ describe('cross-domain route ownership', () => { it('lazyGiving owns assurance-contract project routes', () => { const routePaths = extractRoutePaths(domainManifests.lazyGiving.routes) - expect(routePaths).toEqual(['/', '/projects', '/projects/new', '/projects/:projectAddress/leaderboard', '/projects/:projectAddress', '/delegation', '/delegation/notes', '/delegation/notes/new', '/delegation/notes/:noteId', '/delegates/offer', '/delegates/:address', '/docs', '/docs/*']) + expect(routePaths).toEqual(['/', '/projects', '/projects/new', '/projects/:projectAddress/leaderboard', '/projects/:projectAddress', '/delegation', '/delegation/notes', '/delegation/notes/new', '/delegation/pledges/:pledgeId', '/delegation/notes/:noteId', '/delegates/offer', '/delegates/:address', '/docs', '/docs/*']) }) it('alignment owns funding-portal routes', () => { diff --git a/ui/src/domains/CrossLinkCrawler.test.tsx b/ui/src/domains/CrossLinkCrawler.test.tsx index fd3889978..0204a1842 100644 --- a/ui/src/domains/CrossLinkCrawler.test.tsx +++ b/ui/src/domains/CrossLinkCrawler.test.tsx @@ -14,6 +14,7 @@ const publicDocModules = import.meta.glob('../../../docs/end-user/**/*.md', { qu const routeParamSamples: Record = { address: '0x0000000000000000000000000000000000000001', noteId: '1', + pledgeId: '1', platform: 'youtube', projectAddress: '0x0000000000000000000000000000000000000002', roundAddress: '0x0000000000000000000000000000000000000003', diff --git a/ui/src/domains/DomainDeepLinksSmoke.test.tsx b/ui/src/domains/DomainDeepLinksSmoke.test.tsx index 46c3922c6..d92e38d94 100644 --- a/ui/src/domains/DomainDeepLinksSmoke.test.tsx +++ b/ui/src/domains/DomainDeepLinksSmoke.test.tsx @@ -15,6 +15,7 @@ const sampleParamValues: Record = { address: '0x1111111111111111111111111111111111111111', channelId: 'creator-123', noteId: '1', + pledgeId: '1', platform: 'twitter', projectAddress: '0x2222222222222222222222222222222222222222', roundAddress: '0x3333333333333333333333333333333333333333', diff --git a/ui/src/domains/commonality/manifest.tsx b/ui/src/domains/commonality/manifest.tsx index b4d536bfa..bd544191a 100644 --- a/ui/src/domains/commonality/manifest.tsx +++ b/ui/src/domains/commonality/manifest.tsx @@ -28,6 +28,7 @@ const routes: ReactNode = ( } /> import('../../delegation/pages/MyNotesPage'), 'MyNotesPage')} /> import('../../delegation/pages/DepositPage'), 'DepositPage')} /> + import('../../delegation/pages/PledgePage'), 'PledgePage')} /> import('../../delegation/pages/NoteDetailPage'), 'NoteDetailPage')} /> import('../../delegation/pages/DelegateProfilePage'), 'DelegateProfilePage')} /> import('../../delegation/pages/DelegateProfilePage'), 'DelegateProfilePage')} /> diff --git a/ui/src/domains/content-funding/manifest.tsx b/ui/src/domains/content-funding/manifest.tsx index fde81b01a..96879b6a2 100644 --- a/ui/src/domains/content-funding/manifest.tsx +++ b/ui/src/domains/content-funding/manifest.tsx @@ -23,6 +23,7 @@ const routes: ReactNode = ( import('../delegation/LandingPage'), 'DelegationLandingPage')} /> import('../../delegation/pages/MyNotesPage'), 'MyNotesPage')} /> import('../../delegation/pages/DepositPage'), 'DepositPage')} /> + import('../../delegation/pages/PledgePage'), 'PledgePage')} /> import('../../delegation/pages/NoteDetailPage'), 'NoteDetailPage')} /> import('../../docs/DocsPage'), 'DocsPage')} /> import('../../docs/DocsPage'), 'DocsPage')} /> diff --git a/ui/src/domains/lazy-giving/manifest.tsx b/ui/src/domains/lazy-giving/manifest.tsx index 809b09153..51aeac0a9 100644 --- a/ui/src/domains/lazy-giving/manifest.tsx +++ b/ui/src/domains/lazy-giving/manifest.tsx @@ -17,6 +17,7 @@ const routes: ReactNode = ( } /> import('../../delegation/pages/MyNotesPage'), 'MyNotesPage')} /> import('../../delegation/pages/DepositPage'), 'DepositPage')} /> + import('../../delegation/pages/PledgePage'), 'PledgePage')} /> import('../../delegation/pages/NoteDetailPage'), 'NoteDetailPage')} /> import('../../delegation/pages/DelegateProfilePage'), 'DelegateProfilePage')} /> import('../../delegation/pages/DelegateProfilePage'), 'DelegateProfilePage')} /> From cf989dd46179323f6ff30b3f77d4f3fef8e90266 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sun, 27 Sep 2026 14:51:16 -0400 Subject: [PATCH 2/3] Let one Takeback cover the receipts and refunds that came out of a note. revokeMany skips notes that are already gone. The client walks that closure from the logs and confirms which delegated notes to revoke. A purchase or refund copies the source note's spend rules onto the new note. --- TODO.md | 3 - .../contracts/delegation/DelegatableNotes.sol | 42 +++++ hardhat/test/DelegatableNotes.refund.test.js | 52 ++++++ indexer/abis/DelegatableNotesAbi.ts | 18 +++ sdk/abis/DelegatableNotesAbi.ts | 18 +++ sdk/src/subsystems/delegation/actions.ts | 21 +++ sdk/src/subsystems/delegation/index.ts | 1 + sdk/src/subsystems/delegation/queries.ts | 2 +- .../delegation/revocationClosure.test.ts | 29 ++++ .../delegation/revocationClosure.ts | 149 ++++++++++++++++++ specs/glossary.md | 2 +- specs/product/legal/delegation-narrowing.md | 2 +- specs/tech/subsystems/delegation/README.md | 6 +- specs/tech/subsystems/delegation/one-hop.md | 2 +- .../tech/subsystems/delegation/revocation.md | 27 ++++ specs/tech/subsystems/delegation/ui.md | 10 +- .../subsystems/delegation/waiting-period.md | 2 +- .../components/RevokeClosureDialog.tsx | 146 +++++++++++++++++ .../components/SpendPolicyPanel.tsx | 4 + ui/src/delegation/pages/MyNotesPage.test.tsx | 34 +++- ui/src/delegation/pages/MyNotesPage.tsx | 44 +++--- ui/src/delegation/pages/NoteDetailPage.tsx | 44 ++---- 22 files changed, 586 insertions(+), 72 deletions(-) create mode 100644 sdk/src/subsystems/delegation/revocationClosure.test.ts create mode 100644 sdk/src/subsystems/delegation/revocationClosure.ts create mode 100644 specs/tech/subsystems/delegation/revocation.md create mode 100644 ui/src/delegation/components/RevokeClosureDialog.tsx diff --git a/TODO.md b/TODO.md index e1ee09ec0..f6ebb8e2f 100644 --- a/TODO.md +++ b/TODO.md @@ -24,9 +24,6 @@ Getting **testnet to a two-person shared lab** is also a standing plan, not a pi - One voice for delegation copy. The donor is authorizing an address to spend a stated amount on projects in Commonality. The delegate promises nothing. A stated intent is public and does not bind the spend. Unspent funds stay revocable by the donor. Commonality does not hold the funds, choose the delegate, or supervise the spending. Remove the steward voice: entrusting money to a scout, program-officer framing, "money under management," and any Commonality ranking whose job is to send people to a delegate. A public history of what an address already funded can stay. "Scout" as the early contributor who may later be reimbursed at cost can stay; do not let that word mean a manager of other people's money. Start with `specs/product/legal/retroactive-funding-redesign.md` (Design 2) and `docs/end-user/lazyGiving/` (`retroactive-funding.md`, `index.md`, `fund-something.md`, `get-your-project-funded.md`). No delegate marketplace. -- Reliable revocation of delegated authority over returned funds. Revocation covers the unspent balance, pending spends, and outstanding receipt claims, so a later refund cannot revive authority the donor removed. Failed-project refunds stay inside the same authorization ("keep trying until I revoke") and remain subject to its current rules and revocation state. Successful-project reimbursement recycling is still an open choice; do not settle it in this item. Write the proposal against `specs/tech/subsystems/delegation/` and [delegation-narrowing.md](specs/product/legal/delegation-narrowing.md) before changing contracts. - ----- - **(Tell)** Testnet Commonality SPA does not hydrate. `https://testnet.commonality.works/` and deep links now return the HTML shell (SPA fallback after public-gateway 429 is deployed), but browser loads fail on chunks such as `/assets/address-TZjglcQ5.js` (HTTP 429, public IPFS sunset body). Dedicated Pinata origin times out; Worker then falls through to `ipfs.io` / `w3s.link`. Reproduce, fix the Worker/gateway path so real assets are served from Pinata (or another working origin) instead of caching/returning 429, redeploy `cloudflare-ui-gateway`, and verify `/`, `/founders`, and a hydrated heading in a real browser. Pinata dashboard Host Origins remains Adam’s step in [`inbox.md`](inbox.md). Continuity: [`continuity/2026-09-15-commonality-live-gateway-followup.md`](continuity/2026-09-15-commonality-live-gateway-followup.md). diff --git a/hardhat/contracts/delegation/DelegatableNotes.sol b/hardhat/contracts/delegation/DelegatableNotes.sol index 339162bd1..711db736b 100644 --- a/hardhat/contracts/delegation/DelegatableNotes.sol +++ b/hardhat/contracts/delegation/DelegatableNotes.sol @@ -1132,6 +1132,20 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { } } + function _copySpendPolicy(uint256 fromNoteId, uint256 toNoteId, address copiedDelegate) private { + SpendPolicy storage policy = spendPolicies[fromNoteId]; + address[] memory flaggers = spendFlaggerList[fromNoteId]; + _writePolicy( + toNoteId, + policy.delay, + policy.unsuspiciousDelay, + policy.strictMode, + flaggers, + copiedDelegate + ); + _copyFineList(fromNoteId, toNoteId); + } + function _copyFineList(uint256 fromNoteId, uint256 toNoteId) private { bytes32[] storage ids = fineListIds[fromNoteId]; for (uint256 i = 0; i < ids.length; i++) { @@ -1159,6 +1173,25 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { * @param owners The delegation chain (leaf first, root last) */ function revoke(uint256 noteId, address[] calldata owners) external nonReentrant { + _revoke(noteId, owners); + } + + /** + * @notice Revoke each note. A note that is already gone is skipped. + * @dev A note that still exists with a wrong chain, or a caller who is not in it, reverts the call. + */ + function revokeMany( + uint256[] calldata noteIds, + address[][] calldata owners + ) external nonReentrant { + if (noteIds.length != owners.length) revert ArrayLengthMismatch(); + for (uint256 i = 0; i < noteIds.length; i++) { + if (notes[noteIds[i]].chainHash == bytes32(0)) continue; + _revoke(noteIds[i], owners[i]); + } + } + + function _revoke(uint256 noteId, address[] calldata owners) private { address caller = _msgSender(); Note storage note = notes[noteId]; @@ -1293,6 +1326,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { primaryMarket, erc1155Contract, tokenId, + inputNoteIds, paymentChains, outputShares, requiredPayment, @@ -1405,6 +1439,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { // NoteCreated alone carries only the leaf; RefundedIntoNote lets the fold copy the full // chain from the consumed input note (the same pattern ERC1155Purchased uses for outputs). emit NoteCreated(refundNoteId, chain[0], refundValue, paymentToken, TokenType.ERC20, 0); + _copySpendPolicy(noteId, refundNoteId, chain.length > 1 ? chain[0] : address(0)); + _deleteSpendPolicy(noteId); emit RefundedIntoNote( _msgSender(), primaryMarket, @@ -1557,6 +1593,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { address primaryMarket, address erc1155Contract, uint256 tokenId, + uint256[] memory inputNoteIds, address[][] memory chains, uint256[] memory outputShares, uint256 totalPayment, @@ -1575,6 +1612,11 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { tokenType: TokenType.ERC1155, tokenId: tokenId }); + _copySpendPolicy( + inputNoteIds[i], + newNoteId, + chains[i].length > 1 ? chains[i][0] : address(0) + ); reimbursementClaims[newNoteId] = ReimbursementClaim({ primaryMarket: primaryMarket, contribution: totalPayment * outputShares[i] / totalShares, diff --git a/hardhat/test/DelegatableNotes.refund.test.js b/hardhat/test/DelegatableNotes.refund.test.js index 984075a71..e5cdd1a14 100644 --- a/hardhat/test/DelegatableNotes.refund.test.js +++ b/hardhat/test/DelegatableNotes.refund.test.js @@ -218,4 +218,56 @@ describe("DelegatableNotes - Refund Into Note", function () { ) ).to.be.revertedWithCustomError(notes, "UnauthorizedMarket"); }); + + it("copies the payment note's rules onto the receipt and the receipt's current rules onto the refund", async function () { + const paymentNoteId = await depositPaymentNote(alice, COST); + await notes.connect(alice).delegate(paymentNoteId, [alice.address], bob.address, COST); + const beneficiaryId = ethers.id("beneficiary"); + await notes.connect(alice).setFineListed(paymentNoteId, [bob.address, alice.address], beneficiaryId, true); + + const tx = await notes.connect(bob).purchaseFromPrimaryMarket( + [{ noteId: paymentNoteId, chain: [bob.address, alice.address], shares: COUNT }], + await assuranceContract.getAddress(), + await erc1155Token.getAddress(), + TOKEN_ID, + COUNT + ); + const purchased = await tx.wait(); + const receiptNoteId = purchased.logs.find(l => l.fragment && l.fragment.name === "ERC1155Purchased").args.outputNoteIds[0]; + expect(await notes.fineListed(receiptNoteId, beneficiaryId)).to.equal(true); + + await notes.connect(alice).setSpendDelay(receiptNoteId, [bob.address, alice.address], 250); + await failTheContract(); + const refundTx = await notes.connect(bob).refundIntoNote( + receiptNoteId, + [bob.address, alice.address], + await assuranceContract.getAddress() + ); + const refunded = await refundTx.wait(); + const refundNoteId = refunded.logs.find(l => l.fragment && l.fragment.name === "RefundedIntoNote").args.outputNoteId; + expect((await notes.spendPolicies(refundNoteId)).delay).to.equal(250); + expect(await notes.fineListed(refundNoteId, beneficiaryId)).to.equal(true); + }); + + it("revokeMany skips a missing note and revokes the receipt that is still there", async function () { + const receiptNoteId = await setUpDelegatedReceiptNote(); + const chain = [bob.address, alice.address]; + await notes.connect(alice).revokeMany([999n, receiptNoteId], [chain, chain]); + const revoked = await notes.notes(receiptNoteId); + const rootHash = ethers.keccak256(ethers.solidityPacked(["address", "bytes32"], [alice.address, ethers.ZeroHash])); + expect(revoked.chainHash).to.equal(rootHash); + + await failTheContract(); + await expect( + notes.connect(bob).refundIntoNote(receiptNoteId, chain, await assuranceContract.getAddress()) + ).to.be.revertedWithCustomError(notes, "InvalidChain"); + const refundTx = await notes.connect(alice).refundIntoNote( + receiptNoteId, + [alice.address], + await assuranceContract.getAddress() + ); + const refunded = await refundTx.wait(); + const refundNoteId = refunded.logs.find(l => l.fragment && l.fragment.name === "RefundedIntoNote").args.outputNoteId; + expect((await notes.notes(refundNoteId)).chainHash).to.equal(rootHash); + }); }); diff --git a/indexer/abis/DelegatableNotesAbi.ts b/indexer/abis/DelegatableNotesAbi.ts index 7e773481d..2bded1659 100644 --- a/indexer/abis/DelegatableNotesAbi.ts +++ b/indexer/abis/DelegatableNotesAbi.ts @@ -2059,6 +2059,24 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256[]", + "name": "noteIds", + "type": "uint256[]" + }, + { + "internalType": "address[][]", + "name": "owners", + "type": "address[][]" + } + ], + "name": "revokeMany", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/sdk/abis/DelegatableNotesAbi.ts b/sdk/abis/DelegatableNotesAbi.ts index 7e773481d..2bded1659 100644 --- a/sdk/abis/DelegatableNotesAbi.ts +++ b/sdk/abis/DelegatableNotesAbi.ts @@ -2059,6 +2059,24 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256[]", + "name": "noteIds", + "type": "uint256[]" + }, + { + "internalType": "address[][]", + "name": "owners", + "type": "address[][]" + } + ], + "name": "revokeMany", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/sdk/src/subsystems/delegation/actions.ts b/sdk/src/subsystems/delegation/actions.ts index 1ecb8bd15..6cb85f5c6 100644 --- a/sdk/src/subsystems/delegation/actions.ts +++ b/sdk/src/subsystems/delegation/actions.ts @@ -335,6 +335,27 @@ export async function revokeNote( return hash; } +/** Revoke each note. A note that is already gone is skipped. */ +export async function revokeMany( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { + notes: { noteId: bigint; owners: Address[] }[]; + } +): Promise { + const hash = await clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'revokeMany', + args: [params.notes.map((note) => note.noteId), params.notes.map((note) => note.owners)], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); + + await clients.publicClient.waitForTransactionReceipt({ hash }); + return hash; +} + /** * Reclaim funds from a root note (non-delegated) * diff --git a/sdk/src/subsystems/delegation/index.ts b/sdk/src/subsystems/delegation/index.ts index 4deff78ef..5ef52f1c5 100644 --- a/sdk/src/subsystems/delegation/index.ts +++ b/sdk/src/subsystems/delegation/index.ts @@ -5,3 +5,4 @@ export * from './note-intent-actions.js'; export * from './recurring-pledges.js'; export * from './events.js'; export * from './folds.js'; +export * from './revocationClosure.js'; diff --git a/sdk/src/subsystems/delegation/queries.ts b/sdk/src/subsystems/delegation/queries.ts index f9f209324..70d146e11 100644 --- a/sdk/src/subsystems/delegation/queries.ts +++ b/sdk/src/subsystems/delegation/queries.ts @@ -34,7 +34,7 @@ import { foldDelegationState, foldNote, foldNoteIntentAttestations, uniqueNotes, import { getAllProjects, type Project } from '../lazy-giving/index.js'; import { getStandingPledges } from './recurring-pledges.js'; -function decodeDelegationEvents(rawEvents: Awaited>): DelegationEvent[] { +export function decodeDelegationEvents(rawEvents: Awaited>): DelegationEvent[] { const events: DelegationEvent[] = []; for (const raw of rawEvents) { switch (raw.eventName) { diff --git a/sdk/src/subsystems/delegation/revocationClosure.test.ts b/sdk/src/subsystems/delegation/revocationClosure.test.ts new file mode 100644 index 000000000..fd290235f --- /dev/null +++ b/sdk/src/subsystems/delegation/revocationClosure.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import type { DelegationEvent } from './folds.js'; +import { revocableNotesFromEvents } from './revocationClosure.js'; + +const ALICE = '0x0000000000000000000000000000000000000001' as const; +const BOB = '0x0000000000000000000000000000000000000002' as const; +const raw = { contractAddress: '0x00000000000000000000000000000000000000aa' as const, blockNumber: 1n, blockTimestamp: 1n, transactionHash: '0x' as const, logIndex: 0 }; + +function created(noteId: bigint, owner: `0x${string}`): DelegationEvent { + return { type: 'noteCreated', event: { ...raw, noteId, owner, amount: 10n, token: ALICE, tokenType: 0, tokenId: 0n } }; +} + +describe('revocableNotesFromEvents', () => { + it('includes a receipt and a refund that came out of the note, not a replacement', () => { + const events: DelegationEvent[] = [ + created(1n, ALICE), + { type: 'noteDelegated', event: { ...raw, parentNoteId: 1n, childNoteId: 1n, delegate: BOB, amount: 10n } }, + created(2n, BOB), + { type: 'erc1155Purchased', event: { ...raw, buyer: BOB, erc1155Contract: ALICE, tokenIds: [1n], counts: [1n], totalCost: 10n, inputNoteIds: [1n], outputNoteIds: [2n] } }, + created(3n, BOB), + { type: 'refundedIntoNote', event: { ...raw, caller: BOB, primaryMarket: ALICE, erc1155Contract: ALICE, tokenId: 1n, refundValue: 10n, paymentToken: ALICE, inputNoteId: 2n, outputNoteId: 3n } }, + created(4n, ALICE), + { type: 'noteDelegateReplaced', event: { ...raw, fromNoteId: 1n, toNoteId: 4n, newDelegate: BOB, amount: 1n } }, + ]; + const ids = revocableNotesFromEvents(events, 1n).map((note) => note.id); + assert.ok(ids.includes('3')); + assert.equal(ids.includes('4'), false); + }); +}); diff --git a/sdk/src/subsystems/delegation/revocationClosure.ts b/sdk/src/subsystems/delegation/revocationClosure.ts new file mode 100644 index 000000000..88ce337fa --- /dev/null +++ b/sdk/src/subsystems/delegation/revocationClosure.ts @@ -0,0 +1,149 @@ +import { decodeEventLog, type Address, type Hex } from 'viem'; +import { DelegatableNotesAbi } from '../../abis.js'; +import type { RawEventFromCache } from '../../utils/eventCacheClient.js'; +import { foldDelegationState, type DelegationEvent } from './folds.js'; +import { decodeDelegationEvents } from './queries.js'; + +export interface RevocableNote { + id: string; + noteId: bigint; + amount: string; + /** Leaf first, root last. */ + owners: Address[]; + parentId: string | null; +} + +/** Child note id → the note it came out of. Does not follow replacement or partial takeback. */ +export function closureParents(events: DelegationEvent[]): Map { + const parent = new Map(); + const link = (from: bigint, to: bigint) => { + parent.set(to.toString(), from.toString()); + }; + for (const ev of events) { + if (ev.type === 'chainSplit') link(ev.event.originalLeafId, ev.event.splitLeafId); + if (ev.type === 'noteSplitSameChain') link(ev.event.fromNoteId, ev.event.newNoteId); + if (ev.type === 'refundedIntoNote') link(ev.event.inputNoteId, ev.event.outputNoteId); + if (ev.type === 'reimbursementClaimedIntoNote') link(ev.event.receiptNoteId, ev.event.reimbursementNoteId); + if (ev.type === 'erc1155Purchased') { + const inputs = ev.event.inputNoteIds; + const outputs = ev.event.outputNoteIds; + const tokenCount = ev.event.tokenIds.length; + for (let c = 0; c < inputs.length; c++) { + for (let t = 0; t < tokenCount; t++) { + const output = outputs[t * inputs.length + c]; + if (output !== undefined) link(inputs[c], output); + } + } + } + } + return parent; +} + +/** Notes in the closure that are still delegated (chain longer than the root). */ +export function revocableNotesFromEvents(events: DelegationEvent[], originNoteId: bigint): RevocableNote[] { + const parents = closureParents(events); + const inClosure = new Set(); + const pending = [originNoteId.toString()]; + while (pending.length > 0) { + const id = pending.pop(); + if (id === undefined || inClosure.has(id)) continue; + inClosure.add(id); + for (const [child, parent] of parents) { + if (parent === id) pending.push(child); + } + } + + const { notes, chains } = foldDelegationState(events); + const rows: RevocableNote[] = []; + for (const id of inClosure) { + const note = notes.get(id); + const chain = chains.get(id); + if (!note?.active || !chain || chain.length < 2) continue; + const rootFirst = [...chain].sort((a, b) => a.position - b.position); + rows.push({ + id, + noteId: BigInt(id), + amount: note.amount, + owners: rootFirst.map((link) => link.address as Address).reverse(), + parentId: parents.get(id) ?? null, + }); + } + rows.sort((a, b) => (a.noteId < b.noteId ? -1 : 1)); + return rows; +} + +interface DelegationLogSource { + getContractEvents(args: { address: Address; abi: typeof DelegatableNotesAbi; fromBlock: bigint; toBlock: 'latest' }): Promise; +} + +interface DecodedLog { + eventName: string; + blockNumber: bigint; + transactionHash: Hex; + logIndex: number; + data: Hex; + topics: readonly Hex[]; +} + +function asDecodedLog(value: unknown): DecodedLog | undefined { + if (!value || typeof value !== 'object') return undefined; + const log = value as Partial; + if (typeof log.eventName !== 'string' || typeof log.data !== 'string' || !Array.isArray(log.topics)) return undefined; + if (typeof log.blockNumber !== 'bigint' || typeof log.transactionHash !== 'string' || typeof log.logIndex !== 'number') return undefined; + return log as DecodedLog; +} + +/** + * Still-delegated notes in `originNoteId`'s closure, from DelegatableNotes logs. + * The indexer is not consulted. + */ +export async function loadRevocableClosure( + source: DelegationLogSource, + contractAddress: Address, + originNoteId: bigint, +): Promise { + const logs = await source.getContractEvents({ + address: contractAddress, + abi: DelegatableNotesAbi, + fromBlock: 0n, + toBlock: 'latest', + }); + const raw: RawEventFromCache[] = []; + for (const value of logs) { + const log = asDecodedLog(value); + if (!log) continue; + let eventName = log.eventName; + try { + const decoded: unknown = decodeEventLog({ + abi: DelegatableNotesAbi, + data: log.data, + topics: log.topics as [Hex, ...Hex[]], + }); + if ( + typeof decoded !== 'object' || + decoded === null || + !('eventName' in decoded) || + typeof decoded.eventName !== 'string' + ) continue; + eventName = decoded.eventName; + } catch { + continue; + } + raw.push({ + id: `${log.transactionHash}:${log.logIndex}`, + contractAddress, + eventName, + blockNumber: log.blockNumber.toString(), + blockTimestamp: '0', + transactionHash: log.transactionHash, + logIndex: log.logIndex, + topic0: log.topics[0] ?? null, + topic1: log.topics[1] ?? null, + topic2: log.topics[2] ?? null, + topic3: log.topics[3] ?? null, + data: log.data, + }); + } + raw.sort((a, b) => Number(BigInt(a.blockNumber) - BigInt(b.blockNumber)) || a.logIndex - b.logIndex); + return revocableNotesFromEvents(decodeDelegationEvents(raw), originNoteId); +} diff --git a/specs/glossary.md b/specs/glossary.md index 2a0233916..49e016b22 100644 --- a/specs/glossary.md +++ b/specs/glossary.md @@ -40,7 +40,7 @@ wrong (or this file is out of date and needs an ADR — see | **Retroactive donation** | Money going into a *successful* project's reimbursement flow, after the fact. Buys nothing; it repays early contributors | | **Reimbursement** | What a retroactive donation pays out to an early contributor — at cost, no upside | | **Note** | A `DelegatableNote`: a bucket of deposited funds whose spending authority can be delegated down a chain, revocably. The unit of delegated giving | -| **Takeback** | The donor taking a whole delegated note back. The delegate's authority over that note ends. Still the `revoke` call. His handing the note back is the same call and is not a takeback | +| **Takeback** | The donor taking delegated authority back. The confirm list starts with every note still delegated in that note's closure. Still `revoke` / `revokeMany`. His handing a note back is the same call and is not a takeback | | **Partial takeback** | The donor taking an amount back from a delegated note. The delegate keeps the rest under the same rules. She pays from the note she then holds. Not an approval of his spend. See [partial takeback](tech/subsystems/delegation/partial-takeback.md) | | **Fine list** | The `beneficiaryId`s a donor has named so a delegate's spend to the current controller of one of them is unsuspicious. Empty until she adds a name. Not an endorsement of a project. See [spend classification](tech/subsystems/delegation/spend-classification.md) | | **Unsuspicious / unmarked / suspicious** | Contract classes of a delegate's spend. Unsuspicious shortens the wait. Unmarked keeps the standing delay and is not a warning. Suspicious is a longer wait plus a warning, or a block, and no criterion returns it yet. The product does not use these words. A listed payee is "on your list"; anything else waiting is "not on your list". See [spend classification](tech/subsystems/delegation/spend-classification.md) | diff --git a/specs/product/legal/delegation-narrowing.md b/specs/product/legal/delegation-narrowing.md index d60a29393..2195f9f82 100644 --- a/specs/product/legal/delegation-narrowing.md +++ b/specs/product/legal/delegation-narrowing.md @@ -73,7 +73,7 @@ Check the actual project payout route, not just its metadata or the registry's c Direction discussed with Adam, not a contract implementation specification: 1. **One named delegate**, with donor approval required for replacement. -2. **Reliable revocation**, covering pending spends and authority over future returned funds. Revocation must reach outstanding receipt claims so a later refund cannot revive authority the donor removed. +2. **Reliable revocation**, covering pending spends and authority over future returned funds. Revocation must reach outstanding receipt claims so a later refund cannot revive authority the donor removed. Specified in [revocation.md](../../tech/subsystems/delegation/revocation.md): the contract revokes the notes the client names, and the client walks the logs. A failed-project refund copies the receipt's current rules. Successful-project reimbursement recycling is still not decided. 3. **Optional donor-set delay**, including zero, with clear pending payments and early approval. Zero delay offers no guaranteed intervention window. Keep trusted flags as notification by default and the already-proposed donor opt-in pause mode as the stronger choice. 4. **Beneficiary identity integration**, showing and checking the actual destination. Accepted in [ADR 0017](/specs/decisions/0017-spend-classification.md) and [spend-classification.md](/specs/tech/subsystems/delegation/spend-classification.md). The fine list is optional and starts empty. Broad project discovery stays the delegate's job. 5. **Partial takeback**, without accidentally changing standing rules. Rules govern what the delegate can do without her acting. To pay something those rules would reject, she takes that amount back and pays it herself. The rest of the note stays delegated. diff --git a/specs/tech/subsystems/delegation/README.md b/specs/tech/subsystems/delegation/README.md index 027db0221..51066ce8a 100644 --- a/specs/tech/subsystems/delegation/README.md +++ b/specs/tech/subsystems/delegation/README.md @@ -6,7 +6,7 @@ The `DelegatableNotes` contract lets users deposit tokens and delegate spending `delegate` and `revoke` still rewrite `chainHash` on the same note id. `replaceDelegate` does not. -See [ui.md](./ui.md) for the UI spec. For standing-order/recurring pledges built on top of notes, see [recurring-pledges.md](./recurring-pledges.md) (product view: [specs/product/recurring-pledges.md](/specs/product/recurring-pledges.md)). For the donor-set delay on delegated spends, see [waiting-period.md](./waiting-period.md). For classifying those spends as unsuspicious, unmarked, or suspicious, see [spend-classification.md](./spend-classification.md) and [ADR 0017](/specs/decisions/0017-spend-classification.md). For the donor taking part of a delegated note back, see [partial-takeback.md](./partial-takeback.md). +See [ui.md](./ui.md) for the UI spec. For standing-order/recurring pledges built on top of notes, see [recurring-pledges.md](./recurring-pledges.md) (product view: [specs/product/recurring-pledges.md](/specs/product/recurring-pledges.md)). For the donor-set delay on delegated spends, see [waiting-period.md](./waiting-period.md). For classifying those spends as unsuspicious, unmarked, or suspicious, see [spend-classification.md](./spend-classification.md) and [ADR 0017](/specs/decisions/0017-spend-classification.md). For the donor taking part of a delegated note back, see [partial-takeback.md](./partial-takeback.md). For one Takeback covering the receipts and refunds that came out of a note, see [revocation.md](./revocation.md). --- @@ -159,6 +159,10 @@ delegateNote(clients, contract, { noteId, owners, delegateTo, amount }) // The donor's full takeback, and the delegate handing the note back, are both this call. revokeNote(clients, contract, { noteId, owners }) +// Revoke many notes in one transaction. A note that is already gone is skipped. +// A bad chain or a caller who is not in a note that still exists reverts the call. +revokeMany(clients, contract, { notes: [{ noteId, owners }, ...] }) + // Donor takes part of a delegated note back. The original note stays delegated. // amount is greater than zero and less than the balance. Reverts while a spend is pending. partialTakeback(clients, contract, { noteId, owners, amount }) diff --git a/specs/tech/subsystems/delegation/one-hop.md b/specs/tech/subsystems/delegation/one-hop.md index 28d337f13..275659a66 100644 --- a/specs/tech/subsystems/delegation/one-hop.md +++ b/specs/tech/subsystems/delegation/one-hop.md @@ -12,4 +12,4 @@ A recurring pledge still mints `[donor, delegate]` for each period. The delegate Purchases, refunds, and reimbursements keep copying the chain they already copy. A copied one-hop chain stays with that delegate. A copied longer chain is frozen under the same spend rule. -Not in this rule: an off-chain UI where the current delegate names a proposed successor for the donor to sign. The waiting period, delegation copy, refund revocation, beneficiary checks, and partial takeback stay in their own items. +Not in this rule: an off-chain UI where the current delegate names a proposed successor for the donor to sign. The waiting period, delegation copy, beneficiary checks, and partial takeback stay in their own items. Revocation of receipts and refunds is [revocation.md](./revocation.md). diff --git a/specs/tech/subsystems/delegation/revocation.md b/specs/tech/subsystems/delegation/revocation.md new file mode 100644 index 000000000..8c65791c9 --- /dev/null +++ b/specs/tech/subsystems/delegation/revocation.md @@ -0,0 +1,27 @@ +# Revocation of delegated authority + +Revocation is per note in the contract. The client is what makes one Takeback cover the notes that came out of the one she named. + +The contract does not store which receipt came from which payment note. Those links are in the logs: `ChainSplit`, `NoteSplitSameChain`, `ERC1155Purchased`, `RefundedIntoNote`, and `ReimbursementClaimedIntoNote`. The client reads those logs from the chain. The indexer may paint the page, but it is not the source of the revoke set. + +## What one action covers + +The closure of a note is that note plus every note reached by walking those links forward. It does not include another note she delegated to the same person. It does not follow `replaceDelegate` or `partialTakeback`. A receipt left with an earlier delegate, or a slice she already took back, stays out until she names that note. + +`revokeMany(noteIds, owners)` revokes each note with the same rules as `revoke`, including clearing that note's pending spend. A note that is already gone is skipped. A note that still exists but whose chain or caller is wrong reverts the whole call. There is no parent pointer and no cascade inside the contract. + +## Confirm list + +Takeback and Hand back both open a list of every note in the closure that is still delegated. Each row can be unchecked. Takeback starts with every row checked. Hand back starts with only the note on the page checked. One control checks or unchecks the rest. + +The client sends `revokeMany` for the checked notes only. It then reads the logs again. A new note that appeared from a checked note, and that she did not uncheck, joins the next batch. A note she unchecked, and anything that comes out of it afterward, does not. The action is finished only when a pass finds nothing still delegated in that set. If she rejects a later transaction, the page keeps showing what is still his and does not say the authority is gone. + +Partial takeback is not this walk. It is still one note, and it still does not clear a pending spend. + +## Refunds and rules + +`refundIntoNote` still requires the current leaf. After the receipt has been revoked, only she can refund it, and the new note is hers. If she has not revoked it, the delegate refunds into a note he still holds. + +A purchase copies that payment note's delay, unsuspicious delay, strict mode, flaggers, and fine list onto the receipt. She can edit that copy while the receipt exists. The setters are the same ones as on a payment note. Editing the unspent remainder does not change a receipt already issued. A refund copies the receipt's rules onto the new settlement-token note. That is the "current rules" a failed-project refund comes back under. + +`claimReimbursementIntoNote` is unchanged. It still copies the receipt's chain and does not copy the rules. This does not decide whether a successful-project reimbursement keeps the delegation. A reimbursement note that is already inside the closure can still be revoked by the walk above. diff --git a/specs/tech/subsystems/delegation/ui.md b/specs/tech/subsystems/delegation/ui.md index b18c07cf5..0351067b6 100644 --- a/specs/tech/subsystems/delegation/ui.md +++ b/specs/tech/subsystems/delegation/ui.md @@ -45,7 +45,7 @@ Each note shows: - Current leaf owner (with chain depth, e.g. "controlled by [address] (3 levels deep)") - Status: "Undelegated" / "Delegated" -For delegated notes where the connected user is the root, show **Takeback** and **Partial takeback**. Takeback calls `revokeNote` with the full delegation chain and brings the whole note back to her. Partial takeback asks for an amount greater than zero and less than the balance, calls `partialTakeback`, and leaves the rest delegated. The copy says she is taking that amount back, not approving a payment of his. While a spend is pending, partial takeback is unavailable until that spend is cancelled or the whole note is taken back. +For delegated notes where the connected user is the root, show **Takeback** and **Partial takeback**. Takeback opens the confirm list in [revocation.md](./revocation.md). Partial takeback asks for an amount greater than zero and less than the balance, calls `partialTakeback`, and leaves the rest delegated. The copy says she is taking that amount back, not approving a payment of his. While a spend is pending, partial takeback is unavailable until that spend is cancelled or the whole note is taken back. A **Reclaim** button is available on undelegated notes (where root = leaf), calling `reclaimFunds` to withdraw the funds back to the user's wallet. @@ -87,9 +87,11 @@ Each link shows the address (and ENS name if resolvable) with a copy button and Only shown to relevant users: - **Delegate** (shown to the current leaf owner): address + amount fields. Calls `delegateNote`. -- **Takeback** (shown to the root while the note is delegated): calls `revokeNote`. The whole note comes back to her. The delegate's authority over it ends. -- **Partial takeback** (shown to the root while the note is delegated): an amount field, then `partialTakeback`. The rest stays with the delegate under the same rules. Hidden or disabled while a spend is pending. Not labeled as approving his payment. -- **Hand back** (shown to the leaf while the note is delegated): also calls `revokeNote`. He is giving the note back, not taking it back. +- **Takeback** (shown to the root while the note is delegated): opens the confirm list in [revocation.md](./revocation.md). Every still-delegated note in the closure starts checked. She can uncheck rows. The client calls `revokeMany` and repeats until that set is clear. +- **Partial takeback** (shown to the root while the note is delegated): an amount field, then `partialTakeback`. The rest stays with the delegate under the same rules. Hidden or disabled while a spend is pending. Not labeled as approving his payment. This does not walk the closure. +- **Hand back** (shown to the leaf while the note is delegated): the same confirm list. Only the note on this page starts checked. + +The spend-policy and fine-list controls stay on the note the root is viewing, including a receipt. Edits on a receipt are the rules a later refund of that receipt comes back under. They do not change the unspent remainder. - **Reclaim** (shown only to the root owner, and only when the note is undelegated — root = leaf): calls `reclaimFunds`. - **Spend on Project** (shown to the current leaf owner): see "Spending" section below. diff --git a/specs/tech/subsystems/delegation/waiting-period.md b/specs/tech/subsystems/delegation/waiting-period.md index 50f4a5d82..d58a91f14 100644 --- a/specs/tech/subsystems/delegation/waiting-period.md +++ b/specs/tech/subsystems/delegation/waiting-period.md @@ -68,4 +68,4 @@ Public remarks from people who are not flaggers are a later UI feature. They are ## Out of scope -Beneficiary allow-lists, donor exceptions, refund authority, and a prose label on the schedule are other items. This design does not add them. +Beneficiary allow-lists, donor exceptions, and a prose label on the schedule are other items. Refund authority is [revocation.md](./revocation.md). This design does not add them. diff --git a/ui/src/delegation/components/RevokeClosureDialog.tsx b/ui/src/delegation/components/RevokeClosureDialog.tsx new file mode 100644 index 000000000..1e3091916 --- /dev/null +++ b/ui/src/delegation/components/RevokeClosureDialog.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react' +import { Alert, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Stack, Typography } from '@mui/material' +import { loadRevocableClosure, revokeMany, type RevocableNote } from '@commonality/sdk/delegation' +import type { DelegatableNotesContract } from '@commonality/sdk/delegation' + +type WriteClients = Parameters[0] + +export function RevokeClosureDialog({ + open, + mode, + originNoteId, + contract, + clients, + logSource, + onClose, + onFinished, +}: { + open: boolean + mode: 'takeback' | 'handback' + originNoteId: bigint | null + contract: DelegatableNotesContract | null + clients: WriteClients | null + logSource: { getContractEvents: Parameters[0]['getContractEvents'] } | null + onClose: () => void + onFinished: () => Promise +}) { + const [rows, setRows] = useState>([]) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [remaining, setRemaining] = useState(null) + + useEffect(() => { + if (!open || originNoteId === null || !contract || !logSource) return + let cancelled = false + setError(null) + setRemaining(null) + loadRevocableClosure(logSource, contract.address, originNoteId) + .then((notes) => { + if (cancelled) return + const next = notes.map((note) => ({ + ...note, + checked: mode === 'takeback' || note.noteId === originNoteId, + })) + setRows(next) + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : 'Could not read the notes on chain') + }) + return () => { cancelled = true } + }, [open, originNoteId, contract, logSource, mode]) + + function toggle(id: string) { + setRows((current) => current.map((note) => note.id === id ? { ...note, checked: !note.checked } : note)) + } + + function toggleAll(checked: boolean) { + setRows((current) => current.map((note) => ({ ...note, checked }))) + } + + function selectedFrom(notes: RevocableNote[], chosen: Set, skipped: Set) { + const allowed = new Set(chosen) + let grew = true + while (grew) { + grew = false + for (const note of notes) { + if (skipped.has(note.id) || allowed.has(note.id)) continue + if (note.parentId && allowed.has(note.parentId)) { + allowed.add(note.id) + grew = true + } + } + } + return notes.filter((note) => allowed.has(note.id) && !skipped.has(note.id)) + } + + async function submit() { + if (!contract || !clients || !logSource || originNoteId === null) return + const chosen = new Set(rows.filter((note) => note.checked).map((note) => note.id)) + const skipped = new Set(rows.filter((note) => !note.checked).map((note) => note.id)) + setBusy(true) + setError(null) + try { + let confirmed = chosen + for (let pass = 0; pass < 8; pass++) { + const latest = await loadRevocableClosure(logSource, contract.address, originNoteId) + const batch = selectedFrom(latest, confirmed, skipped) + if (batch.length === 0) { + setRemaining([]) + await onFinished() + onClose() + return + } + await revokeMany(clients, contract, { notes: batch.map((note) => ({ noteId: note.noteId, owners: note.owners })) }) + confirmed = new Set([...confirmed, ...batch.map((note) => note.id)]) + } + const latest = await loadRevocableClosure(logSource, contract.address, originNoteId) + setRemaining(selectedFrom(latest, confirmed, skipped).map((note) => note.id)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Revocation failed') + const latest = await loadRevocableClosure(logSource, contract.address, originNoteId).catch(() => []) + setRemaining(selectedFrom(latest, chosen, skipped).map((note) => note.id)) + } finally { + setBusy(false) + } + } + + const title = mode === 'takeback' ? 'Takeback' : 'Hand back' + return ( + + {title} + + + {mode === 'takeback' + ? 'Every note still delegated from this one starts selected. Uncheck any you want to leave with him.' + : 'Only this note starts selected. Check the others to hand those back too.'} + + 0 && rows.every((note) => note.checked)} onChange={(event) => toggleAll(event.target.checked)} />} + label="All of these" + /> + + {rows.map((note) => ( + toggle(note.id)} />} + label={`Fund #${note.id}`} + /> + ))} + {rows.length === 0 && !error && Nothing delegated is left in this set.} + + {remaining && remaining.length > 0 && ( + + Still delegated: {remaining.map((id) => `#${id}`).join(', ')}. The authority is not gone. + + )} + {error && {error}} + + + + + + + ) +} diff --git a/ui/src/delegation/components/SpendPolicyPanel.tsx b/ui/src/delegation/components/SpendPolicyPanel.tsx index 260a0ddfb..f2fbbff58 100644 --- a/ui/src/delegation/components/SpendPolicyPanel.tsx +++ b/ui/src/delegation/components/SpendPolicyPanel.tsx @@ -13,11 +13,14 @@ export function SpendPolicyPanel({ contractAddress, owners, onChanged, + receipt = false, }: { noteId: bigint contractAddress: Address owners: Address[] onChanged?: () => Promise + /** Edits here follow a refund of this receipt. They do not change the unspent remainder. */ + receipt?: boolean }) { const publicClient = usePublicClient() const clients = useWriteClients() @@ -128,6 +131,7 @@ export function SpendPolicyPanel({ Wait before a spend completes Other payments wait this long, and you can cancel them until they complete. A spend already scheduled keeps the deadline it was given, unless you change who is on your list. + {receipt ? ' A refund of this receipt comes back under these rules. The unspent fund is separate.' : ''} setDelayHours(event.target.value)} helperText="Empty means no wait." /> diff --git a/ui/src/delegation/pages/MyNotesPage.test.tsx b/ui/src/delegation/pages/MyNotesPage.test.tsx index 0e9fcbf8f..f544a7201 100644 --- a/ui/src/delegation/pages/MyNotesPage.test.tsx +++ b/ui/src/delegation/pages/MyNotesPage.test.tsx @@ -24,6 +24,8 @@ vi.mock('@commonality/sdk/delegation', async () => { getDelegationChain: vi.fn(), delegateNote: vi.fn(), revokeNote: vi.fn(), + revokeMany: vi.fn(), + loadRevocableClosure: vi.fn(), partialTakeback: vi.fn(), reclaimFunds: vi.fn(), getActiveStandingPledgesByUser: vi.fn(), @@ -49,7 +51,7 @@ vi.mock('@commonality/sdk/machinery', async () => { }) import { useAccount, useWalletClient, usePublicClient } from 'wagmi' -import { getNotesByOwner, getNotesByRoot, getDelegationChain, getDonationActivityByRoot, delegateNote, revokeNote, reclaimFunds, getActiveStandingPledgesByUser, cancelStandingPledge } from '@commonality/sdk/delegation' +import { getNotesByOwner, getNotesByRoot, getDelegationChain, getDonationActivityByRoot, delegateNote, revokeMany, loadRevocableClosure, reclaimFunds, getActiveStandingPledgesByUser, cancelStandingPledge } from '@commonality/sdk/delegation' import { createSDKMachinery } from '@commonality/sdk/machinery' import { getStatement } from '@commonality/sdk/conceptspace' import { getDomainUrl } from '../../shared' @@ -558,7 +560,18 @@ describe('MyNotesPage', () => { { address: userAddress, position: 0, createdAt: '1700000000' }, { address: delegateAddress, position: 1, createdAt: '1700000001' }, ]) - vi.mocked(revokeNote).mockResolvedValue({ hash: '0xrevoke' } as any) + const row = { + id: '1', + noteId: 1n, + amount: '1', + owners: [delegateAddress as `0x${string}`, userAddress as `0x${string}`], + parentId: null, + } + vi.mocked(revokeMany).mockResolvedValue('0xrevoke') + vi.mocked(loadRevocableClosure) + .mockResolvedValueOnce([row]) + .mockResolvedValueOnce([row]) + .mockResolvedValue([]) render() @@ -567,15 +580,14 @@ describe('MyNotesPage', () => { expect(screen.getByRole('button', { name: 'Partial takeback' })).toBeInTheDocument() }) fireEvent.click(screen.getByRole('button', { name: 'Takeback' })) + fireEvent.click(await screen.findByRole('button', { name: 'Revoke selected' })) await waitFor(() => { - expect(getDelegationChain).toHaveBeenCalledWith(mockMachinery, '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1') - expect(revokeNote).toHaveBeenCalledWith( + expect(revokeMany).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }), expect.objectContaining({ - noteId: 1n, - owners: [delegateAddress, userAddress], + notes: [{ noteId: 1n, owners: [delegateAddress, userAddress] }], }) ) }) @@ -593,13 +605,21 @@ describe('MyNotesPage', () => { { address: userAddress, position: 0, createdAt: '1700000000' }, { address: delegateAddress, position: 1, createdAt: '1700000001' }, ]) - vi.mocked(revokeNote).mockRejectedValue(new Error('Revocation reverted')) + vi.mocked(loadRevocableClosure).mockResolvedValue([{ + id: '1', + noteId: 1n, + amount: '1', + owners: [delegateAddress, userAddress], + parentId: null, + }]) + vi.mocked(revokeMany).mockRejectedValue(new Error('Revocation reverted')) render() await waitFor(() => { fireEvent.click(screen.getByRole('button', { name: 'Takeback' })) }) + fireEvent.click(await screen.findByRole('button', { name: 'Revoke selected' })) await waitFor(() => { expect(screen.getByText('Revocation reverted')).toBeInTheDocument() diff --git a/ui/src/delegation/pages/MyNotesPage.tsx b/ui/src/delegation/pages/MyNotesPage.tsx index 38d7abfd0..c0d42ba74 100644 --- a/ui/src/delegation/pages/MyNotesPage.tsx +++ b/ui/src/delegation/pages/MyNotesPage.tsx @@ -26,13 +26,14 @@ import { useAccount, usePublicClient } from 'wagmi' import { decodeEventLog, formatEther, parseEther, type Hex } from 'viem' import { DelegatableNotesAbi, RecurringPledgesAbi } from '@commonality/sdk/abis' import { getStatement } from '@commonality/sdk/conceptspace' -import { getNotesByOwner, getNotesByRoot, getDelegationChain, getDonationActivityByRoot, delegateNote, partialTakeback, replaceDelegate, revokeNote, reclaimFunds, getActiveStandingPledgesByUser, cancelStandingPledge, type DonationActivity, type Note, type StandingPledge, type DelegatableNotesContract, type RecurringPledgesContract } from '@commonality/sdk/delegation' +import { getNotesByOwner, getNotesByRoot, getDelegationChain, getDonationActivityByRoot, delegateNote, partialTakeback, replaceDelegate, reclaimFunds, getActiveStandingPledgesByUser, cancelStandingPledge, type DonationActivity, type Note, type StandingPledge, type DelegatableNotesContract, type RecurringPledgesContract } from '@commonality/sdk/delegation' import { fetchEventsComplete, type Currency, type IpfsCidV1 } from '@commonality/sdk/utils' import { getDomainUrl, useMachinery } from '../../shared' import { useWriteClients } from '../../shared' import { formatCurrencyAmount, getCurrencyForNote } from '../../shared/funding' import { formatNoteAmount, isDelegate, truncateAddress, isEthNote, noteDetailPath, noteScopedKey, parsePartialTakebackAmount } from '../utils' import { DonorPendingSpends } from '../components/DonorPendingSpends' +import { RevokeClosureDialog } from '../components/RevokeClosureDialog' import { spendClassLabel } from '../spendClass' import { readLazyGivingProjectMetadata } from '../../lazy-giving/metadata' @@ -522,6 +523,7 @@ function DonationActivityFeed({ activities, projectTitles, causeTitles, classByN export function MyNotesPage({ experience = 'delegation' }: { experience?: 'delegation' | 'donate' } = {}) { const { address } = useAccount() + const publicClient = usePublicClient() const writeClients = useWriteClients(address) const machinery = useMachinery() @@ -541,6 +543,7 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg const [delegateDialogOpen, setDelegateDialogOpen] = useState(false) const [delegateMode, setDelegateMode] = useState<'delegate' | 'replace'>('delegate') const [delegateTarget, setDelegateTarget] = useState(null) + const [revokeTarget, setRevokeTarget] = useState<{ note: Note; mode: 'takeback' | 'handback' } | null>(null) const isDonate = experience === 'donate' const getClients = () => { @@ -664,28 +667,8 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg } } - const handleRevoke = async (note: Note) => { - const clients = getClients() - const contract = getContract(note.contractAddress) - if (!clients || !contract) return - try { - setActionLoading(true) - setActionError(null) - const chain = await getDelegationChain(machinery, noteScopedKey(note)) - const owners = chain - .sort((a, b) => b.position - a.position) - .map(link => link.address as `0x${string}`) - await revokeNote(clients, contract, { - noteId: BigInt(note.id), - owners, - }) - await loadNotes() - } catch (err) { - console.error('Takeback failed:', err) - setActionError(err instanceof Error ? err.message : 'Takeback failed') - } finally { - setActionLoading(false) - } + const handleRevoke = (note: Note, mode: 'takeback' | 'handback') => { + setRevokeTarget({ note, mode }) } const handlePartialTakeback = async (note: Note, amount: string) => { @@ -828,7 +811,7 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg showDelegate={!isDelegate(note)} showGiveBack={isDelegate(note)} onDelegate={handleDelegate} - onGiveBack={handleRevoke} + onGiveBack={(note) => handleRevoke(note, 'handback')} /> ))} @@ -885,7 +868,7 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg showDelegate={!isDelegate(note)} onDelegate={handleDelegate} onReplace={handleReplace} - onRevoke={handleRevoke} + onRevoke={(note) => handleRevoke(note, 'takeback')} onPartialTakeback={handlePartialTakeback} onReclaim={handleReclaim} /> @@ -922,6 +905,17 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg )} + setRevokeTarget(null)} + onFinished={async () => { await loadNotes() }} + /> + (null) const [partialAmount, setPartialAmount] = useState('') const [spendPending, setSpendPending] = useState(false) const [delegateMode, setDelegateMode] = useState<'delegate' | 'replace'>('delegate') @@ -495,30 +497,6 @@ export function NoteDetailPage() { } } - const handleRevoke = async () => { - if (!note) return - const clients = getClients() - const contract = getContract(note.contractAddress) - if (!clients || !contract) return - try { - setActionLoading(true) - setActionError(null) - const owners = [...chain] - .sort((a, b) => b.position - a.position) - .map(link => link.address as `0x${string}`) - await revokeNote(clients, contract, { - noteId: BigInt(note.id), - owners, - }) - await loadNoteData() - } catch (err) { - console.error('Takeback failed:', err) - setActionError(err instanceof Error ? err.message : 'Takeback failed') - } finally { - setActionLoading(false) - } - } - const handlePartialTakeback = async () => { if (!note) return const amount = parsePartialTakebackAmount(partialAmount, note) @@ -750,6 +728,7 @@ export function NoteDetailPage() { <> b.position - a.position).map((link) => link.address as `0x${string}`)} @@ -853,12 +832,12 @@ export function NoteDetailPage() { )} {canResign && ( - )} {canTakeback && ( - )} @@ -899,6 +878,17 @@ export function NoteDetailPage() { onSubmit={handleDelegateSubmit} /> + setRevokeMode(null)} + onFinished={loadNoteData} + /> + setPartialOpen(false)} maxWidth="sm" fullWidth> Partial takeback of fund #{note.id} From f47312b3a896afcaa242a87eae320f666961a714 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sun, 27 Sep 2026 14:56:23 -0400 Subject: [PATCH 3/3] Keep recurring pledge fine-list getter ABI stable --- hardhat/contracts/delegation/RecurringPledges.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hardhat/contracts/delegation/RecurringPledges.sol b/hardhat/contracts/delegation/RecurringPledges.sol index 0c08cc49b..c14764536 100644 --- a/hardhat/contracts/delegation/RecurringPledges.sol +++ b/hardhat/contracts/delegation/RecurringPledges.sol @@ -84,7 +84,7 @@ contract RecurringPledges is ReentrancyGuard { mapping(uint256 => Pledge) public pledges; mapping(uint256 => address[]) private pledgeFlaggerList; mapping(uint256 => bytes32[]) private pledgeFineIds; - mapping(uint256 => mapping(bytes32 => bool)) public pledgeFineIdsed; + mapping(uint256 => mapping(bytes32 => bool)) public pledgeFineListed; constructor(address delegatableNotesAddress) { if (delegatableNotesAddress == address(0)) revert ZeroAddress(); @@ -208,15 +208,15 @@ contract RecurringPledges is ReentrancyGuard { function _setFineListed(uint256 pledgeId, bytes32 beneficiaryId, bool allowed) private { if (beneficiaryId == bytes32(0)) revert ZeroAddress(); - if (allowed == pledgeFineIdsed[pledgeId][beneficiaryId]) { + if (allowed == pledgeFineListed[pledgeId][beneficiaryId]) { emit PledgeFineListSet(pledgeId, beneficiaryId, allowed); return; } if (allowed) { - pledgeFineIdsed[pledgeId][beneficiaryId] = true; + pledgeFineListed[pledgeId][beneficiaryId] = true; pledgeFineIds[pledgeId].push(beneficiaryId); } else { - pledgeFineIdsed[pledgeId][beneficiaryId] = false; + pledgeFineListed[pledgeId][beneficiaryId] = false; bytes32[] storage ids = pledgeFineIds[pledgeId]; for (uint256 i = 0; i < ids.length; i++) { if (ids[i] == beneficiaryId) {