diff --git a/TODO.md b/TODO.md index d67366e8..f6ebb8e2 100644 --- a/TODO.md +++ b/TODO.md @@ -20,15 +20,10 @@ 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. -- 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 339162bd..711db736 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/contracts/delegation/RecurringPledges.sol b/hardhat/contracts/delegation/RecurringPledges.sol index b1167d9b..c1476453 100644 --- a/hardhat/contracts/delegation/RecurringPledges.sol +++ b/hardhat/contracts/delegation/RecurringPledges.sol @@ -83,7 +83,7 @@ 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 => bytes32[]) private pledgeFineIds; mapping(uint256 => mapping(bytes32 => bool)) public pledgeFineListed; constructor(address delegatableNotesAddress) { @@ -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,6 +203,10 @@ 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]) { emit PledgeFineListSet(pledgeId, beneficiaryId, allowed); @@ -201,10 +214,10 @@ contract RecurringPledges is ReentrancyGuard { } if (allowed) { pledgeFineListed[pledgeId][beneficiaryId] = true; - pledgeFineList[pledgeId].push(beneficiaryId); + pledgeFineIds[pledgeId].push(beneficiaryId); } else { pledgeFineListed[pledgeId][beneficiaryId] = false; - bytes32[] storage ids = pledgeFineList[pledgeId]; + 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/DelegatableNotes.refund.test.js b/hardhat/test/DelegatableNotes.refund.test.js index 984075a7..e5cdd1a1 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/hardhat/test/RecurringPledges.test.js b/hardhat/test/RecurringPledges.test.js index 0495fb07..d3a163e4 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/DelegatableNotesAbi.ts b/indexer/abis/DelegatableNotesAbi.ts index 7e773481..2bded165 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/indexer/abis/RecurringPledgesAbi.ts b/indexer/abis/RecurringPledgesAbi.ts index 1451df64..fda219d7 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/DelegatableNotesAbi.ts b/sdk/abis/DelegatableNotesAbi.ts index 7e773481..2bded165 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/abis/RecurringPledgesAbi.ts b/sdk/abis/RecurringPledgesAbi.ts index 1451df64..fda219d7 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 905ace28..6cb85f5c 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 @@ -316,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 4deff78e..5ef52f1c 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 f9f20932..70d146e1 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/recurring-pledges.ts b/sdk/src/subsystems/delegation/recurring-pledges.ts index cd54692d..723f009d 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/sdk/src/subsystems/delegation/revocationClosure.test.ts b/sdk/src/subsystems/delegation/revocationClosure.test.ts new file mode 100644 index 00000000..fd290235 --- /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 00000000..88ce337f --- /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/decisions/0018-pledge-classification-before-first-note.md b/specs/decisions/0018-pledge-classification-before-first-note.md new file mode 100644 index 00000000..6b101827 --- /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 c717b47e..077c6f81 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 e91414a4..49e016b2 100644 --- a/specs/glossary.md +++ b/specs/glossary.md @@ -40,10 +40,10 @@ 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** | 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/product/legal/delegation-narrowing.md b/specs/product/legal/delegation-narrowing.md index d60a2939..2195f9f8 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 027db022..51066ce8 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 28d337f1..275659a6 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 00000000..8c65791c --- /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/spend-classification.md b/specs/tech/subsystems/delegation/spend-classification.md index 98e5466f..b2267f6a 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/ui.md b/specs/tech/subsystems/delegation/ui.md index b18c07cf..0351067b 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 5126cd93..d58a91f1 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 | @@ -66,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/commonality/shell/SuspiciousSpendBanner.tsx b/ui/src/commonality/shell/SuspiciousSpendBanner.tsx index 9f9313aa..facff05b 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 99c3ecd1..ef6773b6 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 69cb6e33..9fd9d2de 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 4bb87d67..93753d40 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 23e84a56..a5272fc0 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/RevokeClosureDialog.tsx b/ui/src/delegation/components/RevokeClosureDialog.tsx new file mode 100644 index 00000000..1e309191 --- /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 5967f817..f2fbbff5 100644 --- a/ui/src/delegation/components/SpendPolicyPanel.tsx +++ b/ui/src/delegation/components/SpendPolicyPanel.tsx @@ -13,16 +13,20 @@ 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() 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 +56,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 +68,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 +85,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 +128,29 @@ 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. + {receipt ? ' A refund of this receipt comes back under these rules. The unspent fund is separate.' : ''} - 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 a1065946..83c1a778 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 33f5ff3f..64db8758 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.test.tsx b/ui/src/delegation/pages/MyNotesPage.test.tsx index 0e9fcbf8..f544a720 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 cd0c2c08..c0d42ba7 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' @@ -412,7 +413,15 @@ function StandingPledgeCard({ Last executed: {pledge.lastExecuted === '0' ? 'not yet' : formatPledgeDate(pledge.lastExecuted)} - + + )} {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} diff --git a/ui/src/delegation/pages/PledgePage.tsx b/ui/src/delegation/pages/PledgePage.tsx new file mode 100644 index 00000000..e0cbf964 --- /dev/null +++ b/ui/src/delegation/pages/PledgePage.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState } from 'react' +import { Alert, Box, Button, Chip, Paper, Stack, Switch, TextField, Typography } from '@mui/material' +import { useParams } from 'react-router-dom' +import { useAccount, usePublicClient } from 'wagmi' +import type { Address } from 'viem' +import { isAddress } from 'viem' +import { RecurringPledgesAbi } from '@commonality/sdk/abis' +import { hashBeneficiaryId, normalizeDnsBeneficiary } from '@commonality/sdk/content-funding' +import { setPledgeFineListed, setPledgeUnsuspiciousDelay, updatePledgeSpendPolicy } from '@commonality/sdk/delegation' +import { useWriteClients } from '../../shared' +import { hoursInputToSeconds, secondsToHourInput } from '../spendClass' + +function pledgeContract() { + const addr = import.meta.env.VITE_RECURRING_PLEDGES_CONTRACT_ADDRESS + if (!addr) return null + return { address: addr as Address, abi: RecurringPledgesAbi } +} + +export function PledgePage() { + const { pledgeId: pledgeIdParam } = useParams() + const pledgeId = pledgeIdParam && /^\d+$/.test(pledgeIdParam) ? BigInt(pledgeIdParam) : null + const { address } = useAccount() + const publicClient = usePublicClient() + const clients = useWriteClients(address) + const [owner, setOwner] = useState
(null) + const [active, setActive] = useState(true) + const [waitHours, setWaitHours] = 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('') + const [names, setNames] = useState<`0x${string}`[]>([]) + const [nameInput, setNameInput] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + if (!publicClient || pledgeId === null) return + const contract = pledgeContract() + if (!contract) return + let cancelled = false + ;(async () => { + const pledge = await publicClient.readContract({ + ...contract, + functionName: 'pledges', + args: [pledgeId], + }) + const listedFlaggers = await publicClient.readContract({ + ...contract, + functionName: 'pledgeFlaggers', + args: [pledgeId], + }) as Address[] + const fineList = await publicClient.readContract({ + ...contract, + functionName: 'pledgeFineList', + args: [pledgeId], + }) as `0x${string}`[] + if (cancelled) return + setOwner(pledge[0]) + setActive(pledge[8]) + setWaitHours(secondsToHourInput(pledge[9])) + setListedWaitHours(secondsToHourInput(pledge[10])) + setShowListedWait(pledge[10] > 0n) + setStrict(pledge[11]) + setFlaggers(listedFlaggers) + setNames(fineList) + setLoaded(true) + })().catch(() => { + if (!cancelled) setError('Could not read this pledge') + }) + return () => { cancelled = true } + }, [publicClient, pledgeId, busy]) + + if (pledgeId === null) { + return This pledge link is not valid. + } + + const contract = pledgeContract() + const canEdit = Boolean(clients && contract && address && owner && address.toLowerCase() === owner.toLowerCase() && active) + const id = pledgeId + + async function saveTemplate() { + if (!clients || !contract || !canEdit) return + const wait = hoursInputToSeconds(waitHours.trim() === '' ? '0' : waitHours) + const listedWait = showListedWait + ? hoursInputToSeconds(listedWaitHours.trim() === '' ? '0' : listedWaitHours) + : 0n + if (wait === null || listedWait === null) { + setError('Enter the wait in hours, or leave it empty for none') + return + } + if (listedWait > wait) { + setError('The wait for a listed name cannot be longer than the ordinary wait') + return + } + setBusy(true) + setError(null) + try { + await updatePledgeSpendPolicy(clients, contract, { + pledgeId: id, + spendDelay: wait, + strictMode, + flaggers, + }) + const savedListedWait = listedWait > wait ? wait : listedWait + await setPledgeUnsuspiciousDelay(clients, contract, { pledgeId: id, delay: savedListedWait }) + if (savedListedWait !== listedWait) setListedWaitHours(secondsToHourInput(savedListedWait)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not save this pledge') + } finally { + setBusy(false) + } + } + + async function changeName(beneficiaryId: `0x${string}`, allowed: boolean) { + if (!clients || !contract || !canEdit) return + setBusy(true) + setError(null) + try { + await setPledgeFineListed(clients, contract, { pledgeId: id, beneficiaryId, allowed }) + setNameInput('') + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not change that name') + } finally { + setBusy(false) + } + } + + return ( + + Monthly pledge #{pledgeId.toString()} + + A save applies to notes minted afterward, not to notes already minted. Change a note that already exists from that note's page. + + {!contract && Recurring pledges are not configured.} + {loaded && !active && This pledge is cancelled. Future notes will not be minted.} + {loaded && address && owner && address.toLowerCase() !== owner.toLowerCase() && ( + Only the donor can edit what future notes inherit. + )} + + Wait before a spend completes + setWaitHours(event.target.value)} + disabled={!canEdit || busy} + helperText="This is the wait stored on the pledge, not a suggestion." + sx={{ mb: 1 }} + /> + {!showListedWait && ( + + + + )} + {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 ae5598d0..c8e28040 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 55e190a8..e020228d 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 403ae6a3..2e08b70a 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 4d078196..80dc8cdd 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 fd388997..0204a184 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 46c3922c..d92e38d9 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 b4d536bf..bd544191 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 fde81b01..96879b6a 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 809b0915..51aeac0a 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')} />