From 05abb833860b3eeb4c51887f8e7726fea939cc7c Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sat, 26 Sep 2026 16:22:11 -0400 Subject: [PATCH 1/5] Classify a delegated spend by the current controller of a fine-listed beneficiary. A donor names beneficiary ids and a shorter unsuspicious delay. Only a market whose payout registry reports that id as the current controller uses that shorter wait; every other spend keeps the standing delay. ADR 0017 records the decision, and recurring pledges copy the fine list onto each new note. --- TODO.md | 1 - .../contracts/delegation/DelegatableNotes.sol | 189 +++++++++++- .../contracts/delegation/RecurringPledges.sol | 58 +++- .../FixedControllerAssuranceContract.sol | 31 ++ .../FixedControllerFactory.sol | 28 ++ .../individual-projects/ProjectFactory.sol | 28 +- hardhat/contracts/test/MockPayoutRegistry.sol | 14 + hardhat/contracts/test/MockPrimaryMarket.sol | 9 + hardhat/scripts/deploy-incremental.js | 11 +- .../ConceptspaceAcceptanceJourneys.test.js | 2 + ...legatableNotes.spendClassification.test.js | 78 +++++ .../DelegatableNotes.waitingPeriod.test.js | 13 + hardhat/test/ProjectFactory.test.js | 15 +- hardhat/test/RecurringPledges.test.js | 2 + indexer/abis/DelegatableNotesAbi.ts | 268 ++++++++++++++++++ indexer/abis/ProjectFactoryAbi.ts | 18 ++ indexer/abis/RecurringPledgesAbi.ts | 119 ++++++++ indexer/src/events-cache/index.ts | 6 + scripts/deployment-manifest.mjs | 1 + sdk/abis/DelegatableNotesAbi.ts | 268 ++++++++++++++++++ sdk/abis/ProjectFactoryAbi.ts | 18 ++ sdk/abis/RecurringPledgesAbi.ts | 119 ++++++++ sdk/src/subsystems/delegation/actions.ts | 30 ++ specs/decisions/0017-spend-classification.md | 41 +++ specs/decisions/README.md | 1 + specs/glossary.md | 2 + specs/product/legal/delegation-narrowing.md | 4 +- specs/tech/subsystems/delegation/README.md | 2 +- .../delegation/spend-classification.md | 71 +++++ .../subsystems/delegation/waiting-period.md | 2 +- .../components/FineListPanel.test.ts | 9 + .../delegation/components/FineListPanel.tsx | 108 +++++++ ui/src/delegation/pages/NoteDetailPage.tsx | 9 + 33 files changed, 1547 insertions(+), 28 deletions(-) create mode 100644 hardhat/contracts/individual-projects/FixedControllerAssuranceContract.sol create mode 100644 hardhat/contracts/individual-projects/FixedControllerFactory.sol create mode 100644 hardhat/contracts/test/MockPayoutRegistry.sol create mode 100644 hardhat/test/DelegatableNotes.spendClassification.test.js create mode 100644 specs/decisions/0017-spend-classification.md create mode 100644 specs/tech/subsystems/delegation/spend-classification.md create mode 100644 ui/src/delegation/components/FineListPanel.test.ts create mode 100644 ui/src/delegation/components/FineListPanel.tsx diff --git a/TODO.md b/TODO.md index 0b6c140b4..e78ced9e0 100644 --- a/TODO.md +++ b/TODO.md @@ -26,7 +26,6 @@ Getting **testnet to a two-person shared lab** is also a standing plan, not a pi - 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. -- Beneficiary identity on delegated spends. Show and check the actual payout route (a direct recipient fixed at creation, versus beneficiary escrow), not project metadata or the registry's current wallet, and handle wallet rotation when a spend is scheduled or executes. An optional rule may limit the delegate's independent spends to identities the donor has approved, without requiring every donor to preselect recipients or every beneficiary to claim before funds arrive. Distinguish "destination reserved for this identity" from "controller has verified a payout wallet." Neither is an endorsement of the project. A new domain or wallet is not suspicious, and a delegate proving control of their own domain is not evidence of independence. Use [claimable-beneficiaries.md](specs/tech/subsystems/claimable-beneficiaries.md). Do not add new payout-attestation machinery. Write the proposal against `specs/tech/subsystems/delegation/` before changing contracts. - Exact-payment donor overrides. For an ordinary pending spend, "Approve now" is enough. If that spend breaks a rule she configured, name the exception (for example, a beneficiary outside her approved list) and bind her approval to that payment only. Changing the standing rule is a separate action. The delegate's restrictions stay enforced by the contract. She should not have to revoke and redeposit to make the exception. Write the proposal against `specs/tech/subsystems/delegation/` and [delegation-narrowing.md](specs/product/legal/delegation-narrowing.md) before changing contracts. diff --git a/hardhat/contracts/delegation/DelegatableNotes.sol b/hardhat/contracts/delegation/DelegatableNotes.sol index 8e88a31b4..c5f7194e7 100644 --- a/hardhat/contracts/delegation/DelegatableNotes.sol +++ b/hardhat/contracts/delegation/DelegatableNotes.sol @@ -77,6 +77,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { error SpendNotDue(); error SpendPaused(); error NotSpendFlagger(); + error UnsuspiciousDelayExceedsStanding(); error FlaggerCannotBeDelegate(); error FlaggerCannotBeMarket(); error SplitAmountMustBePartial(); @@ -105,6 +106,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { struct SpendPolicy { uint256 delay; + uint256 unsuspiciousDelay; bool strictMode; } @@ -113,12 +115,16 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { address erc1155Contract; uint256 tokenId; uint256 count; + uint256 scheduledAt; uint256 deadline; uint256 nonce; bool paused; bool exists; } + uint8 public constant CLASS_UNMARKED = 0; + uint8 public constant CLASS_UNSUSPICIOUS = 1; + // Depth limit to prevent gas exhaustion from extremely long chains uint256 public constant MAX_DELEGATION_DEPTH = 200; @@ -128,6 +134,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { mapping(uint256 => ReimbursementClaim) public reimbursementClaims; mapping(uint256 => SpendPolicy) public spendPolicies; mapping(uint256 => PendingSpend) public pendingSpends; + mapping(uint256 => mapping(bytes32 => bool)) public fineListed; + mapping(uint256 => bytes32[]) private fineListIds; mapping(uint256 => mapping(address => bool)) public isSpendFlagger; mapping(uint256 => address[]) private spendFlaggerList; bool private scheduledExecution; @@ -229,6 +237,10 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { ); event SpendDelaySet(uint256 indexed noteId, uint256 delay); + event UnsuspiciousDelaySet(uint256 indexed noteId, uint256 delay); + event FineListSet(uint256 indexed noteId, bytes32 indexed beneficiaryId, bool allowed); + event SpendClassResolved(uint256 indexed noteId, uint8 class, bytes32 beneficiaryId); + event SpendDeadlineRevised(uint256 indexed noteId, uint256 indexed nonce, uint256 deadline); event StrictModeSet(uint256 indexed noteId, bool enabled); event SpendFlaggerSet(uint256 indexed noteId, address indexed flagger, bool allowed); event NoteSplitSameChain(uint256 indexed fromNoteId, uint256 indexed newNoteId, uint256 amount); @@ -388,13 +400,16 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { uint256 amount, address delegateTo, uint256 spendDelay, + uint256 unsuspiciousDelay, bool strictMode, - address[] calldata flaggers + address[] calldata flaggers, + bytes32[] calldata fineIds ) external nonReentrant returns (uint256) { if (_msgSender() != recurringPledgeRegistry) revert UnauthorizedRecurringPledgeRegistry(); if (rootOwner == address(0) || token == address(0) || delegateTo == address(0)) revert ZeroAddress(); if (amount == 0) revert AmountMustBeGreaterThanZero(); if (rootOwner == delegateTo) revert CircularDelegationDetected(); + if (unsuspiciousDelay > spendDelay) revert UnsuspiciousDelayExceedsStanding(); uint256 noteId = nextNoteId++; bytes32 rootChainHash = _computeChainHash(rootOwner, bytes32(0)); @@ -407,7 +422,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { tokenType: TokenType.ERC20, tokenId: 0 }); - _writePolicy(noteId, spendDelay, strictMode, flaggers, delegateTo); + _writePolicy(noteId, spendDelay, unsuspiciousDelay, strictMode, flaggers, delegateTo); + _includeFineIds(noteId, fineIds); emit NoteCreated(noteId, rootOwner, amount, token, TokenType.ERC20, 0); emit NoteDelegated(noteId, noteId, delegateTo, amount); @@ -635,6 +651,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { TokenType tokenType = note.tokenType; uint256 tokenId = note.tokenId; bool strictMode = spendPolicies[noteId].strictMode; + uint256 copiedUnsuspicious = spendPolicies[noteId].unsuspiciousDelay; + if (copiedUnsuspicious > newDelay) copiedUnsuspicious = newDelay; _clearPending(noteId); bytes32 newChainHash = _computeChainHash(newDelegate, _computeChainHash(root, bytes32(0))); @@ -648,7 +666,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { }); _moveClaimPortion(noteId, replacedNoteId, amount); address[] memory flaggers = spendFlaggerList[noteId]; - _writePolicy(replacedNoteId, newDelay, strictMode, flaggers, newDelegate); + _writePolicy(replacedNoteId, newDelay, copiedUnsuspicious, strictMode, flaggers, newDelegate); + _copyFineList(noteId, replacedNoteId); if (amount == note.amount) { delete notes[noteId]; @@ -697,9 +716,60 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { function setSpendDelay(uint256 noteId, address[] calldata owners, uint256 delay) external { _requireRoot(noteId, owners); spendPolicies[noteId].delay = delay; + if (spendPolicies[noteId].unsuspiciousDelay > delay) { + spendPolicies[noteId].unsuspiciousDelay = delay; + emit UnsuspiciousDelaySet(noteId, delay); + } emit SpendDelaySet(noteId, delay); } + function setUnsuspiciousDelay(uint256 noteId, address[] calldata owners, uint256 delay) external { + _requireRoot(noteId, owners); + if (delay > spendPolicies[noteId].delay) revert UnsuspiciousDelayExceedsStanding(); + spendPolicies[noteId].unsuspiciousDelay = delay; + emit UnsuspiciousDelaySet(noteId, delay); + _revisePending(noteId); + } + + function setFineListed( + uint256 noteId, + address[] calldata owners, + bytes32 beneficiaryId, + bool allowed + ) external { + _requireRoot(noteId, owners); + if (beneficiaryId == bytes32(0)) revert ZeroAddress(); + if (allowed == fineListed[noteId][beneficiaryId]) { + emit FineListSet(noteId, beneficiaryId, allowed); + return; + } + if (allowed) { + fineListed[noteId][beneficiaryId] = true; + fineListIds[noteId].push(beneficiaryId); + } else { + fineListed[noteId][beneficiaryId] = false; + bytes32[] storage ids = fineListIds[noteId]; + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == beneficiaryId) { + ids[i] = ids[ids.length - 1]; + ids.pop(); + break; + } + } + } + emit FineListSet(noteId, beneficiaryId, allowed); + _revisePending(noteId); + } + + function fineList(uint256 noteId) external view returns (bytes32[] memory) { + return fineListIds[noteId]; + } + + /// @notice The delay and class a delegate spend of `primaryMarket` would use now. + function effectiveSpendDelay(uint256 noteId, address primaryMarket) external view returns (uint256 delay, uint8 class) { + return _classification(noteId, primaryMarket); + } + function setStrictMode(uint256 noteId, address[] calldata owners, bool enabled) external { _requireRoot(noteId, owners); spendPolicies[noteId].strictMode = enabled; @@ -758,7 +828,15 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { tokenId: note.tokenId }); address[] memory flaggers = spendFlaggerList[noteId]; - _writePolicy(newNoteId, spendPolicies[noteId].delay, spendPolicies[noteId].strictMode, flaggers, owners[0]); + _writePolicy( + newNoteId, + spendPolicies[noteId].delay, + spendPolicies[noteId].unsuspiciousDelay, + spendPolicies[noteId].strictMode, + flaggers, + owners[0] + ); + _copyFineList(noteId, newNoteId); _moveClaimPortion(noteId, newNoteId, amount); note.amount -= amount; emit NoteSplitSameChain(noteId, newNoteId, amount); @@ -784,7 +862,9 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { note.amount ); - if (spendPolicies[noteId].delay == 0) { + (uint256 delay, uint8 class) = _classification(noteId, primaryMarket); + if (delay == 0) { + emit SpendClassResolved(noteId, class, _routeBeneficiaryId(primaryMarket)); PurchaseShare[] memory shares = new PurchaseShare[](1); shares[0] = PurchaseShare({ noteId: noteId, chain: owners, shares: count }); _purchaseFromPrimaryMarket(shares, primaryMarket, erc1155Contract, tokenId, count); @@ -792,17 +872,20 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { } uint256 nonce = nextScheduleNonce++; - uint256 deadline = block.timestamp + spendPolicies[noteId].delay; + uint256 scheduledAt = block.timestamp; + uint256 deadline = scheduledAt + delay; pendingSpends[noteId] = PendingSpend({ primaryMarket: primaryMarket, erc1155Contract: erc1155Contract, tokenId: tokenId, count: count, + scheduledAt: scheduledAt, deadline: deadline, nonce: nonce, paused: false, exists: true }); + emit SpendClassResolved(noteId, class, _routeBeneficiaryId(primaryMarket)); emit SpendScheduled( noteId, nonce, @@ -839,7 +922,14 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { PendingSpend storage pending = pendingSpends[noteId]; if (!pending.exists) revert NoScheduledSpend(); if (pending.paused) revert SpendPaused(); - if (block.timestamp < pending.deadline) revert SpendNotDue(); + uint256 previousDeadline = pending.deadline; + _revisePending(noteId); + if (block.timestamp < pending.deadline) { + // A class change that pushes the deadline back out has to persist. + // Reverting would roll that revision back. + if (pending.deadline != previousDeadline) return; + revert SpendNotDue(); + } _executePending(noteId, owners, false); } @@ -866,6 +956,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { pending.count, notes[noteId].amount ); + (, uint8 class) = _classification(noteId, pending.primaryMarket); + emit SpendClassResolved(noteId, class, _routeBeneficiaryId(pending.primaryMarket)); delete pendingSpends[noteId]; scheduledExecution = true; PurchaseShare[] memory shares = new PurchaseShare[](1); @@ -923,6 +1015,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { function _deleteSpendPolicy(uint256 noteId) private { delete spendPolicies[noteId]; + _deleteFineList(noteId); address[] storage list = spendFlaggerList[noteId]; for (uint256 i = 0; i < list.length; i++) { delete isSpendFlagger[noteId][list[i]]; @@ -933,13 +1026,16 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { function _writePolicy( uint256 noteId, uint256 delay, + uint256 unsuspiciousDelay, bool strictMode, address[] memory flaggers, address delegateTo ) private { spendPolicies[noteId].delay = delay; + spendPolicies[noteId].unsuspiciousDelay = unsuspiciousDelay; spendPolicies[noteId].strictMode = strictMode; emit SpendDelaySet(noteId, delay); + emit UnsuspiciousDelaySet(noteId, unsuspiciousDelay); emit StrictModeSet(noteId, strictMode); for (uint256 i = 0; i < flaggers.length; i++) { address flagger = flaggers[i]; @@ -950,6 +1046,75 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { } } + function _revisePending(uint256 noteId) private { + PendingSpend storage pending = pendingSpends[noteId]; + if (!pending.exists) return; + (uint256 delay, uint8 class) = _classification(noteId, pending.primaryMarket); + uint256 deadline = pending.scheduledAt + delay; + if (deadline == pending.deadline) return; + pending.deadline = deadline; + emit SpendDeadlineRevised(noteId, pending.nonce, deadline); + emit SpendClassResolved(noteId, class, _routeBeneficiaryId(pending.primaryMarket)); + } + + function _classification(uint256 noteId, address market) private view returns (uint256 delay, uint8 class) { + delay = spendPolicies[noteId].delay; + class = CLASS_UNMARKED; + (bytes32 id, address recipient, address registry) = _route(market); + if (id == bytes32(0) || recipient == address(0) || registry == address(0)) return (delay, class); + if (!fineListed[noteId][id]) return (delay, class); + (bool ok, bytes memory data) = registry.staticcall( + abi.encodeWithSignature("payoutAddress(bytes32)", id) + ); + if (!ok || data.length < 32) return (delay, class); + address payout = abi.decode(data, (address)); + if (payout == address(0) || payout != recipient) return (delay, class); + return (spendPolicies[noteId].unsuspiciousDelay, CLASS_UNSUSPICIOUS); + } + + function _routeBeneficiaryId(address market) private view returns (bytes32 id) { + (id,,) = _route(market); + } + + function _route(address market) private view returns (bytes32 id, address recipient, address registry) { + if (market == address(0)) return (bytes32(0), address(0), address(0)); + (bool okId, bytes memory idData) = market.staticcall(abi.encodeWithSignature("beneficiaryId()")); + (bool okRecipient, bytes memory recipientData) = market.staticcall(abi.encodeWithSignature("recipient()")); + (bool okRegistry, bytes memory registryData) = market.staticcall(abi.encodeWithSignature("proceedsRegistry()")); + if (okId && idData.length >= 32) id = abi.decode(idData, (bytes32)); + if (okRecipient && recipientData.length >= 32) recipient = abi.decode(recipientData, (address)); + if (okRegistry && registryData.length >= 32) registry = abi.decode(registryData, (address)); + } + + function _includeFineIds(uint256 noteId, bytes32[] calldata ids) private { + for (uint256 i = 0; i < ids.length; i++) { + bytes32 id = ids[i]; + if (id == bytes32(0) || fineListed[noteId][id]) continue; + fineListed[noteId][id] = true; + fineListIds[noteId].push(id); + emit FineListSet(noteId, id, true); + } + } + + function _copyFineList(uint256 fromNoteId, uint256 toNoteId) private { + bytes32[] storage ids = fineListIds[fromNoteId]; + for (uint256 i = 0; i < ids.length; i++) { + bytes32 id = ids[i]; + if (fineListed[toNoteId][id]) continue; + fineListed[toNoteId][id] = true; + fineListIds[toNoteId].push(id); + emit FineListSet(toNoteId, id, true); + } + } + + function _deleteFineList(uint256 noteId) private { + bytes32[] storage ids = fineListIds[noteId]; + for (uint256 i = 0; i < ids.length; i++) { + delete fineListed[noteId][ids[i]]; + } + delete fineListIds[noteId]; + } + // ============ Revocation ============ /** @@ -1040,7 +1205,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { uint256[] memory inputNoteIds, address[][] memory paymentChains, uint256[] memory outputShares - ) = _executeSharePurchase(purchaseShares, count, requiredPayment, paymentToken); + ) = _executeSharePurchase(purchaseShares, count, requiredPayment, paymentToken, primaryMarket); uint256[] memory outputNoteIds = _createNotesForPurchasedToken( primaryMarket, @@ -1222,7 +1387,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { PurchaseShare[] memory purchaseShares, uint256 outputCount, uint256 requiredPayment, - address paymentToken + address paymentToken, + address primaryMarket ) private returns ( uint256[] memory inputNoteIds, address[][] memory paymentChains, @@ -1251,8 +1417,9 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { bytes32 expectedHash = _verifyAndComputeChainHash(purchaseShare.chain); if (note.chainHash != expectedHash) revert InvalidChain(); if (pendingSpends[purchaseShare.noteId].exists) revert SpendAlreadyScheduled(); - if (!scheduledExecution && spendPolicies[purchaseShare.noteId].delay != 0) { - revert SpendMustBeScheduled(); + if (!scheduledExecution && purchaseShare.chain.length > 1) { + (uint256 effectiveDelay,) = _classification(purchaseShare.noteId, primaryMarket); + if (effectiveDelay != 0) revert SpendMustBeScheduled(); } if (!scheduledExecution && purchaseShare.chain[0] != caller) revert NotNoteOwner(); if (purchaseShare.chain.length > 2) revert DelegationHopLimit(); diff --git a/hardhat/contracts/delegation/RecurringPledges.sol b/hardhat/contracts/delegation/RecurringPledges.sol index 754281e24..b1167d9b5 100644 --- a/hardhat/contracts/delegation/RecurringPledges.sol +++ b/hardhat/contracts/delegation/RecurringPledges.sol @@ -11,8 +11,10 @@ interface IDelegatableNotesForRecurringPledges { uint256 amount, address delegateTo, uint256 spendDelay, + uint256 unsuspiciousDelay, bool strictMode, - address[] calldata flaggers + address[] calldata flaggers, + bytes32[] calldata fineIds ) external returns (uint256); } @@ -34,6 +36,7 @@ contract RecurringPledges is ReentrancyGuard { uint256 lastExecuted; bool active; uint256 spendDelay; + uint256 unsuspiciousDelay; bool strictMode; } @@ -65,6 +68,9 @@ contract RecurringPledges is ReentrancyGuard { bool strictMode ); + event PledgeUnsuspiciousDelaySet(uint256 indexed pledgeId, uint256 unsuspiciousDelay); + event PledgeFineListSet(uint256 indexed pledgeId, bytes32 indexed beneficiaryId, bool allowed); + event StandingPledgeExecuted( uint256 indexed pledgeId, uint256 indexed noteId, @@ -77,6 +83,8 @@ contract RecurringPledges is ReentrancyGuard { uint256 public nextPledgeId = 1; mapping(uint256 => Pledge) public pledges; mapping(uint256 => address[]) private pledgeFlaggerList; + mapping(uint256 => bytes32[]) private pledgeFineList; + mapping(uint256 => mapping(bytes32 => bool)) public pledgeFineListed; constructor(address delegatableNotesAddress) { if (delegatableNotesAddress == address(0)) revert ZeroAddress(); @@ -111,6 +119,7 @@ contract RecurringPledges is ReentrancyGuard { lastExecuted: 0, active: true, spendDelay: spendDelay, + unsuspiciousDelay: 0, strictMode: strictMode }); for (uint256 i = 0; i < flaggers.length; i++) { @@ -164,8 +173,51 @@ contract RecurringPledges is ReentrancyGuard { } } emit PledgeSpendPolicyUpdated(pledgeId, spendDelay, strictMode); + if (pledge.unsuspiciousDelay > spendDelay) { + pledge.unsuspiciousDelay = spendDelay; + emit PledgeUnsuspiciousDelaySet(pledgeId, spendDelay); + } + } + + function setPledgeUnsuspiciousDelay(uint256 pledgeId, uint256 unsuspiciousDelay) external { + Pledge storage pledge = pledges[pledgeId]; + if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist(); + if (pledge.rootOwner != msg.sender) revert NotPledgeOwner(); + if (!pledge.active) revert PledgeInactive(); + if (unsuspiciousDelay > pledge.spendDelay) revert UnsuspiciousDelayExceedsStanding(); + pledge.unsuspiciousDelay = unsuspiciousDelay; + emit PledgeUnsuspiciousDelaySet(pledgeId, unsuspiciousDelay); } + function setPledgeFineListed(uint256 pledgeId, bytes32 beneficiaryId, bool allowed) external { + Pledge storage pledge = pledges[pledgeId]; + if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist(); + if (pledge.rootOwner != msg.sender) revert NotPledgeOwner(); + if (!pledge.active) revert PledgeInactive(); + if (beneficiaryId == bytes32(0)) revert ZeroAddress(); + if (allowed == pledgeFineListed[pledgeId][beneficiaryId]) { + emit PledgeFineListSet(pledgeId, beneficiaryId, allowed); + return; + } + if (allowed) { + pledgeFineListed[pledgeId][beneficiaryId] = true; + pledgeFineList[pledgeId].push(beneficiaryId); + } else { + pledgeFineListed[pledgeId][beneficiaryId] = false; + bytes32[] storage ids = pledgeFineList[pledgeId]; + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == beneficiaryId) { + ids[i] = ids[ids.length - 1]; + ids.pop(); + break; + } + } + } + emit PledgeFineListSet(pledgeId, beneficiaryId, allowed); + } + + error UnsuspiciousDelayExceedsStanding(); + function pledgeFlaggers(uint256 pledgeId) external view returns (address[] memory) { return pledgeFlaggerList[pledgeId]; } @@ -204,8 +256,10 @@ contract RecurringPledges is ReentrancyGuard { pledge.amountPerPeriod, pledge.delegateTo, pledge.spendDelay, + pledge.unsuspiciousDelay, pledge.strictMode, - pledgeFlaggerList[pledgeId] + pledgeFlaggerList[pledgeId], + pledgeFineList[pledgeId] ); emit StandingPledgeExecuted(pledgeId, noteId, executedAt); } diff --git a/hardhat/contracts/individual-projects/FixedControllerAssuranceContract.sol b/hardhat/contracts/individual-projects/FixedControllerAssuranceContract.sol new file mode 100644 index 000000000..68f4b1f54 --- /dev/null +++ b/hardhat/contracts/individual-projects/FixedControllerAssuranceContract.sol @@ -0,0 +1,31 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import {MultiERC1155AssuranceContract} from "./AssuranceContracts.sol"; + +/** + * @notice Pays a recipient fixed at creation, and records which beneficiary + * that recipient controlled at the time. A later registry rotation does + * not retarget these proceeds. + */ +contract FixedControllerAssuranceContract is MultiERC1155AssuranceContract { + bytes32 public immutable beneficiaryId; + address public immutable proceedsRegistry; + + constructor( + address owner, + address controller, + address paymentToken, + address erc1155Addr, + string memory projectMetadataCid, + bytes32 _beneficiaryId, + address registry + ) MultiERC1155AssuranceContract(owner, controller, paymentToken, erc1155Addr, projectMetadataCid) { + beneficiaryId = _beneficiaryId; + proceedsRegistry = registry; + } + + function recipient() external view returns (address) { + return _recipient; + } +} diff --git a/hardhat/contracts/individual-projects/FixedControllerFactory.sol b/hardhat/contracts/individual-projects/FixedControllerFactory.sol new file mode 100644 index 000000000..8738ee936 --- /dev/null +++ b/hardhat/contracts/individual-projects/FixedControllerFactory.sol @@ -0,0 +1,28 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import {FixedControllerAssuranceContract} from "./FixedControllerAssuranceContract.sol"; + +/// @notice Deploys fixed-controller assurance contracts. DelegatableNotes authorizes +/// this factory on its own so AssuranceContractFactory stays under the size limit. +contract FixedControllerFactory { + mapping(address => bool) public isDeployedPrimaryMarket; + + event FixedControllerAssuranceCreated(address indexed assuranceContract); + + function create( + address owner, + address recipient, + address paymentToken, + address erc1155Addr, + string memory projectMetadataCid, + bytes32 beneficiaryId, + address registry + ) external returns (FixedControllerAssuranceContract ac) { + ac = new FixedControllerAssuranceContract( + owner, recipient, paymentToken, erc1155Addr, projectMetadataCid, beneficiaryId, registry + ); + isDeployedPrimaryMarket[address(ac)] = true; + emit FixedControllerAssuranceCreated(address(ac)); + } +} diff --git a/hardhat/contracts/individual-projects/ProjectFactory.sol b/hardhat/contracts/individual-projects/ProjectFactory.sol index ff560681a..d05be14d9 100644 --- a/hardhat/contracts/individual-projects/ProjectFactory.sol +++ b/hardhat/contracts/individual-projects/ProjectFactory.sol @@ -9,6 +9,7 @@ import {MultiERC1155AssuranceContract} from "./AssuranceContracts.sol"; import {IAssuranceCondition} from "./IAssuranceCondition.sol"; import {ValueThresholdCondition} from "./ValueThresholdCondition.sol"; import {BeneficiaryAssuranceContract} from "./BeneficiaryAssuranceContract.sol"; +import {FixedControllerFactory} from "./FixedControllerFactory.sol"; interface IProjectBeneficiaryRegistry { function isVerified(bytes32 beneficiaryId) external view returns (bool); @@ -147,6 +148,7 @@ contract ProjectFactory { AssuranceContractFactory public immutable _assuranceFactory; ValueThresholdConditionFactory public immutable _conditionFactory; IProjectBeneficiaryRegistry public immutable beneficiaryRegistry; + FixedControllerFactory public immutable fixedControllerFactory; /// @notice Retained so existing deployment wiring still constructs this factory. /// New projects do not deposit here. address public immutable beneficiaryEscrow; @@ -168,18 +170,21 @@ contract ProjectFactory { address assuranceFactory, address conditionFactory, address _beneficiaryRegistry, - address _beneficiaryEscrow + address _beneficiaryEscrow, + address _fixedControllerFactory ) { if (erc1155Factory == address(0)) revert InvalidFactoryAddress(); if (assuranceFactory == address(0)) revert InvalidFactoryAddress(); if (conditionFactory == address(0)) revert InvalidFactoryAddress(); if (_beneficiaryRegistry == address(0)) revert InvalidFactoryAddress(); if (_beneficiaryEscrow == address(0)) revert InvalidFactoryAddress(); + if (_fixedControllerFactory == address(0)) revert InvalidFactoryAddress(); _premintingERC1155Factory = PremintingERC1155Factory(erc1155Factory); _assuranceFactory = AssuranceContractFactory(assuranceFactory); _conditionFactory = ValueThresholdConditionFactory(conditionFactory); beneficiaryRegistry = IProjectBeneficiaryRegistry(_beneficiaryRegistry); beneficiaryEscrow = _beneficiaryEscrow; + fixedControllerFactory = FixedControllerFactory(_fixedControllerFactory); if (IProjectBeneficiaryEscrow(_beneficiaryEscrow).beneficiaryRegistry() != _beneficiaryRegistry) { revert BeneficiaryEscrowRegistryMismatch(); } @@ -245,8 +250,9 @@ contract ProjectFactory { } /** - * @notice Creates a threshold project whose proceeds stay in the project until - * the named beneficiary claims or refuses them. + * @notice Creates a project for a beneficiary id. A verified identity pays the + * current controller's wallet, fixed here. An unclaimed identity keeps + * the proceeds until a later claim. */ function createERC1155AndAssuranceContractForBeneficiary( string memory metadataURI, @@ -266,6 +272,7 @@ contract ProjectFactory { if (deadline <= block.timestamp) revert InvalidDeadline(); address payout = beneficiaryRegistry.payoutAddress(beneficiaryId); + bool verified = beneficiaryRegistry.isVerified(beneficiaryId) && payout != address(0); if (beneficiaryRegistry.isBeneficiaryControlled(beneficiaryId) && msg.sender != payout) { revert OnlyPayoutAddressCanCreateForControlledBeneficiary(); } @@ -273,7 +280,7 @@ contract ProjectFactory { metadataURI: metadataURI, contractURI: contractURI, owner: owner, - recipient: address(this), + recipient: verified ? payout : address(this), paymentToken: paymentToken, projectMetadataCid: projectMetadataCid, ids: ids, @@ -283,9 +290,16 @@ contract ProjectFactory { _validateProjectParams(params); PremintingERC1155 t = _deployToken(params); - BeneficiaryAssuranceContract ac = _assuranceFactory.createBeneficiaryAssuranceContract( - address(this), paymentToken, address(t), projectMetadataCid, beneficiaryId, address(beneficiaryRegistry), UNCLAIMED_PROCEEDS_WINDOW - ); + MultiERC1155AssuranceContract ac; + if (verified) { + ac = fixedControllerFactory.create( + address(this), payout, paymentToken, address(t), projectMetadataCid, beneficiaryId, address(beneficiaryRegistry) + ); + } else { + ac = _assuranceFactory.createBeneficiaryAssuranceContract( + address(this), paymentToken, address(t), projectMetadataCid, beneficiaryId, address(beneficiaryRegistry), UNCLAIMED_PROCEEDS_WINDOW + ); + } ValueThresholdCondition condition = _conditionFactory.createCondition(address(ac), threshold, deadline); _wireUpAndFinalize(t, ac, IAssuranceCondition(address(condition)), params); emit ProjectCreated(msg.sender, address(t), address(ac), address(condition)); diff --git a/hardhat/contracts/test/MockPayoutRegistry.sol b/hardhat/contracts/test/MockPayoutRegistry.sol new file mode 100644 index 000000000..66e94d58b --- /dev/null +++ b/hardhat/contracts/test/MockPayoutRegistry.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +contract MockPayoutRegistry { + address public payout; + + function setPayout(address payout_) external { + payout = payout_; + } + + function payoutAddress(bytes32) external view returns (address) { + return payout; + } +} diff --git a/hardhat/contracts/test/MockPrimaryMarket.sol b/hardhat/contracts/test/MockPrimaryMarket.sol index 49bc9d866..4971b0f6e 100644 --- a/hardhat/contracts/test/MockPrimaryMarket.sol +++ b/hardhat/contracts/test/MockPrimaryMarket.sol @@ -3,6 +3,15 @@ pragma solidity 0.8.33; contract MockPrimaryMarket { uint256 public price = 1; + bytes32 public beneficiaryId; + address public recipient; + address public proceedsRegistry; + + function setRoute(bytes32 beneficiaryId_, address recipient_, address registry_) external { + beneficiaryId = beneficiaryId_; + recipient = recipient_; + proceedsRegistry = registry_; + } function setPrice(uint256 price_) external { price = price_; diff --git a/hardhat/scripts/deploy-incremental.js b/hardhat/scripts/deploy-incremental.js index 81574bff4..2391b1fea 100644 --- a/hardhat/scripts/deploy-incremental.js +++ b/hardhat/scripts/deploy-incremental.js @@ -34,6 +34,7 @@ const ADDRESS_KEYS = { ProspectiveContentRoundFactory: ['PROSPECTIVE_CONTENT_ROUND_FACTORY_ADDRESS'], NudgePublications: ['NUDGE_PUBLICATIONS_CONTRACT_ADDRESS'], PublishedData: ['PUBLISHED_DATA_CONTRACT_ADDRESS'], + FixedControllerFactory: ['FIXED_CONTROLLER_FACTORY_ADDRESS'], ProjectFactory: ['PROJECT_FACTORY_ADDRESS'], SponsoredGasEntryPoint: ['SPONSORED_GAS_ENTRY_POINT_ADDRESS'], CreatorGasTank: ['CREATOR_GAS_TANK_ADDRESS'], @@ -335,7 +336,14 @@ async function main() { } await deployOrReuse('NudgePublications', 'NudgePublications'); await deployOrReuse('PublishedData', 'PublishedData'); - await deployOrReuse('ProjectFactory', 'ProjectFactory', [addresses.PremintingERC1155Factory, addresses.AssuranceContractFactory, addresses.ValueThresholdConditionFactory, addresses.BeneficiaryRegistry, addresses.BeneficiaryEscrow]); + await deployOrReuse('FixedControllerFactory', 'FixedControllerFactory'); + if (addresses.DelegatableNotes && addresses.FixedControllerFactory && (freshlyDeployed.has('DelegatableNotes') || freshlyDeployed.has('FixedControllerFactory'))) { + const d = await ownerCapable(await ethers.getContractAt('DelegatableNotes', addresses.DelegatableNotes)); + if (!(await d.authorizedPrimaryMarketFactories(addresses.FixedControllerFactory))) { + await (await d.setPrimaryMarketFactoryAuthorization(addresses.FixedControllerFactory, true)).wait(); + } + } + await deployOrReuse('ProjectFactory', 'ProjectFactory', [addresses.PremintingERC1155Factory, addresses.AssuranceContractFactory, addresses.ValueThresholdConditionFactory, addresses.BeneficiaryRegistry, addresses.BeneficiaryEscrow, addresses.FixedControllerFactory]); if (isLocal) { await deployOrReuse('SponsoredGasEntryPoint', 'MockEntryPoint'); @@ -467,6 +475,7 @@ async function main() { PAYMENT_TOKEN_ADDRESS: addresses.FreeERC20, PAYMENT_TOKEN_SYMBOL: 'USDZZZ', PAYMENT_TOKEN_DECIMALS: '6', + FIXED_CONTROLLER_FACTORY_ADDRESS: addresses.FixedControllerFactory, PROJECT_FACTORY_ADDRESS: addresses.ProjectFactory, DEPLOYER_ADDRESS: deployer.address, CONTRACT_ADMIN_ADDRESS: contractAdminAddress, diff --git a/hardhat/test/ConceptspaceAcceptanceJourneys.test.js b/hardhat/test/ConceptspaceAcceptanceJourneys.test.js index 05f985629..e2e69396c 100644 --- a/hardhat/test/ConceptspaceAcceptanceJourneys.test.js +++ b/hardhat/test/ConceptspaceAcceptanceJourneys.test.js @@ -98,6 +98,7 @@ describe("Conceptspace acceptance journeys", function () { const tokenFactory = await ethers.deployContract("PremintingERC1155Factory"); const assuranceFactory = await ethers.deployContract("AssuranceContractFactory"); + const fixedControllerFactory = await ethers.deployContract("FixedControllerFactory"); const conditionFactory = await ethers.deployContract("ValueThresholdConditionFactory"); const verifier = await ethers.deployContract("MockBeneficiaryVerifier"); const paymentToken = await ethers.deployContract("FreeERC20", ["USD Coin", "USDC", 6]); @@ -113,6 +114,7 @@ describe("Conceptspace acceptance journeys", function () { conditionFactory.target, beneficiaryRegistry.target, beneficiaryEscrow.target, + fixedControllerFactory.target, ]); const [, owner, recipient] = await ethers.getSigners(); diff --git a/hardhat/test/DelegatableNotes.spendClassification.test.js b/hardhat/test/DelegatableNotes.spendClassification.test.js new file mode 100644 index 000000000..abaef75a1 --- /dev/null +++ b/hardhat/test/DelegatableNotes.spendClassification.test.js @@ -0,0 +1,78 @@ +import { expect } from "chai"; +import hre from "hardhat"; + +const { ethers } = hre; + +describe("DelegatableNotes spend classification", function () { + let notes, alice, bob, market, registry; + const beneficiaryId = ethers.keccak256(ethers.toUtf8Bytes("dns:example.org")); + + beforeEach(async function () { + [alice, bob] = await ethers.getSigners(); + const factory = await ethers.deployContract("AssuranceContractFactory"); + notes = await ethers.deployContract("DelegatableNotes", [await factory.getAddress()]); + market = await ethers.deployContract("MockPrimaryMarket"); + registry = await ethers.deployContract("MockPayoutRegistry"); + await market.setPrice(ethers.parseEther("0.1")); + await registry.setPayout(bob.address); + await market.setRoute(beneficiaryId, bob.address, await registry.getAddress()); + + const token = await ethers.deployContract("PremintingERC20", [ + alice.address, "Token", "TKN", "https://example.com/t.json", + ]); + await token.connect(alice).mint(alice.address, ethers.parseEther("10")); + await token.connect(alice).approve(await notes.getAddress(), ethers.parseEther("1")); + const noteId = await notes.connect(alice).deposit.staticCall(await token.getAddress(), 0, 0, ethers.parseEther("0.1")); + await notes.connect(alice).deposit(await token.getAddress(), 0, 0, ethers.parseEther("0.1")); + await notes.connect(alice).delegateWithDelay(noteId, [alice.address], bob.address, ethers.parseEther("0.1"), 100); + }); + + const owners = () => [bob.address, alice.address]; + + it("shortens and restores a pending deadline from the original schedule time", async function () { + await notes.connect(bob).scheduleSpend(1, owners(), market.target, alice.address, 1, 1); + const first = await notes.pendingSpends(1); + expect(first.deadline).to.equal(first.scheduledAt + 100n); + expect((await notes.effectiveSpendDelay(1, market.target)).class).to.equal(0); + + await notes.connect(alice).setUnsuspiciousDelay(1, owners(), 40); + expect((await notes.pendingSpends(1)).deadline).to.equal(first.scheduledAt + 100n); + + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, true); + expect((await notes.pendingSpends(1)).deadline).to.equal(first.scheduledAt + 40n); + expect((await notes.effectiveSpendDelay(1, market.target)).class).to.equal(1); + + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, false); + expect((await notes.pendingSpends(1)).deadline).to.equal(first.scheduledAt + 100n); + expect((await notes.effectiveSpendDelay(1, market.target)).class).to.equal(0); + }); + + it("puts a spend back on the standing delay when the controller changes", async function () { + await notes.connect(alice).setUnsuspiciousDelay(1, owners(), 10); + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, true); + await notes.connect(bob).scheduleSpend(1, owners(), market.target, alice.address, 1, 1); + const first = await notes.pendingSpends(1); + expect(first.deadline).to.equal(first.scheduledAt + 10n); + + await registry.setPayout(alice.address); + await notes.connect(bob).executeScheduledSpend(1, owners()); + const revised = await notes.pendingSpends(1); + expect(revised.exists).to.equal(true); + expect(revised.deadline).to.equal(first.scheduledAt + 100n); + expect((await notes.effectiveSpendDelay(1, market.target)).class).to.equal(0); + }); + + it("does not let the unsuspicious delay exceed the standing delay", async function () { + await expect( + notes.connect(alice).setUnsuspiciousDelay(1, owners(), 101) + ).to.be.revertedWithCustomError(notes, "UnsuspiciousDelayExceedsStanding"); + }); + + it("does not treat a claim-later route as unsuspicious", async function () { + await market.setRoute(beneficiaryId, await market.getAddress(), await registry.getAddress()); + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, true); + const [delay, spendClass] = await notes.effectiveSpendDelay(1, market.target); + expect(delay).to.equal(100n); + expect(spendClass).to.equal(0); + }); +}); diff --git a/hardhat/test/DelegatableNotes.waitingPeriod.test.js b/hardhat/test/DelegatableNotes.waitingPeriod.test.js index b7d9a02e7..cb25770aa 100644 --- a/hardhat/test/DelegatableNotes.waitingPeriod.test.js +++ b/hardhat/test/DelegatableNotes.waitingPeriod.test.js @@ -195,4 +195,17 @@ describe("DelegatableNotes waiting period", function () { expect((await notes.pendingSpends(noteId)).deadline).to.equal(deadline); expect((await notes.spendPolicies(noteId)).delay).to.equal(10); }); + + it("does not apply the delay when the donor spends the note herself", async function () { + const noteId = await delegateAmount(ethers.parseEther("0.1"), 3600); + await notes.connect(alice).revoke(noteId, [bob.address, alice.address]); + await notes.connect(alice).purchaseFromPrimaryMarket( + [{ noteId, chain: [alice.address], shares: 1 }], + assuranceContract.target, + erc1155Token.target, + 1, + 1 + ); + expect((await notes.notes(noteId)).chainHash).to.equal(ethers.ZeroHash); + }); }); diff --git a/hardhat/test/ProjectFactory.test.js b/hardhat/test/ProjectFactory.test.js index 43ba21b7a..93069d36c 100644 --- a/hardhat/test/ProjectFactory.test.js +++ b/hardhat/test/ProjectFactory.test.js @@ -6,6 +6,7 @@ const { ethers } = hardhat; async function deployProjectFactory() { const tokenFactory = await ethers.deployContract('PremintingERC1155Factory'); const assuranceFactory = await ethers.deployContract('AssuranceContractFactory'); + const fixedControllerFactory = await ethers.deployContract('FixedControllerFactory'); const conditionFactory = await ethers.deployContract('ValueThresholdConditionFactory'); const verifier = await ethers.deployContract('MockBeneficiaryVerifier'); const beneficiaryIdentity = await ethers.deployContract('BeneficiaryIdentity', [verifier.target]); @@ -18,6 +19,7 @@ async function deployProjectFactory() { conditionFactory.target, beneficiaryRegistry.target, beneficiaryEscrow.target, + fixedControllerFactory.target, ]); return { projectFactory, assuranceFactory, conditionFactory, beneficiaryRegistry, beneficiaryEscrow, verifier, beneficiaryPaymentToken: paymentToken }; } @@ -105,7 +107,7 @@ describe('ProjectFactory', function () { const dependency = await ethers.deployContract('PremintingERC1155Factory'); await expect( - factory.deploy(ethers.ZeroAddress, dependency.target, dependency.target, dependency.target, dependency.target), + factory.deploy(ethers.ZeroAddress, dependency.target, dependency.target, dependency.target, dependency.target, dependency.target), ).to.be.revertedWithCustomError(factory, 'InvalidFactoryAddress'); }); @@ -172,9 +174,16 @@ describe('ProjectFactory', function () { ); const args = defaultProjectParams(owner.address, ethers.ZeroAddress, paymentToken.target, deadline); - await expect(projectFactory.connect(creator).createERC1155AndAssuranceContractForBeneficiary( + const tx = await projectFactory.connect(creator).createERC1155AndAssuranceContractForBeneficiary( args[0], args[1], args[2], beneficiaryId, ...args.slice(4), - )).to.emit(projectFactory, 'ProjectCreated'); + ); + await expect(tx).to.emit(projectFactory, 'ProjectCreated'); + const receipt = await tx.wait(); + const event = receipt.logs.map(log => { try { return projectFactory.interface.parseLog(log); } catch { return null; } }) + .find(log => log?.name === 'ProjectCreated'); + const fixed = await ethers.getContractAt('FixedControllerAssuranceContract', event.args.assuranceContract); + expect(await fixed.beneficiaryId()).to.equal(beneficiaryId); + expect(await fixed.recipient()).to.equal(owner.address); }); it('blocks third-party creation once the beneficiary has taken control', async function () { diff --git a/hardhat/test/RecurringPledges.test.js b/hardhat/test/RecurringPledges.test.js index a05cf2e84..92b869bc3 100644 --- a/hardhat/test/RecurringPledges.test.js +++ b/hardhat/test/RecurringPledges.test.js @@ -138,7 +138,9 @@ describe("RecurringPledges", function () { 10_000n, bob.address, 0, + 0, false, + [], [] )).to.be.revertedWithCustomError(notes, "UnauthorizedRecurringPledgeRegistry"); }); diff --git a/indexer/abis/DelegatableNotesAbi.ts b/indexer/abis/DelegatableNotesAbi.ts index 1b3d115c8..9a479b5a9 100644 --- a/indexer/abis/DelegatableNotesAbi.ts +++ b/indexer/abis/DelegatableNotesAbi.ts @@ -262,6 +262,11 @@ export const DelegatableNotesAbi = [ "name": "UnauthorizedRecurringPledgeRegistry", "type": "error" }, + { + "inputs": [], + "name": "UnsuspiciousDelayExceedsStanding", + "type": "error" + }, { "inputs": [], "name": "WrongPrimaryMarket", @@ -352,6 +357,31 @@ export const DelegatableNotesAbi = [ "name": "ERC1155Purchased", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "FineListSet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -743,6 +773,56 @@ export const DelegatableNotesAbi = [ "name": "SpendCancelled", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "class", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + } + ], + "name": "SpendClassResolved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "SpendDeadlineRevised", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -948,6 +1028,51 @@ export const DelegatableNotesAbi = [ "name": "StrictModeSet", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "delay", + "type": "uint256" + } + ], + "name": "UnsuspiciousDelaySet", + "type": "event" + }, + { + "inputs": [], + "name": "CLASS_UNMARKED", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "CLASS_UNSUSPICIOUS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "MAX_DELEGATION_DEPTH", @@ -1072,6 +1197,11 @@ export const DelegatableNotesAbi = [ "name": "spendDelay", "type": "uint256" }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + }, { "internalType": "bool", "name": "strictMode", @@ -1081,6 +1211,11 @@ export const DelegatableNotesAbi = [ "internalType": "address[]", "name": "flaggers", "type": "address[]" + }, + { + "internalType": "bytes32[]", + "name": "fineIds", + "type": "bytes32[]" } ], "name": "createDelegatedNoteFor", @@ -1211,6 +1346,35 @@ export const DelegatableNotesAbi = [ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "primaryMarket", + "type": "address" + } + ], + "name": "effectiveSpendDelay", + "outputs": [ + { + "internalType": "uint256", + "name": "delay", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "class", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -1229,6 +1393,49 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + } + ], + "name": "fineList", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "fineListed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -1476,6 +1683,11 @@ export const DelegatableNotesAbi = [ "name": "count", "type": "uint256" }, + { + "internalType": "uint256", + "name": "scheduledAt", + "type": "uint256" + }, { "internalType": "uint256", "name": "deadline", @@ -1812,6 +2024,34 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "setFineListed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1917,6 +2157,29 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "delay", + "type": "uint256" + } + ], + "name": "setUnsuspiciousDelay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1951,6 +2214,11 @@ export const DelegatableNotesAbi = [ "name": "delay", "type": "uint256" }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + }, { "internalType": "bool", "name": "strictMode", diff --git a/indexer/abis/ProjectFactoryAbi.ts b/indexer/abis/ProjectFactoryAbi.ts index bb33de22b..3662ccb77 100644 --- a/indexer/abis/ProjectFactoryAbi.ts +++ b/indexer/abis/ProjectFactoryAbi.ts @@ -28,6 +28,11 @@ export const ProjectFactoryAbi = [ "internalType": "address", "name": "_beneficiaryEscrow", "type": "address" + }, + { + "internalType": "address", + "name": "_fixedControllerFactory", + "type": "address" } ], "stateMutability": "nonpayable", @@ -408,5 +413,18 @@ export const ProjectFactoryAbi = [ ], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [], + "name": "fixedControllerFactory", + "outputs": [ + { + "internalType": "contract FixedControllerFactory", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" } ] as const; diff --git a/indexer/abis/RecurringPledgesAbi.ts b/indexer/abis/RecurringPledgesAbi.ts index e0269ca40..1451df640 100644 --- a/indexer/abis/RecurringPledgesAbi.ts +++ b/indexer/abis/RecurringPledgesAbi.ts @@ -53,11 +53,41 @@ export const RecurringPledgesAbi = [ "name": "SelfDelegationNotAllowed", "type": "error" }, + { + "inputs": [], + "name": "UnsuspiciousDelayExceedsStanding", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "PledgeFineListSet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -83,6 +113,25 @@ export const RecurringPledgesAbi = [ "name": "PledgeSpendPolicyUpdated", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + } + ], + "name": "PledgeUnsuspiciousDelaySet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -349,6 +398,30 @@ export const RecurringPledgesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "pledgeFineListed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -428,6 +501,11 @@ export const RecurringPledgesAbi = [ "name": "spendDelay", "type": "uint256" }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + }, { "internalType": "bool", "name": "strictMode", @@ -437,6 +515,47 @@ export const RecurringPledgesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "setPledgeFineListed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + } + ], + "name": "setPledgeUnsuspiciousDelay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/indexer/src/events-cache/index.ts b/indexer/src/events-cache/index.ts index 03615b8fd..29822f41b 100644 --- a/indexer/src/events-cache/index.ts +++ b/indexer/src/events-cache/index.ts @@ -78,6 +78,12 @@ register("DelegatableNotes:ERC1155Purchased"); register("DelegatableNotes:RefundedIntoNote"); register("DelegatableNotes:ReimbursementClaimedIntoNote"); register("DelegatableNotes:SpendDelaySet"); +register("DelegatableNotes:UnsuspiciousDelaySet"); +register("DelegatableNotes:FineListSet"); +register("DelegatableNotes:SpendClassResolved"); +register("DelegatableNotes:SpendDeadlineRevised"); +register("RecurringPledges:PledgeUnsuspiciousDelaySet"); +register("RecurringPledges:PledgeFineListSet"); register("DelegatableNotes:StrictModeSet"); register("DelegatableNotes:SpendFlaggerSet"); register("DelegatableNotes:NoteSplitSameChain"); diff --git a/scripts/deployment-manifest.mjs b/scripts/deployment-manifest.mjs index 7deb50712..bc33b4f90 100644 --- a/scripts/deployment-manifest.mjs +++ b/scripts/deployment-manifest.mjs @@ -29,6 +29,7 @@ export const FUNDING_LOGICAL_CONTRACTS = [ ['DelegatableNotes', 'DELEGATABLE_NOTES_ADDRESS', 'DELEGATABLE_NOTES_START_BLOCK', 'DELEGATION_START_BLOCK'], ['RecurringPledges', 'RECURRING_PLEDGES_ADDRESS', 'RECURRING_PLEDGES_START_BLOCK', 'DELEGATION_START_BLOCK'], ['AssuranceContractFactory', 'ASSURANCE_CONTRACT_FACTORY_ADDRESS', 'ASSURANCE_CONTRACT_FACTORY_START_BLOCK', 'LAZYGIVING_START_BLOCK'], + ['FixedControllerFactory', 'FIXED_CONTROLLER_FACTORY_ADDRESS', 'FIXED_CONTROLLER_FACTORY_START_BLOCK', 'LAZYGIVING_START_BLOCK'], ['ProjectFactory', 'PROJECT_FACTORY_ADDRESS', 'PROJECT_FACTORY_START_BLOCK', 'LAZYGIVING_START_BLOCK'], ['ERC1155Factory', 'ERC1155_FACTORY_ADDRESS', 'ERC1155_FACTORY_START_BLOCK', 'LAZYGIVING_START_BLOCK'], ['ContentRegistry', 'CONTENT_REGISTRY_ADDRESS', 'CONTENT_REGISTRY_START_BLOCK', 'CONTENT_FUNDING_START_BLOCK'], diff --git a/sdk/abis/DelegatableNotesAbi.ts b/sdk/abis/DelegatableNotesAbi.ts index 1b3d115c8..9a479b5a9 100644 --- a/sdk/abis/DelegatableNotesAbi.ts +++ b/sdk/abis/DelegatableNotesAbi.ts @@ -262,6 +262,11 @@ export const DelegatableNotesAbi = [ "name": "UnauthorizedRecurringPledgeRegistry", "type": "error" }, + { + "inputs": [], + "name": "UnsuspiciousDelayExceedsStanding", + "type": "error" + }, { "inputs": [], "name": "WrongPrimaryMarket", @@ -352,6 +357,31 @@ export const DelegatableNotesAbi = [ "name": "ERC1155Purchased", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "FineListSet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -743,6 +773,56 @@ export const DelegatableNotesAbi = [ "name": "SpendCancelled", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "class", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + } + ], + "name": "SpendClassResolved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "SpendDeadlineRevised", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -948,6 +1028,51 @@ export const DelegatableNotesAbi = [ "name": "StrictModeSet", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "delay", + "type": "uint256" + } + ], + "name": "UnsuspiciousDelaySet", + "type": "event" + }, + { + "inputs": [], + "name": "CLASS_UNMARKED", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "CLASS_UNSUSPICIOUS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "MAX_DELEGATION_DEPTH", @@ -1072,6 +1197,11 @@ export const DelegatableNotesAbi = [ "name": "spendDelay", "type": "uint256" }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + }, { "internalType": "bool", "name": "strictMode", @@ -1081,6 +1211,11 @@ export const DelegatableNotesAbi = [ "internalType": "address[]", "name": "flaggers", "type": "address[]" + }, + { + "internalType": "bytes32[]", + "name": "fineIds", + "type": "bytes32[]" } ], "name": "createDelegatedNoteFor", @@ -1211,6 +1346,35 @@ export const DelegatableNotesAbi = [ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "primaryMarket", + "type": "address" + } + ], + "name": "effectiveSpendDelay", + "outputs": [ + { + "internalType": "uint256", + "name": "delay", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "class", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -1229,6 +1393,49 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + } + ], + "name": "fineList", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "fineListed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -1476,6 +1683,11 @@ export const DelegatableNotesAbi = [ "name": "count", "type": "uint256" }, + { + "internalType": "uint256", + "name": "scheduledAt", + "type": "uint256" + }, { "internalType": "uint256", "name": "deadline", @@ -1812,6 +2024,34 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "setFineListed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1917,6 +2157,29 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "delay", + "type": "uint256" + } + ], + "name": "setUnsuspiciousDelay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1951,6 +2214,11 @@ export const DelegatableNotesAbi = [ "name": "delay", "type": "uint256" }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + }, { "internalType": "bool", "name": "strictMode", diff --git a/sdk/abis/ProjectFactoryAbi.ts b/sdk/abis/ProjectFactoryAbi.ts index bb33de22b..3662ccb77 100644 --- a/sdk/abis/ProjectFactoryAbi.ts +++ b/sdk/abis/ProjectFactoryAbi.ts @@ -28,6 +28,11 @@ export const ProjectFactoryAbi = [ "internalType": "address", "name": "_beneficiaryEscrow", "type": "address" + }, + { + "internalType": "address", + "name": "_fixedControllerFactory", + "type": "address" } ], "stateMutability": "nonpayable", @@ -408,5 +413,18 @@ export const ProjectFactoryAbi = [ ], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [], + "name": "fixedControllerFactory", + "outputs": [ + { + "internalType": "contract FixedControllerFactory", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" } ] as const; diff --git a/sdk/abis/RecurringPledgesAbi.ts b/sdk/abis/RecurringPledgesAbi.ts index e0269ca40..1451df640 100644 --- a/sdk/abis/RecurringPledgesAbi.ts +++ b/sdk/abis/RecurringPledgesAbi.ts @@ -53,11 +53,41 @@ export const RecurringPledgesAbi = [ "name": "SelfDelegationNotAllowed", "type": "error" }, + { + "inputs": [], + "name": "UnsuspiciousDelayExceedsStanding", + "type": "error" + }, { "inputs": [], "name": "ZeroAddress", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "PledgeFineListSet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -83,6 +113,25 @@ export const RecurringPledgesAbi = [ "name": "PledgeSpendPolicyUpdated", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + } + ], + "name": "PledgeUnsuspiciousDelaySet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -349,6 +398,30 @@ export const RecurringPledgesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "pledgeFineListed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -428,6 +501,11 @@ export const RecurringPledgesAbi = [ "name": "spendDelay", "type": "uint256" }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + }, { "internalType": "bool", "name": "strictMode", @@ -437,6 +515,47 @@ export const RecurringPledgesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "setPledgeFineListed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "unsuspiciousDelay", + "type": "uint256" + } + ], + "name": "setPledgeUnsuspiciousDelay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/sdk/src/subsystems/delegation/actions.ts b/sdk/src/subsystems/delegation/actions.ts index ab33338f0..937de6c7b 100644 --- a/sdk/src/subsystems/delegation/actions.ts +++ b/sdk/src/subsystems/delegation/actions.ts @@ -454,3 +454,33 @@ export async function claimNoteReimbursement( return extractCreatedNoteId(clients, hash); } + +export async function setUnsuspiciousDelay( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { noteId: bigint; owners: Address[]; delay: bigint }, +): Promise { + return clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'setUnsuspiciousDelay', + args: [params.noteId, params.owners, params.delay], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); +} + +export async function setFineListed( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { noteId: bigint; owners: Address[]; beneficiaryId: `0x${string}`; allowed: boolean }, +): Promise { + return clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'setFineListed', + args: [params.noteId, params.owners, params.beneficiaryId, params.allowed], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); +} diff --git a/specs/decisions/0017-spend-classification.md b/specs/decisions/0017-spend-classification.md new file mode 100644 index 000000000..017f77254 --- /dev/null +++ b/specs/decisions/0017-spend-classification.md @@ -0,0 +1,41 @@ +# 0017. Delegated spends are classified, and only a current controller is unsuspicious + +- **Status:** Accepted +- **Date:** 2026-09-26 +- **Related specs:** [`specs/tech/subsystems/delegation/spend-classification.md`](../tech/subsystems/delegation/spend-classification.md) + +## Context + +A donor delegates because she does not want to approve each project. The delegate will sometimes spend in ways she would not have. There is no rule that prevents that without undoing the delegation. The useful version is a small set of criteria that mark a spend as probably fine or extra-suspicious, so she is not asked to look at the ordinary ones. + +Mechanical tests were already rejected. A new project is not suspicious. "The payout address is not the delegate" and "this address has been paid before" are easy to fake with extra wallets. A vouch for payout addresses was deferred, along with any new attestation machinery. Beneficiary identity was the evidence already on hand. + +That evidence answers one question: does this route pay the wallet that currently controls a public name she already accepts? It does not answer whether the work is worth funding, or whether the delegate is independent of the recipient. He can verify a domain he controls. Domain control is a poor blacklist and a usable whitelist. + +## Decision + +Criteria return only unsuspicious or suspicious. Effects are per class, not per criterion. Suspicious wins. Silence is unmarked and keeps her standing delay `T`, with no extra notification. + +The only criterion is her fine list of `beneficiaryId`s. It matches only when the project records that id on-chain and its immutable recipient is still the registry's current payout for that id. Claim-later proceeds and a stale fixed recipient stay unmarked, not suspicious. The list starts empty. The unsuspicious delay `U` defaults to `0` and cannot exceed `T`. + +No suspicious criterion ships. Enabling one later requires her to choose, with no default, either a longer on-chain delay plus an off-chain warning, or an on-chain block. A warning alone is not a treatment. + +The rules bind the delegate's own spends. Her spend of a note she holds is not classified. List and delay edits apply to spends not yet executed, recomputed from the original schedule time. `U = 0` does not create a cancellable pending spend. She can still see it, labeled, in history. Pending rows are labeled too, and a suspicious one raises a site banner whether or not she opted into push or email. + +## Alternatives considered + +- **Treat "verified domain" or "new wallet" as suspicious.** Rejected. A dishonest delegate verifies his own domain. An honest project is often new. Both heuristics punish the wrong spends and train her to ignore warnings. +- **Let claim-later escrow for a listed name count as unsuspicious.** Rejected. The short wait is for a payment to the current controller. Escrow, including `IdentityHeldProceeds`, pays whoever controls the name at claim time, which may be someone else. Reserved-for-this-name stays visible. It does not skip her delay. +- **Match on project metadata or on the registry wallet in place of the route.** Rejected. Metadata is not what the contract pays. Showing the live registry wallet hides a stale locked-in recipient. +- **Give each criterion its own delay or block.** Rejected. She would be configuring a matrix. A later criterion, such as an ecosystem vouch, should reuse the same class treatments. +- **Default the unsuspicious delay to `T` until she picks a number.** Rejected. The list starts empty, so a default of `0` changes nothing until she adds a name, and adding a name should not be a second chore. +- **Warn on every unmarked spend.** Rejected. That is alarm fatigue. Unmarked stays in the pending list with no notification. Only suspicious warns, and only suspicious raises the banner. +- **Keep an in-flight deadline frozen when the class changes.** Rejected for classification, not for `setSpendDelay`. Removing a name has to be able to put a spend back on `T`. Adding one has to be able to make `U` apply, including immediately when `U` is `0`. The clock starts at the original schedule time either way. + +## Consequences + +Website projects created through `createERC1155AndAssuranceContractForBeneficiary` do not match this criterion. They claim later. The fine list does nothing for them until a route fixes the recipient and stores the `beneficiaryId` on-chain. Do not paper over that with metadata. + +A block cannot reuse `approveScheduledSpend`, because a blocked spend has no schedule. One-payment approval of a block waits on the exact-payment override. Delayed spends can already be approved early. + +Revisit this if claim-time payout and the controller at spend time need to be the same fact, or if a suspicious criterion exists that is not satisfied by the delegate verifying a name he controls. diff --git a/specs/decisions/README.md b/specs/decisions/README.md index bb53e0165..c717b47eb 100644 --- a/specs/decisions/README.md +++ b/specs/decisions/README.md @@ -64,3 +64,4 @@ instance most needs answered and can't get anywhere else. | [0014](./0014-no-operated-generic-explorer.md) | Commonality does not run a generic explorer | Accepted | | [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 | diff --git a/specs/glossary.md b/specs/glossary.md index 9a64d9745..040f86015 100644 --- a/specs/glossary.md +++ b/specs/glossary.md @@ -40,6 +40,8 @@ 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 | +| **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) | | **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 8e12d204b..bbd5f7072 100644 --- a/specs/product/legal/delegation-narrowing.md +++ b/specs/product/legal/delegation-narrowing.md @@ -75,13 +75,15 @@ 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. 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. Offer donor-approved identities where useful, without requiring every donor to preselect recipients: broad project discovery can deliberately remain the delegate's job. +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. **Specific donor overrides**, without accidentally changing standing rules. Rules govern what the delegate can do without asking; the donor can approve an exception for an exact payment. Keep monthly deposits as they are. Defer additional spending caps, compulsory renewal, separate watchers with cancellation authority, and new payout-attestation machinery. Optional spending summaries can prompt review without expiring authority; respect notification preferences. ### Beneficiary evidence and recipient choice +Settled in [ADR 0017](/specs/decisions/0017-spend-classification.md). The notes below are the reasoning that led there. + “Has a verified domain” is not a sufficient safety filter: a dishonest delegate can verify their own domain. An optional rule restricting independent spending to identities the donor has approved is stronger, but costs the donor some of the discovery benefit of delegation. Identity verification connects an identity to an authorized payout; it does not establish that the work is worthwhile or aligned with the donor's intentions. Do not require every beneficiary to claim before receiving contributions. Preserve fund-now-claim-later, while distinguishing “destination reserved for this identity” from “controller has verified and adopted a payout wallet.” Neither implies endorsement of a third-party project. Stronger proof mechanisms can improve verification later without inventing a new product concept now. diff --git a/specs/tech/subsystems/delegation/README.md b/specs/tech/subsystems/delegation/README.md index 5690e96dd..1787ca5d8 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). +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). --- diff --git a/specs/tech/subsystems/delegation/spend-classification.md b/specs/tech/subsystems/delegation/spend-classification.md new file mode 100644 index 000000000..d6c309d33 --- /dev/null +++ b/specs/tech/subsystems/delegation/spend-classification.md @@ -0,0 +1,71 @@ +# 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). + +This file is the proposal the beneficiary-identity item in [TODO.md](/TODO.md) asked for. It does not change contracts. The donor-set delay itself is [waiting-period.md](./waiting-period.md). A one-payment exception to a block is the separate exact-payment override item, not this one. + +## Classes + +A criterion returns only **unsuspicious** or **suspicious**. It does not name a delay or a block. She sets those once, per class. + +| Class | When | +|---|---| +| Suspicious | Any enabled criterion returns suspicious | +| Unsuspicious | At least one returns unsuspicious, and none returns suspicious | +| Unmarked | Every criterion is silent | + +Suspicious wins. Unmarked is the ordinary case, not a third setting. + +The rules apply only when the delegate spends on his own. When the donor holds the note and submits the spend herself, neither the class nor `T` delays or blocks it. + +## What she stores + +On the note, beside the standing delay `T` from [waiting-period.md](./waiting-period.md): + +- A **fine list** of `beneficiaryId`s. It starts empty. +- `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. + +An empty fine list never matches. That is not a separate "off" switch. + +## The one criterion + +A delegate spend is unsuspicious only when all of these are true: + +1. The note's fine list contains a `beneficiaryId`. +2. The project records that same id **on-chain**, not in its metadata. +3. The project's assurance contract has an immutable recipient, and that recipient is still `payoutAddress(beneficiaryId)` on the beneficiary registry. + +Escrow does not match. Neither does a project whose proceeds stay in the contract and are claimed later by whoever the registry names at claim time (`IdentityHeldProceeds` / `createERC1155AndAssuranceContractForBeneficiary`). A recipient fixed at creation that is no longer the current payout does not match. Both stay unmarked. They are not suspicious. A new domain, a new wallet, or a delegate who proved control of his own domain is not a suspicious criterion. + +A third-party project matches if, and only if, it meets the three conditions above. The class does not mean the beneficiary endorses the project. Disavowal does not change it. + +A verified identity is created as `FixedControllerAssuranceContract`: the recipient is the registry payout at creation, and `beneficiaryId` is stored on the contract. An identity that is not verified yet stays on `BeneficiaryAssuranceContract`, whose recipient is the contract itself until claim. That route does not match. Do not invent a match by reading metadata, and do not add payout-attestation machinery. + +The pending spend shows that on-chain route. It does not substitute the registry's current wallet when the project pays something else. + +## Treatments + +| Class | Delegate spend | +|---|---| +| Unmarked | Wait `T`. No notification. | +| Unsuspicious | Wait `U`. No notification. | +| Suspicious | Wait `S` and warn, or block, as she chose when she enabled the criterion | + +`U = 0` spends in the delegate's transaction and stores no pending row, same as `T = 0` on an unmarked spend. It is labeled unsuspicious in that authorization's history. + +A suspicious warning is off-chain and follows her existing notification opt-in. The longer wait and the block are on-chain. Her exact-payment approval can let that one spend through without changing the list, `U`, `S`, or the block. [waiting-period.md](./waiting-period.md) already lets her approve a scheduled spend early. A block has no schedule. The override item has to authorize that one payment before a blocked spend can be forced through. Until then, block means the delegate's spend reverts. + +## Deadlines already running + +`setSpendDelay` does not move a deadline already given. That rule stays in [waiting-period.md](./waiting-period.md). + +Classification is different. The schedule stores the time it was created. While it has not executed, the contract reclassifies it when the fine list, `U`, the project's route, or the registry payout changes the answer. The deadline becomes `scheduledAt +` the delay for the class it is in now. A deadline already in the past may be executed. Losing a match restores `T` from that same `scheduledAt`, which can put the spend back into the waiting period. The clock does not restart at the edit. + +## What she sees + +One section 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. + +Immediate unsuspicious spends are not in that pending section. They are in the authorization's history, with the same label. diff --git a/specs/tech/subsystems/delegation/waiting-period.md b/specs/tech/subsystems/delegation/waiting-period.md index fa7bbd4dd..585a9d74c 100644 --- a/specs/tech/subsystems/delegation/waiting-period.md +++ b/specs/tech/subsystems/delegation/waiting-period.md @@ -6,7 +6,7 @@ The delay is her control over one delegate's spends. It is not a second escrow, ## Where the delay lives -`spendPolicies[noteId]` stores `delay` (seconds) and `strictMode`. A note with no policy has delay zero and strict mode off. She can change either in place. A spend already scheduled keeps the deadline it was given (`block.timestamp + delay` at schedule time). Setting the delay to zero does not make that in-flight spend immediate. She approves it if she wants it paid now. +`spendPolicies[noteId]` stores `delay` (seconds) and `strictMode`. A note with no policy has delay zero and strict mode off. She can change either in place. A spend already scheduled keeps the deadline it was given (`block.timestamp + delay` at schedule time). Setting the delay to zero does not make that in-flight spend immediate. She approves it if she wants it paid now. A change of spend class is not this rule: that deadline is recomputed from the original schedule time, as [spend-classification.md](./spend-classification.md) describes. `delegate` still creates a note with delay zero. `delegateWithDelay` is the same delegation with a delay. `setSpendDelay` and `setStrictMode` are root-only on that note. diff --git a/ui/src/delegation/components/FineListPanel.test.ts b/ui/src/delegation/components/FineListPanel.test.ts new file mode 100644 index 000000000..104de529b --- /dev/null +++ b/ui/src/delegation/components/FineListPanel.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { spendClassLabel } from './FineListPanel' + +describe('spendClassLabel', () => { + it('names the two classes the contract can return', () => { + expect(spendClassLabel(1)).toBe('Unsuspicious') + expect(spendClassLabel(0)).toBe('Unmarked') + }) +}) \ No newline at end of file diff --git a/ui/src/delegation/components/FineListPanel.tsx b/ui/src/delegation/components/FineListPanel.tsx new file mode 100644 index 000000000..bd0c1f05a --- /dev/null +++ b/ui/src/delegation/components/FineListPanel.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react' +import { Alert, Box, Button, Chip, Paper, Stack, TextField, Typography } from '@mui/material' +import { usePublicClient } from 'wagmi' +import type { Address } from 'viem' +import { DelegatableNotesAbi } from '@commonality/sdk/abis' +import { hashBeneficiaryId, normalizeDnsBeneficiary } from '@commonality/sdk/content-funding' +import { setFineListed } from '@commonality/sdk/delegation' +import { useWriteClients } from '../../shared' + +export function spendClassLabel(spendClass: number): 'Unsuspicious' | 'Unmarked' { + return spendClass === 1 ? 'Unsuspicious' : 'Unmarked' +} + +export function FineListPanel({ + noteId, + contractAddress, + owners, +}: { + noteId: bigint + contractAddress: Address + owners: Address[] +}) { + const publicClient = usePublicClient() + const clients = useWriteClients() + const [domain, setDomain] = useState('') + const [names, setNames] = useState([]) + const [pendingLabel, setPendingLabel] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (!publicClient) return + let cancelled = false + const contract = { address: contractAddress, abi: DelegatableNotesAbi } as const + ;(async () => { + const listed = await publicClient.readContract({ + ...contract, + functionName: 'fineList', + args: [noteId], + }) as `0x${string}`[] + const pending = await publicClient.readContract({ + ...contract, + functionName: 'pendingSpends', + args: [noteId], + }) as readonly [Address, Address, bigint, bigint, bigint, bigint, bigint, boolean, boolean] + let label: string | null = null + if (pending[8]) { + const classified = await publicClient.readContract({ + ...contract, + functionName: 'effectiveSpendDelay', + args: [noteId, pending[0]], + }) as readonly [bigint, number] + label = spendClassLabel(Number(classified[1])) + } + if (!cancelled) { + setNames(listed) + setPendingLabel(label) + } + })().catch(() => { + if (!cancelled) { + setNames([]) + setPendingLabel(null) + } + }) + return () => { cancelled = true } + }, [publicClient, contractAddress, noteId, busy]) + + async function addName() { + if (!clients) return + setBusy(true) + setError(null) + try { + const canonical = normalizeDnsBeneficiary(domain) + await setFineListed(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { + noteId, + owners, + beneficiaryId: hashBeneficiaryId('dns', canonical), + allowed: true, + }) + setDomain('') + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not add that name') + } finally { + setBusy(false) + } + } + + return ( + + Fine list + + 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. + + {pendingLabel && ( + + )} + + {names.length === 0 && No names yet.} + {names.map((id) => )} + + + setDomain(event.target.value)} placeholder="example.org" /> + + + {error && {error}} + + ) +} diff --git a/ui/src/delegation/pages/NoteDetailPage.tsx b/ui/src/delegation/pages/NoteDetailPage.tsx index 00ab2d795..3eeceb96b 100644 --- a/ui/src/delegation/pages/NoteDetailPage.tsx +++ b/ui/src/delegation/pages/NoteDetailPage.tsx @@ -30,6 +30,7 @@ import { getProjectsFiltered, type ProjectWithMetrics, getProjectTokens, type Pr import { StatementPicker, useMachinery } from '../../shared' import { useWriteClients } from '../../shared' import { formatNoteAmount, isDelegate, truncateAddress, isEthNote, parseNoteRouteId, noteDetailPathFor } from '../utils' +import { FineListPanel } from '../components/FineListPanel' function getContract(address?: string) { const addr = address ?? import.meta.env.VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS @@ -689,6 +690,14 @@ export function NoteDetailPage() { )} + {isRootOwner && ( + b.position - a.position).map((link) => link.address as `0x${string}`)} + /> + )} + From 526da329563280eaf6a11a2fc6ae6c1c34919a5c Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sat, 26 Sep 2026 17:17:09 -0400 Subject: [PATCH 2/5] Show pending delegated spends and the donor's spend policy in the UI. Note and project screens treat a scheduled spend as cancellable money, not raised funds. Donors can set the delay, strict mode, and flaggers, and a banner points at suspicious spends that are still pending. --- TODO.md | 2 +- sdk/src/subsystems/delegation/actions.ts | 75 +++++++++ .../delegation/spend-classification.md | 2 +- .../subsystems/delegation/waiting-period.md | 2 +- ui/src/commonality/shell/CauseShell.tsx | 2 + .../shell/SuspiciousSpendBanner.tsx | 59 +++++++ .../components/DonorPendingSpends.tsx | 95 ++++++++++++ .../components/FineListPanel.test.ts | 2 +- .../delegation/components/FineListPanel.tsx | 5 +- .../components/PendingSpendCard.tsx | 118 ++++++++++++++ .../components/SpendPolicyPanel.tsx | 145 ++++++++++++++++++ ui/src/delegation/pages/MyNotesPage.tsx | 41 ++++- ui/src/delegation/pages/NoteDetailPage.tsx | 28 +++- ui/src/delegation/spendClass.test.ts | 26 ++++ ui/src/delegation/spendClass.ts | 31 ++++ .../components/PendingProjectSpends.tsx | 80 ++++++++++ .../lazy-giving/pages/ProjectDetailPage.tsx | 2 + 17 files changed, 698 insertions(+), 17 deletions(-) create mode 100644 ui/src/commonality/shell/SuspiciousSpendBanner.tsx create mode 100644 ui/src/delegation/components/DonorPendingSpends.tsx create mode 100644 ui/src/delegation/components/PendingSpendCard.tsx create mode 100644 ui/src/delegation/components/SpendPolicyPanel.tsx create mode 100644 ui/src/delegation/spendClass.test.ts create mode 100644 ui/src/delegation/spendClass.ts create mode 100644 ui/src/lazy-giving/components/PendingProjectSpends.tsx diff --git a/TODO.md b/TODO.md index e78ced9e0..d7efc49f1 100644 --- a/TODO.md +++ b/TODO.md @@ -20,7 +20,7 @@ Getting **testnet to a two-person shared lab** is also a standing plan, not a pi ---- -- **(Tell)** Wire the donor-set waiting period into the product UI. The rules are in [waiting-period.md](specs/tech/subsystems/delegation/waiting-period.md), and the note contracts, same-chain split fold, and one-page-per-schedule notifier rule are in place. Still open: note and project screens that show a pending spend's amount and deadline as money that can still be cancelled (not as raised), donor controls for the delay, strict mode, and flaggers, and a host loop that actually sends the opted-in email or push. Public remarks from people who are not flaggers stay a later UI feature and are not stored on-chain. +- **(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). - One voice for delegation copy. The donor is authorizing an address to spend a stated amount on projects in Commonality. The delegate promises nothing. A stated intent is public and does not bind the spend. Unspent funds stay revocable by the donor. Commonality does not hold the funds, choose the delegate, or supervise the spending. Remove the steward voice: entrusting money to a scout, program-officer framing, "money under management," and any Commonality ranking whose job is to send people to a delegate. A public history of what an address already funded can stay. "Scout" as the early contributor who may later be reimbursed at cost can stay; do not let that word mean a manager of other people's money. Start with `specs/product/legal/retroactive-funding-redesign.md` (Design 2) and `docs/end-user/lazyGiving/` (`retroactive-funding.md`, `index.md`, `fund-something.md`, `get-your-project-funded.md`). No delegate marketplace. diff --git a/sdk/src/subsystems/delegation/actions.ts b/sdk/src/subsystems/delegation/actions.ts index 937de6c7b..3f29d0921 100644 --- a/sdk/src/subsystems/delegation/actions.ts +++ b/sdk/src/subsystems/delegation/actions.ts @@ -484,3 +484,78 @@ export async function setFineListed( account: clients.walletClient.account!, }); } + +export async function setSpendDelay( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { noteId: bigint; owners: Address[]; delay: bigint }, +): Promise { + return clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'setSpendDelay', + args: [params.noteId, params.owners, params.delay], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); +} + +export async function setStrictMode( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { noteId: bigint; owners: Address[]; enabled: boolean }, +): Promise { + return clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'setStrictMode', + args: [params.noteId, params.owners, params.enabled], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); +} + +export async function setSpendFlagger( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { noteId: bigint; owners: Address[]; flagger: Address; allowed: boolean }, +): Promise { + return clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'setSpendFlagger', + args: [params.noteId, params.owners, params.flagger, params.allowed], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); +} + +export async function approveScheduledSpend( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { noteId: bigint; owners: Address[] }, +): Promise { + return clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'approveScheduledSpend', + args: [params.noteId, params.owners], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); +} + +export async function cancelScheduledSpend( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { noteId: bigint; owners: Address[] }, +): Promise { + return clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'cancelScheduledSpend', + args: [params.noteId, params.owners], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); +} diff --git a/specs/tech/subsystems/delegation/spend-classification.md b/specs/tech/subsystems/delegation/spend-classification.md index d6c309d33..f1e8e788f 100644 --- a/specs/tech/subsystems/delegation/spend-classification.md +++ b/specs/tech/subsystems/delegation/spend-classification.md @@ -66,6 +66,6 @@ Classification is different. The schedule stores the time it was created. While ## What she sees -One section 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. +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. Immediate unsuspicious spends are not in that pending section. They are in the authorization's history, with the same label. diff --git a/specs/tech/subsystems/delegation/waiting-period.md b/specs/tech/subsystems/delegation/waiting-period.md index 585a9d74c..104e28368 100644 --- a/specs/tech/subsystems/delegation/waiting-period.md +++ b/specs/tech/subsystems/delegation/waiting-period.md @@ -62,7 +62,7 @@ Public remarks from people who are not flaggers are a later UI feature. They are ## What a project page should show -`SpendScheduled` is a pending contribution of the note's full amount until `SpendExecuted`, `SpendCancelled`, or `SpendScheduleCleared`. The page shows the amount and the deadline as money that can still be cancelled. It does not count as raised. That page is not wired yet. +`SpendScheduled` is a pending contribution of the note's full amount until `SpendExecuted`, `SpendCancelled`, or `SpendScheduleCleared`. The project page shows the amount and the deadline as money that can still be cancelled. It does not count as raised. ## Out of scope diff --git a/ui/src/commonality/shell/CauseShell.tsx b/ui/src/commonality/shell/CauseShell.tsx index ae594ad0a..b22a4c2c8 100644 --- a/ui/src/commonality/shell/CauseShell.tsx +++ b/ui/src/commonality/shell/CauseShell.tsx @@ -24,6 +24,7 @@ import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' import { Link, useLocation, useNavigate } from 'react-router-dom' import { WalletButton } from '../../shared/components/WalletButton' import { containerMaxWidth, pageWidthForPath } from './pageWidth' +import { SuspiciousSpendBanner } from './SuspiciousSpendBanner' const GITHUB_REPO_URL = 'https://github.com/AdamSpitz/commonality' @@ -189,6 +190,7 @@ export function CauseShell({ children }: CauseShellProps) { data-page-width={pageWidth} sx={{ pt: { xs: 2, sm: 3 }, px: { xs: 1.75, sm: 2, md: 3 } }} > + {children} diff --git a/ui/src/commonality/shell/SuspiciousSpendBanner.tsx b/ui/src/commonality/shell/SuspiciousSpendBanner.tsx new file mode 100644 index 000000000..9f9313aa1 --- /dev/null +++ b/ui/src/commonality/shell/SuspiciousSpendBanner.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import { Alert } from '@mui/material' +import { Link } from 'react-router-dom' +import { useAccount, usePublicClient } from 'wagmi' +import type { Address } from 'viem' +import { DelegatableNotesAbi } from '@commonality/sdk/abis' +import { getNotesByRoot } from '@commonality/sdk/delegation' +import { useMachinery } from '../../shared' +import { isSuspiciousClass } from '../../delegation/spendClass' + +const NOTES = import.meta.env.VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS as string | undefined + +/** In-app only. Email and push stay unwired; this does not follow a notification opt-in. */ +export function SuspiciousSpendBanner() { + const { address } = useAccount() + const publicClient = usePublicClient() + const machinery = useMachinery() + const [show, setShow] = useState(false) + + useEffect(() => { + if (!address || !publicClient || !NOTES) { + setShow(false) + return + } + let cancelled = false + ;(async () => { + const notes = await getNotesByRoot(machinery, address) + for (const note of notes) { + const contract = { address: (note.contractAddress || NOTES) as Address, abi: DelegatableNotesAbi } as const + const pending = await publicClient.readContract({ + ...contract, + functionName: 'pendingSpends', + args: [BigInt(note.id)], + }) as readonly [Address, Address, bigint, bigint, bigint, bigint, bigint, boolean, boolean] + if (!pending[8]) continue + const classified = await publicClient.readContract({ + ...contract, + functionName: 'effectiveSpendDelay', + args: [BigInt(note.id), pending[0]], + }) as readonly [bigint, number] + if (isSuspiciousClass(Number(classified[1]))) { + if (!cancelled) setShow(true) + return + } + } + if (!cancelled) setShow(false) + })().catch(() => { + if (!cancelled) setShow(false) + }) + return () => { cancelled = true } + }, [address, publicClient, machinery]) + + if (!show) return null + return ( + + A delegate spend is suspicious and still waiting. Review it + + ) +} diff --git a/ui/src/delegation/components/DonorPendingSpends.tsx b/ui/src/delegation/components/DonorPendingSpends.tsx new file mode 100644 index 000000000..a0d53b52c --- /dev/null +++ b/ui/src/delegation/components/DonorPendingSpends.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react' +import { Chip, Paper, Stack, Typography } from '@mui/material' +import { Link as RouterLink } from 'react-router-dom' +import { usePublicClient } from 'wagmi' +import type { Address } from 'viem' +import { formatEther } from 'viem' +import { DelegatableNotesAbi } from '@commonality/sdk/abis' +import type { Note } from '@commonality/sdk/delegation' +import { formatPendingSpendDeadline, isSuspiciousClass, spendClassLabel } from '../spendClass' +import { noteDetailPathFor } from '../utils' + +type Row = { + note: Note + deadline: bigint + paused: boolean + spendClass: number +} + +export function DonorPendingSpends({ notes }: { notes: Note[] }) { + const publicClient = usePublicClient() + const [rows, setRows] = useState([]) + + useEffect(() => { + if (!publicClient || notes.length === 0) { + setRows([]) + return + } + let cancelled = false + ;(async () => { + const found: Row[] = [] + for (const note of notes) { + const contract = { address: note.contractAddress as Address, abi: DelegatableNotesAbi } as const + const pending = await publicClient.readContract({ + ...contract, + functionName: 'pendingSpends', + args: [BigInt(note.id)], + }) as readonly [Address, Address, bigint, bigint, bigint, bigint, bigint, boolean, boolean] + if (!pending[8]) continue + const classified = await publicClient.readContract({ + ...contract, + functionName: 'effectiveSpendDelay', + args: [BigInt(note.id), pending[0]], + }) as readonly [bigint, number] + found.push({ + note, + deadline: pending[5], + paused: pending[7], + spendClass: Number(classified[1]), + }) + } + if (!cancelled) setRows(found) + })().catch(() => { + if (!cancelled) setRows([]) + }) + return () => { cancelled = true } + }, [publicClient, notes]) + + if (rows.length === 0) return null + + return ( + + Pending delegate spends + + These amounts can still be cancelled. They are not counted as raised. + + {rows.map((row) => { + const label = spendClassLabel(row.spendClass) + const suspicious = isSuspiciousClass(row.spendClass) + return ( + + + + {formatEther(BigInt(row.note.amount))} ETH + + + {row.paused && } + + + {formatPendingSpendDeadline(row.deadline)} + + + ) + })} + + ) +} + diff --git a/ui/src/delegation/components/FineListPanel.test.ts b/ui/src/delegation/components/FineListPanel.test.ts index 104de529b..69cb6e333 100644 --- a/ui/src/delegation/components/FineListPanel.test.ts +++ b/ui/src/delegation/components/FineListPanel.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { spendClassLabel } from './FineListPanel' +import { spendClassLabel } from '../spendClass' describe('spendClassLabel', () => { it('names the two classes the contract can return', () => { diff --git a/ui/src/delegation/components/FineListPanel.tsx b/ui/src/delegation/components/FineListPanel.tsx index bd0c1f05a..5ce42d386 100644 --- a/ui/src/delegation/components/FineListPanel.tsx +++ b/ui/src/delegation/components/FineListPanel.tsx @@ -6,10 +6,7 @@ import { DelegatableNotesAbi } from '@commonality/sdk/abis' import { hashBeneficiaryId, normalizeDnsBeneficiary } from '@commonality/sdk/content-funding' import { setFineListed } from '@commonality/sdk/delegation' import { useWriteClients } from '../../shared' - -export function spendClassLabel(spendClass: number): 'Unsuspicious' | 'Unmarked' { - return spendClass === 1 ? 'Unsuspicious' : 'Unmarked' -} +import { spendClassLabel } from '../spendClass' export function FineListPanel({ noteId, diff --git a/ui/src/delegation/components/PendingSpendCard.tsx b/ui/src/delegation/components/PendingSpendCard.tsx new file mode 100644 index 000000000..b79f59260 --- /dev/null +++ b/ui/src/delegation/components/PendingSpendCard.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react' +import { Alert, Button, Chip, Paper, Stack, Typography } from '@mui/material' +import { usePublicClient } from 'wagmi' +import type { Address } from 'viem' +import { formatEther } from 'viem' +import { DelegatableNotesAbi } from '@commonality/sdk/abis' +import { approveScheduledSpend, cancelScheduledSpend } from '@commonality/sdk/delegation' +import { useWriteClients } from '../../shared' +import { formatPendingSpendDeadline, isSuspiciousClass, spendClassLabel } from '../spendClass' + +type PendingRow = { + primaryMarket: Address + deadline: bigint + paused: boolean + amount: bigint + spendClass: number +} + +export function PendingSpendCard({ + noteId, + contractAddress, + owners, + amount, + canApprove, + canCancel, +}: { + noteId: bigint + contractAddress: Address + owners: Address[] + amount: bigint + canApprove: boolean + canCancel: boolean +}) { + const publicClient = usePublicClient() + const clients = useWriteClients() + const [row, setRow] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (!publicClient) return + let cancelled = false + const contract = { address: contractAddress, abi: DelegatableNotesAbi } as const + ;(async () => { + const pending = await publicClient.readContract({ + ...contract, + functionName: 'pendingSpends', + args: [noteId], + }) as readonly [Address, Address, bigint, bigint, bigint, bigint, bigint, boolean, boolean] + if (!pending[8]) { + if (!cancelled) setRow(null) + return + } + const classified = await publicClient.readContract({ + ...contract, + functionName: 'effectiveSpendDelay', + args: [noteId, pending[0]], + }) as readonly [bigint, number] + if (!cancelled) { + setRow({ + primaryMarket: pending[0], + deadline: pending[5], + paused: pending[7], + amount, + spendClass: Number(classified[1]), + }) + } + })().catch(() => { + if (!cancelled) setRow(null) + }) + return () => { cancelled = true } + }, [publicClient, contractAddress, noteId, amount, busy]) + + if (!row) return null + const label = spendClassLabel(row.spendClass) + const suspicious = isSuspiciousClass(row.spendClass) + + async function act(kind: 'approve' | 'cancel') { + if (!clients) return + setBusy(true) + setError(null) + try { + const contract = { address: contractAddress, abi: DelegatableNotesAbi } + const params = { noteId, owners } + if (kind === 'approve') await approveScheduledSpend(clients, contract, params) + else await cancelScheduledSpend(clients, contract, params) + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not update the pending spend') + } finally { + setBusy(false) + } + } + + return ( + + + Pending spend + + {row.paused && } + + {formatEther(row.amount)} ETH + + {row.paused + ? 'Paused. The delegate cannot cancel it. You can approve it or cancel it. It is not counted as raised.' + : formatPendingSpendDeadline(row.deadline)} + + + Project {row.primaryMarket} + + + {canApprove && } + {canCancel && !row.paused && } + {canApprove && row.paused && } + + {error && {error}} + + ) +} diff --git a/ui/src/delegation/components/SpendPolicyPanel.tsx b/ui/src/delegation/components/SpendPolicyPanel.tsx new file mode 100644 index 000000000..ff7e8577a --- /dev/null +++ b/ui/src/delegation/components/SpendPolicyPanel.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react' +import { Alert, Box, Button, Chip, Paper, Stack, Switch, TextField, Typography } from '@mui/material' +import { usePublicClient } from 'wagmi' +import type { Address } from 'viem' +import { isAddress } from 'viem' +import { DelegatableNotesAbi } from '@commonality/sdk/abis' +import { setSpendDelay, setSpendFlagger, setStrictMode, setUnsuspiciousDelay } from '@commonality/sdk/delegation' +import { useWriteClients } from '../../shared' +import { hoursInputToSeconds, secondsToHourInput } from '../spendClass' + +export function SpendPolicyPanel({ + noteId, + contractAddress, + owners, +}: { + noteId: bigint + contractAddress: Address + owners: Address[] +}) { + const publicClient = usePublicClient() + const clients = useWriteClients() + const [delayHours, setDelayHours] = useState('0') + const [unsuspiciousHours, setUnsuspiciousHours] = useState('0') + const [strictMode, setStrict] = useState(false) + const [flaggers, setFlaggers] = useState([]) + const [flaggerInput, setFlaggerInput] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (!publicClient) return + let cancelled = false + const contract = { address: contractAddress, abi: DelegatableNotesAbi } as const + ;(async () => { + const policy = await publicClient.readContract({ + ...contract, + functionName: 'spendPolicies', + args: [noteId], + }) as readonly [bigint, bigint, boolean] + const listed = await publicClient.readContract({ + ...contract, + functionName: 'spendFlaggers', + args: [noteId], + }) as Address[] + if (cancelled) return + setDelayHours(secondsToHourInput(policy[0])) + setUnsuspiciousHours(secondsToHourInput(policy[1])) + setStrict(policy[2]) + setFlaggers(listed) + })().catch(() => { + if (!cancelled) setError('Could not read the spend delay') + }) + return () => { cancelled = true } + }, [publicClient, contractAddress, noteId, busy]) + + 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') + return + } + if (unsuspicious > delay) { + setError('The shorter delay cannot be longer than the standing delay') + return + } + setBusy(true) + setError(null) + try { + const contract = { address: contractAddress, abi: DelegatableNotesAbi } + await setSpendDelay(clients, contract, { noteId, owners, delay }) + await setUnsuspiciousDelay(clients, contract, { noteId, owners, delay: unsuspicious }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not save the delay') + } finally { + setBusy(false) + } + } + + async function saveStrict(enabled: boolean) { + if (!clients) return + setBusy(true) + setError(null) + try { + await setStrictMode(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { noteId, owners, enabled }) + setStrict(enabled) + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not change strict mode') + } finally { + setBusy(false) + } + } + + async function changeFlagger(flagger: Address, allowed: boolean) { + if (!clients) return + setBusy(true) + setError(null) + try { + await setSpendFlagger(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { + noteId, owners, flagger, allowed, + }) + setFlaggerInput('') + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not change that flagger') + } finally { + setBusy(false) + } + } + + return ( + + Delay before a delegate 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. + + + setDelayHours(event.target.value)} /> + setUnsuspiciousHours(event.target.value)} helperText="Must be at most the standing delay. Zero completes in the delegate's transaction." /> + + + + { void saveStrict(checked) }} /> + Strict mode: a flagger pauses the spend. Off, the countdown continues. + + + {flaggers.length === 0 && No flaggers.} + {flaggers.map((flagger) => ( + { void changeFlagger(flagger, false) }} /> + ))} + + + setFlaggerInput(event.target.value)} placeholder="0x..." /> + + + {error && {error}} + + ) +} diff --git a/ui/src/delegation/pages/MyNotesPage.tsx b/ui/src/delegation/pages/MyNotesPage.tsx index 5867a8c24..da28b7d40 100644 --- a/ui/src/delegation/pages/MyNotesPage.tsx +++ b/ui/src/delegation/pages/MyNotesPage.tsx @@ -23,15 +23,17 @@ import { } from '@mui/material' import { Link as RouterLink } from 'react-router-dom' import { useAccount } from 'wagmi' -import { formatEther, parseEther } from 'viem' +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, replaceDelegate, revokeNote, reclaimFunds, getActiveStandingPledgesByUser, cancelStandingPledge, type DonationActivity, type Note, type StandingPledge, type DelegatableNotesContract, type RecurringPledgesContract } from '@commonality/sdk/delegation' -import type { Currency, IpfsCidV1 } from '@commonality/sdk/utils' +import { fetchEvents, 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 } from '../utils' +import { DonorPendingSpends } from '../components/DonorPendingSpends' +import { spendClassLabel } from '../spendClass' import { readLazyGivingProjectMetadata } from '../../lazy-giving/metadata' function SummaryCards({ ownedNotes, depositedNotes, standingPledges, experience = 'delegation' }: { ownedNotes: Note[]; depositedNotes: Note[]; standingPledges: StandingPledge[]; experience?: 'delegation' | 'donate' }) { @@ -361,10 +363,11 @@ function StandingPledgeCard({ ) } -function DonationActivityFeed({ activities, projectTitles, causeTitles }: { +function DonationActivityFeed({ activities, projectTitles, causeTitles, classByNoteId }: { activities: DonationActivity[] projectTitles: Record causeTitles: Record + classByNoteId: Record }) { const groups = new Map() for (const activity of activities) { @@ -388,6 +391,12 @@ function DonationActivityFeed({ activities, projectTitles, causeTitles }: { {formatCurrencyAmount(activity.amount, activity.currency)} + {(() => { + const value = activity.inputNoteIds.map((id) => classByNoteId[id]).find((item) => item !== undefined) + if (value === undefined) return null + const label = spendClassLabel(value) + return + })()} Directed by {truncateAddress(activity.directedBy)} · {formatPledgeDate(activity.createdAt)} @@ -423,6 +432,7 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg const [depositedNotes, setDepositedNotes] = useState([]) const [standingPledges, setStandingPledges] = useState([]) const [donationActivity, setDonationActivity] = useState([]) + const [classByNoteId, setClassByNoteId] = useState>({}) const [causeTitles, setCauseTitles] = useState>({}) const [projectTitles, setProjectTitles] = useState>({}) const [loading, setLoading] = useState(true) @@ -464,6 +474,27 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg setDepositedNotes(deposited.filter(n => n.active)) setStandingPledges(activePledges) setDonationActivity(activity) + const notesAddress = import.meta.env.VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS as string | undefined + if (isDonate && notesAddress && machinery.eventCacheUrl) { + const events = await fetchEvents(machinery, { + contractAddress: notesAddress, + eventName: 'SpendClassResolved', + }).catch(() => []) + const labels: Record = {} + for (const event of events) { + if (!event.topic0 || !event.topic1) continue + const decoded = decodeEventLog({ + abi: DelegatableNotesAbi, + eventName: 'SpendClassResolved', + topics: [event.topic0 as Hex, event.topic1 as Hex], + data: event.data as Hex, + }) + labels[decoded.args.noteId.toString()] = Number(decoded.args.class) + } + setClassByNoteId(labels) + } else { + setClassByNoteId({}) + } const projectEntries = await Promise.all(activity.map(async (row) => { if (!row.projectMetadataCid) return [row.projectAddress?.toLowerCase() ?? row.receiptContract.toLowerCase(), undefined] as const const metadata = await readLazyGivingProjectMetadata(machinery, row.projectMetadataCid as IpfsCidV1).catch(() => null) @@ -705,6 +736,8 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg )} + + {isDonate ? 'Money in the system' : 'Funds I Created'} @@ -748,7 +781,7 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg ) : ( - + )} )} diff --git a/ui/src/delegation/pages/NoteDetailPage.tsx b/ui/src/delegation/pages/NoteDetailPage.tsx index 3eeceb96b..62e4ddca3 100644 --- a/ui/src/delegation/pages/NoteDetailPage.tsx +++ b/ui/src/delegation/pages/NoteDetailPage.tsx @@ -31,6 +31,8 @@ import { StatementPicker, useMachinery } from '../../shared' import { useWriteClients } from '../../shared' import { formatNoteAmount, isDelegate, truncateAddress, isEthNote, parseNoteRouteId, noteDetailPathFor } from '../utils' import { FineListPanel } from '../components/FineListPanel' +import { PendingSpendCard } from '../components/PendingSpendCard' +import { SpendPolicyPanel } from '../components/SpendPolicyPanel' function getContract(address?: string) { const addr = address ?? import.meta.env.VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS @@ -691,13 +693,29 @@ export function NoteDetailPage() { )} {isRootOwner && ( - b.position - a.position).map((link) => link.address as `0x${string}`)} - /> + <> + b.position - a.position).map((link) => link.address as `0x${string}`)} + /> + b.position - a.position).map((link) => link.address as `0x${string}`)} + /> + )} + b.position - a.position).map((link) => link.address as `0x${string}`)} + amount={BigInt(note.amount)} + canApprove={isRootOwner} + canCancel={isRootOwner || isCurrentLeafOwner} + /> + diff --git a/ui/src/delegation/spendClass.test.ts b/ui/src/delegation/spendClass.test.ts new file mode 100644 index 000000000..8af21d091 --- /dev/null +++ b/ui/src/delegation/spendClass.test.ts @@ -0,0 +1,26 @@ +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') + expect(isSuspiciousClass(2)).toBe(true) + expect(isSuspiciousClass(0)).toBe(false) + }) + + it('converts a delay between hours and seconds', () => { + expect(secondsToHourInput(7200n)).toBe('2') + expect(secondsToHourInput(5400n)).toBe('1.50') + expect(hoursInputToSeconds('2')).toBe(7200n) + expect(hoursInputToSeconds('1.5')).toBe(5400n) + expect(hoursInputToSeconds('soon')).toBeNull() + }) + + it('describes a pending deadline as cancellable money, not raised', () => { + const text = formatPendingSpendDeadline(1_700_000_000n, 1_600_000_000) + expect(text).toContain('not counted as raised') + expect(text).toContain('cancelled') + }) +}) \ No newline at end of file diff --git a/ui/src/delegation/spendClass.ts b/ui/src/delegation/spendClass.ts new file mode 100644 index 000000000..6f9085b21 --- /dev/null +++ b/ui/src/delegation/spendClass.ts @@ -0,0 +1,31 @@ +/** 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' +} + +export function isSuspiciousClass(spendClass: number): boolean { + return spendClassLabel(spendClass) === 'Suspicious' +} + +export function secondsToHourInput(seconds: bigint): string { + if (seconds % 3600n === 0n) return (seconds / 3600n).toString() + return (Number(seconds) / 3600).toFixed(2) +} + +export function hoursInputToSeconds(input: string): bigint | null { + const trimmed = input.trim() + if (!/^\d+(\.\d+)?$/.test(trimmed)) return null + const hours = Number(trimmed) + if (!Number.isFinite(hours)) return null + return BigInt(Math.round(hours * 3600)) +} + +export function formatPendingSpendDeadline(deadlineSeconds: bigint, nowSeconds = Math.floor(Date.now() / 1000)): string { + const when = new Date(Number(deadlineSeconds) * 1000).toLocaleString() + if (Number(deadlineSeconds) <= nowSeconds) { + return `Due ${when}. Anyone can complete it. It can still be cancelled, and it is not counted as raised.` + } + return `Waiting until ${when}. It can still be cancelled, and it is not counted as raised.` +} diff --git a/ui/src/lazy-giving/components/PendingProjectSpends.tsx b/ui/src/lazy-giving/components/PendingProjectSpends.tsx new file mode 100644 index 000000000..8c98bd00a --- /dev/null +++ b/ui/src/lazy-giving/components/PendingProjectSpends.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react' +import { Paper, Stack, Typography } from '@mui/material' +import { decodeEventLog, formatEther, type Address, type Hex } from 'viem' +import { usePublicClient } from 'wagmi' +import { DelegatableNotesAbi } from '@commonality/sdk/abis' +import { fetchEvents } from '@commonality/sdk/utils' +import { useMachinery } from '../../shared' +import { formatPendingSpendDeadline } from '../../delegation/spendClass' + +const NOTES = import.meta.env.VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS as string | undefined + +/** + * Scheduled spends are not purchases, so they are absent from the raised total. + * This lists the ones still pending against this project's assurance contract. + */ +export function PendingProjectSpends({ primaryMarket }: { primaryMarket: string }) { + const publicClient = usePublicClient() + const machinery = useMachinery() + const [lines, setLines] = useState<{ noteId: string; amount: bigint; deadline: bigint }[]>([]) + + useEffect(() => { + if (!publicClient?.readContract || !NOTES || !machinery.eventCacheUrl) { + setLines([]) + return + } + let cancelled = false + ;(async () => { + const events = await fetchEvents(machinery, { + contractAddress: NOTES, + eventName: 'SpendScheduled', + }) + const market = primaryMarket.toLowerCase() + const seen = new Set() + const next: { noteId: string; amount: bigint; deadline: bigint }[] = [] + for (const event of events) { + if (!event.topic0 || !event.topic1 || !event.topic2 || !event.topic3) continue + const decoded = decodeEventLog({ + abi: DelegatableNotesAbi, + eventName: 'SpendScheduled', + topics: [event.topic0, event.topic1, event.topic2, event.topic3] as [Hex, Hex, Hex, Hex], + data: event.data as Hex, + }) + if (decoded.args.primaryMarket.toLowerCase() !== market) continue + const noteId = decoded.args.noteId + const key = noteId.toString() + if (seen.has(key)) continue + seen.add(key) + const pending = await publicClient.readContract({ + address: NOTES as Address, + abi: DelegatableNotesAbi, + functionName: 'pendingSpends', + args: [noteId], + }) as readonly [Address, Address, bigint, bigint, bigint, bigint, bigint, boolean, boolean] + if (!pending[8] || pending[0].toLowerCase() !== market) continue + next.push({ noteId: key, amount: decoded.args.amount, deadline: pending[5] }) + } + if (!cancelled) setLines(next) + })().catch(() => { + if (!cancelled) setLines([]) + }) + return () => { cancelled = true } + }, [publicClient, machinery, primaryMarket]) + + if (lines.length === 0) return null + return ( + + Waiting, and still cancellable + + Not included in the amount raised. The donor or the delegate can still cancel these. + + + {lines.map((line) => ( + + {formatEther(line.amount)} ETH. {formatPendingSpendDeadline(line.deadline)} + + ))} + + + ) +} diff --git a/ui/src/lazy-giving/pages/ProjectDetailPage.tsx b/ui/src/lazy-giving/pages/ProjectDetailPage.tsx index 8041c56ed..cfe1a89b6 100644 --- a/ui/src/lazy-giving/pages/ProjectDetailPage.tsx +++ b/ui/src/lazy-giving/pages/ProjectDetailPage.tsx @@ -27,6 +27,7 @@ import { getRuntimeConfigValue, isCidDeniedByDisplayDenylist, loadDisplayDenylis import { tryParseChainAddressRef } from '../../shared' import { readLazyGivingProjectMetadata, readLazyGivingTokenMetadata, type ProjectMetadata } from '../metadata' import { usePublishedBeneficiaryBinding } from '../components/usePublishedBeneficiaryBinding' +import { PendingProjectSpends } from '../components/PendingProjectSpends' const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const export type ProjectDetailPageProps = { @@ -383,6 +384,7 @@ export function ProjectDetailPage({ return ( + {projectContractAddress && } {projectContractAddress && disavowedProjects.has(projectContractAddress.toLowerCase()) && ( From 9b1a1e55bfdd91b715ca5eb86dc85e9471992b29 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sat, 26 Sep 2026 19:56:11 -0400 Subject: [PATCH 3/5] Designing the partial takeback thing, to be used as an override for the blocking rules for delegated funds. --- TODO.md | 4 +- specs/decisions/0017-spend-classification.md | 2 +- specs/glossary.md | 2 + specs/product/legal/delegation-narrowing.md | 12 +++-- specs/tech/subsystems/aligning/indexer.md | 2 +- specs/tech/subsystems/delegation/README.md | 13 +++-- specs/tech/subsystems/delegation/one-hop.md | 2 +- .../subsystems/delegation/partial-takeback.md | 50 +++++++++++++++++++ .../delegation/spend-classification.md | 4 +- specs/tech/subsystems/delegation/ui.md | 6 ++- .../subsystems/delegation/waiting-period.md | 2 +- 11 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 specs/tech/subsystems/delegation/partial-takeback.md diff --git a/TODO.md b/TODO.md index d7efc49f1..47824af55 100644 --- a/TODO.md +++ b/TODO.md @@ -24,10 +24,10 @@ Getting **testnet to a two-person shared lab** is also a standing plan, not a pi - One voice for delegation copy. The donor is authorizing an address to spend a stated amount on projects in Commonality. The delegate promises nothing. A stated intent is public and does not bind the spend. Unspent funds stay revocable by the donor. Commonality does not hold the funds, choose the delegate, or supervise the spending. Remove the steward voice: entrusting money to a scout, program-officer framing, "money under management," and any Commonality ranking whose job is to send people to a delegate. A public history of what an address already funded can stay. "Scout" as the early contributor who may later be reimbursed at cost can stay; do not let that word mean a manager of other people's money. Start with `specs/product/legal/retroactive-funding-redesign.md` (Design 2) and `docs/end-user/lazyGiving/` (`retroactive-funding.md`, `index.md`, `fund-something.md`, `get-your-project-funded.md`). No delegate marketplace. -- Reliable revocation of delegated authority over returned funds. Revocation covers the unspent balance, pending spends, and outstanding receipt claims, so a later refund cannot revive authority the donor removed. Failed-project refunds stay inside the same authorization ("keep trying until I revoke") and remain subject to its current rules and revocation state. Successful-project reimbursement recycling is still an open choice; do not settle it in this item. Write the proposal against `specs/tech/subsystems/delegation/` and [delegation-narrowing.md](specs/product/legal/delegation-narrowing.md) before changing contracts. +- Implement [partial takeback](specs/tech/subsystems/delegation/partial-takeback.md), including its "Implementing this" section. Also follow the fold and SDK notes in [the delegation README](specs/tech/subsystems/delegation/README.md), the notes-page controls in [ui.md](specs/tech/subsystems/delegation/ui.md), and the names in [the glossary](specs/glossary.md) (Takeback, Partial takeback). +- 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. -- Exact-payment donor overrides. For an ordinary pending spend, "Approve now" is enough. If that spend breaks a rule she configured, name the exception (for example, a beneficiary outside her approved list) and bind her approval to that payment only. Changing the standing rule is a separate action. The delegate's restrictions stay enforced by the contract. She should not have to revoke and redeposit to make the exception. Write the proposal against `specs/tech/subsystems/delegation/` and [delegation-narrowing.md](specs/product/legal/delegation-narrowing.md) before changing contracts. ---- diff --git a/specs/decisions/0017-spend-classification.md b/specs/decisions/0017-spend-classification.md index 017f77254..2bc4e5539 100644 --- a/specs/decisions/0017-spend-classification.md +++ b/specs/decisions/0017-spend-classification.md @@ -36,6 +36,6 @@ The rules bind the delegate's own spends. Her spend of a note she holds is not c Website projects created through `createERC1155AndAssuranceContractForBeneficiary` do not match this criterion. They claim later. The fine list does nothing for them until a route fixes the recipient and stores the `beneficiaryId` on-chain. Do not paper over that with metadata. -A block cannot reuse `approveScheduledSpend`, because a blocked spend has no schedule. One-payment approval of a block waits on the exact-payment override. Delayed spends can already be approved early. +A block cannot reuse `approveScheduledSpend`, because a blocked spend has no schedule. She takes that amount back and pays it herself. See [partial-takeback.md](../tech/subsystems/delegation/partial-takeback.md). Delayed spends can already be approved early. Revisit this if claim-time payout and the controller at spend time need to be the same fact, or if a suspicious criterion exists that is not satisfied by the delegate verifying a name he controls. diff --git a/specs/glossary.md b/specs/glossary.md index 040f86015..e91414a46 100644 --- a/specs/glossary.md +++ b/specs/glossary.md @@ -40,6 +40,8 @@ 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 | +| **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) | | **Standing pledge** | A *recurring* funding commitment registered with `RecurringPledges`, executed periodically into a note | diff --git a/specs/product/legal/delegation-narrowing.md b/specs/product/legal/delegation-narrowing.md index bbd5f7072..d60a29393 100644 --- a/specs/product/legal/delegation-narrowing.md +++ b/specs/product/legal/delegation-narrowing.md @@ -58,11 +58,11 @@ Adam's feedback, 2026-09-25. These qualify the candidates above; they do not aut - Prefer objective evidence where available, with trust-graph judgments as a fallback. Explore the existing [beneficiary identity system](../../tech/subsystems/claimable-beneficiaries.md) before introducing payout-address vouches. Different purposes may need different trust graphs; record that concern without designing those graphs yet. - Keep “$100/month” as depositing another $100 each month. Additional spending-cap accounting is low priority. -- Rules constrain the delegate's independent authority. The donor should be able to authorize an exception to their own rules, with a clear warning about what this spend overrides. No need to reclaim and redeposit just to exercise that control. +- Rules constrain the delegate's independent authority. She does not punch a hole in those rules for one payment. She takes that amount back and pays it herself. The rest stays delegated, and the standing rule is unchanged. See [partial-takeback.md](../../tech/subsystems/delegation/partial-takeback.md). - Refund destination remains open. Failed assurance projects are expected, and requiring fresh delegation after each failure creates donor work and discourages the delegate from helping projects that may not reach threshold. - Mandatory expiry is unattractive: it adds renewal work and can encourage spending before authority expires. A watcher with cancellation powers also appears to add too much complexity for now. -Working direction to develop, not an adopted design: preserve reusable delegated budgets and focus on one hop, revocation, donor-set delay, clear recipient evidence, and explicit donor exceptions. Failed-project refunds and successful-project reimbursements should be considered separately before choosing whether either ends delegation. +Working direction to develop, not an adopted design: preserve reusable delegated budgets and focus on one hop, revocation, donor-set delay, clear recipient evidence, and partial takeback when she wants to pay something the delegate cannot. Failed-project refunds and successful-project reimbursements should be considered separately before choosing whether either ends delegation. Beneficiary integration must distinguish three questions: which public identity the donor intends to fund; evidence connecting that identity to a payout wallet; and whether the proposed work deserves funding. Domain control addresses the second, not all three. Today's verifier uses a trusted platform signer; DNSSEC / zkTLS remain future mechanisms. Unclaimed identity escrow can bind a destination before onboarding, but does not prove the beneficiary will claim or endorse a third-party project. @@ -76,7 +76,7 @@ Direction discussed with Adam, not a contract implementation specification: 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. 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. **Specific donor overrides**, without accidentally changing standing rules. Rules govern what the delegate can do without asking; the donor can approve an exception for an exact payment. +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. Keep monthly deposits as they are. Defer additional spending caps, compulsory renewal, separate watchers with cancellation authority, and new payout-attestation machinery. Optional spending summaries can prompt review without expiring authority; respect notification preferences. @@ -88,9 +88,11 @@ Settled in [ADR 0017](/specs/decisions/0017-spend-classification.md). The notes Do not require every beneficiary to claim before receiving contributions. Preserve fund-now-claim-later, while distinguishing “destination reserved for this identity” from “controller has verified and adopted a payout wallet.” Neither implies endorsement of a third-party project. Stronger proof mechanisms can improve verification later without inventing a new product concept now. -### Donor exceptions +### One payment the rules would reject -For an ordinary pending spend, use **Approve now**. If it violates a configured rule, identify the exception explicitly, for example: “This beneficiary is outside your approved list. Approve this payment anyway?” The donor's approval binds to that exact payment; changing the standing rule is a separate choice. Warnings accompany deliberate donor overrides, while the delegate's independent restrictions remain contract-enforced. +For an ordinary pending spend, use **Approve now**. A beneficiary who is not on her fine list is a longer wait, not a separate exception. **Approve now** already pays it. + +A spend the rules reject outright never becomes a pending payment, so there is nothing to approve. She uses partial takeback: that amount comes back to her, she pays it herself, and the delegate keeps the rest under the same rules. That is not an approval of his payment, and it does not change the standing rule. The contract behavior is [partial-takeback.md](../../tech/subsystems/delegation/partial-takeback.md). ### Reusable budgets and returned funds diff --git a/specs/tech/subsystems/aligning/indexer.md b/specs/tech/subsystems/aligning/indexer.md index 3f93b6463..23d031b06 100644 --- a/specs/tech/subsystems/aligning/indexer.md +++ b/specs/tech/subsystems/aligning/indexer.md @@ -18,7 +18,7 @@ All subsystems share a single thin event cache (one `events` table). The SDK fet ### Delegation -- **Notes and chains:** `foldDelegationState()` processes `NoteCreated`, `NoteDelegated`, `ChainSplit`, `NoteRevoked`, `FundsReclaimed`, `NoteConsumed`, `ERC1155Purchased`, `RefundedIntoNote` events to reconstruct note ownership, delegation chains, and lifecycle state. +- **Notes and chains:** `foldDelegationState()` processes `NoteCreated`, `NoteDelegated`, `ChainSplit`, `NoteRevoked`, `NotePartiallyTakenBack`, `FundsReclaimed`, `NoteConsumed`, `ERC1155Purchased`, `RefundedIntoNote` events to reconstruct note ownership, delegation chains, and lifecycle state. `NotePartiallyTakenBack` reduces the parent balance and leaves its chain in place. - **Note intent attestations:** `foldNoteIntentAttestations()` processes `NoteIntentAttested` events. ### Aligning diff --git a/specs/tech/subsystems/delegation/README.md b/specs/tech/subsystems/delegation/README.md index 1787ca5d8..027db0221 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). +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). --- @@ -66,7 +66,8 @@ The indexer captures every `DelegatableNotes` event as a raw row in the event ca | `NoteCreated` | New note created (deposit or ERC1155 purchase output) | | `ChainSplit` | Partial delegation — original note splits into two | | `NoteDelegated` | A note's chain is extended with a new delegate | -| `NoteRevoked` | A chain member revokes — chain truncated back to revoker | +| `NoteRevoked` | A chain member revokes — chain truncated back to revoker. The donor's full takeback, and the delegate handing the note back, are both this event | +| `NotePartiallyTakenBack` | The root took an amount back. The original note stays delegated. The slice is a new note she alone holds | | `NoteDelegateReplaced` | The root minted a new note for a replacement delegate. The original chain is unchanged. A full replacement retires the original note | | `NoteConsumed` | Note amount reduced (or deleted) by a spend | | `FundsReclaimed` | Root owner withdrew funds | @@ -95,6 +96,7 @@ interface DelegationChainLink { - `NoteDelegated (full)` → push `{ address: delegate, position: chain.length }` onto the same note's chain - `NoteDelegated (partial)` → push delegate onto the split note's chain (ChainSplit ran first) - `NoteRevoked` → truncate the chain so the revoker becomes the new leaf (strips all downstream delegates) +- `NotePartiallyTakenBack` → reduce the parent balance and leave its chain unchanged. The slice was already `NoteCreated` as the root alone. Do not copy the parent's delegate onto it - `NoteDelegateReplaced` → the new note, already created by `NoteCreated` as the root alone, gains the replacement delegate. The original note loses the replaced amount and, when that amount was the whole note, becomes inactive. Its chain stays as it was - `NoteConsumed` / `FundsReclaimed` → mark note inactive; chain is preserved in the map for ERC1155Purchased reference - `ERC1155Purchased` → output notes were emitted as `NoteCreated` with a single-link chain; the fold replaces that with the full chain copied from the corresponding input note @@ -153,9 +155,14 @@ depositETH(clients, contract, { amount }) // Delegate (full or partial); owners is leaf-first delegateNote(clients, contract, { noteId, owners, delegateTo, amount }) -// Revoke (any chain member can call); owners is leaf-first +// Revoke (any chain member can call); owners is leaf-first. +// The donor's full takeback, and the delegate handing the note back, are both this call. revokeNote(clients, contract, { 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 }) + // Reclaim funds from a root (non-delegated) note reclaimFunds(clients, contract, noteId) diff --git a/specs/tech/subsystems/delegation/one-hop.md b/specs/tech/subsystems/delegation/one-hop.md index 615942319..28d337f13 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 exact-payment overrides 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, refund revocation, beneficiary checks, and partial takeback stay in their own items. diff --git a/specs/tech/subsystems/delegation/partial-takeback.md b/specs/tech/subsystems/delegation/partial-takeback.md new file mode 100644 index 000000000..325e716c6 --- /dev/null +++ b/specs/tech/subsystems/delegation/partial-takeback.md @@ -0,0 +1,50 @@ +# Partial takeback + +The donor can take part of a delegated note back without ending the delegate's authority over the rest. This is the exact-payment case as well as an ordinary control. There is no separate approval that lets the delegate break a rule for one payment. + +A wait is still [waiting-period.md](./waiting-period.md). **Approve now** pays a pending spend, including one that is only unmarked because the beneficiary is not on her fine list. A block still has no schedule. The delegate's blocked spend reverts. She takes an amount back and pays it herself from the note she then holds. That does not change the delay, the fine list, the flaggers, strict mode, or a block on the note that stays delegated. + +## Call + +`partialTakeback(noteId, owners, amount)` + +`owners` is leaf-first, as on every other note call. The caller is the root, `owners[owners.length - 1]`. The note is delegated: the chain is longer than the root alone. A note she already holds is not a takeback. She spends or reclaims it. + +`amount` is greater than zero and less than the note's balance. The whole balance is `revoke`, not this call. + +If `pendingSpends[noteId]` exists, the call reverts. It does not clear that spend. She can cancel the spend, or revoke the whole note, and those remain separate actions. This is unlike `replaceDelegate`, which clears a pending spend even when it moves only part of the balance. + +## What moves + +The original note keeps its id, its `chainHash`, and its spend policy. Its balance decreases by `amount`. The delegate is still the leaf. A full takeback emits `NoteRevoked`. This call does not. + +The amount she names is a new note. Its chain is the root alone: `keccak256(root, bytes32(0))`. She is the leaf, so a delay or a block does not apply to a purchase she submits from it. The new note receives the spend policy `splitNote` copies: delay, unsuspicious delay, strict mode, flaggers, and the fine list. Those fields do not constrain her while she holds the note. If she delegates it again, they are still there. + +The reimbursement claim moves in proportion with the amount, by the same division `splitNote` and `replaceDelegate` use. A note with no claim is unchanged aside from the balance. + +This applies to a payment note and to a receipt note. After the slice is hers, a refund is still per note and still whole-note: she can refund the slice she holds, and the delegate can still refund the remainder. A standing pledge is not this note. Later notes it mints are unaffected. + +## Events and fold + +In the same transaction, in this order: + +1. `NoteCreated` for the new note, with her as `owner` and the slice's amount and token. +2. `NotePartiallyTakenBack(noteId, sliceNoteId, amount)`. + +Do not emit `NoteRevoked` for either note. Do not emit `ChainSplit`. That event means the new note inherits the delegated chain. This note does not. + +`foldDelegationState` leaves the parent chain as it was and reduces the parent balance. The slice stays the root-only chain from `NoteCreated`. It does not gain the parent's delegate. + +## Product names + +Her full action is **takeback**. It is still the `revoke` function and the `NoteRevoked` event. Her partial action is **partial takeback**. The delegate's use of `revoke` is him handing the note back, not a takeback. + +Nothing in this action records a project the delegate asked her to pay. A later purchase is whatever she submits. The interface must not call partial takeback an approval of his payment. + +## Implementing this + +Do not reopen the design. Read [workflow/roles/developer.md](/workflow/roles/developer.md) and follow it. The behavior is this file. The fold and the SDK action are in [README.md](./README.md). The notes-page controls are in [ui.md](./ui.md). The names are in [specs/glossary.md](/specs/glossary.md): **Takeback** and **Partial takeback**. + +In `DelegatableNotes`, add `partialTakeback(noteId, owners, amount)` as specified above. Update `foldDelegationState` so the parent chain stays and its balance drops, and the slice stays root-only. Add the SDK action. In the notes UI, the root sees **Takeback** (existing `revoke`) and **Partial takeback** (this call). The leaf's `revoke` control is **Hand back**. Partial takeback is unavailable while a spend is pending. + +Tests must cover: a partial amount; the full amount reverts; zero reverts; a pending spend reverts and is still pending; the policy and the fine list are copied; the claim portion moves; a receipt note as well as a payment note; the parent does not emit `NoteRevoked`; and a later purchase by her is not delayed. Do not change `replaceDelegate`'s habit of clearing a pending spend. diff --git a/specs/tech/subsystems/delegation/spend-classification.md b/specs/tech/subsystems/delegation/spend-classification.md index f1e8e788f..60a2ad387 100644 --- a/specs/tech/subsystems/delegation/spend-classification.md +++ b/specs/tech/subsystems/delegation/spend-classification.md @@ -2,7 +2,7 @@ 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). -This file is the proposal the beneficiary-identity item in [TODO.md](/TODO.md) asked for. It does not change contracts. The donor-set delay itself is [waiting-period.md](./waiting-period.md). A one-payment exception to a block is the separate exact-payment override item, not this one. +This file is the proposal the beneficiary-identity item in [TODO.md](/TODO.md) asked for. It does not change 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. ## Classes @@ -56,7 +56,7 @@ The pending spend shows that on-chain route. It does not substitute the registry `U = 0` spends in the delegate's transaction and stores no pending row, same as `T = 0` on an unmarked spend. It is labeled unsuspicious in that authorization's history. -A suspicious warning is off-chain and follows her existing notification opt-in. The longer wait and the block are on-chain. Her exact-payment approval can let that one spend through without changing the list, `U`, `S`, or the block. [waiting-period.md](./waiting-period.md) already lets her approve a scheduled spend early. A block has no schedule. The override item has to authorize that one payment before a blocked spend can be forced through. Until then, block means the delegate's spend reverts. +A suspicious warning is off-chain and follows her existing notification opt-in. The longer wait and the block are on-chain. [waiting-period.md](./waiting-period.md) already lets her approve a scheduled spend early. A block has no schedule, so **Approve now** has nothing to pay. The delegate's blocked spend reverts. She can take an amount back and pay it herself without changing the list, `U`, `S`, or the block on the note that stays delegated. See [partial-takeback.md](./partial-takeback.md). ## Deadlines already running diff --git a/specs/tech/subsystems/delegation/ui.md b/specs/tech/subsystems/delegation/ui.md index 6e08a72a6..b18c07cf5 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, a **Revoke** button is available. This calls `revokeNote` with the full delegation chain (obtained via `getDelegationChain`). Revoking brings control back to the root. +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. A **Reclaim** button is available on undelegated notes (where root = leaf), calling `reclaimFunds` to withdraw the funds back to the user's wallet. @@ -87,7 +87,9 @@ 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`. -- **Revoke** (shown to any chain member who is not the leaf): calls `revokeNote`. The UI should make it clear that revoking will truncate the chain at the revoker's position, removing all delegations below them. +- **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. - **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 104e28368..5126cd931 100644 --- a/specs/tech/subsystems/delegation/waiting-period.md +++ b/specs/tech/subsystems/delegation/waiting-period.md @@ -14,7 +14,7 @@ A same-chain `splitNote` is leaf-only on a delegated note. It moves part of the `replaceDelegate` cancels a pending schedule and moves the funds with no project attached. The new note copies the strict-mode switch and the flagger list, and it keeps the current delay. `replaceDelegateWithDelay` is that same replacement when she sets a different delay in the action. A full replacement deletes the old note. A partial replacement leaves the remainder delegated to the current leaf, with its schedule cleared, because a schedule covers the whole note. -`revoke` clears a pending schedule and then truncates the chain as it does today. +`revoke` clears a pending schedule and then truncates the chain as it does today. Partial takeback does not. While a spend is pending it reverts, and the schedule stays. See [partial-takeback.md](./partial-takeback.md). ## One schedule for the whole note From 303e87f12bd6e8ef54e9c6229ea628b46a428d5f Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sat, 26 Sep 2026 20:07:54 -0400 Subject: [PATCH 4/5] Let a donor take part of a delegated note back without ending the delegation. The slice is a new root-only note. The original note stays delegated, a pending spend blocks the call, and the notes pages expose the control. --- TODO.md | 2 - .../contracts/delegation/DelegatableNotes.sol | 57 ++++++ .../DelegatableNotes.partialTakeback.test.js | 189 ++++++++++++++++++ indexer/abis/DelegatableNotesAbi.ts | 54 +++++ indexer/src/events-cache/index.ts | 1 + sdk/abis/DelegatableNotesAbi.ts | 54 +++++ sdk/src/subsystems/delegation/actions.ts | 54 +++-- sdk/src/subsystems/delegation/events.ts | 6 + sdk/src/subsystems/delegation/folds.test.ts | 39 ++++ sdk/src/subsystems/delegation/folds.ts | 14 ++ sdk/src/subsystems/delegation/queries.ts | 6 + sdk/src/utils/event-decoders/delegation.ts | 23 +++ sdk/src/utils/eventCacheClient.ts | 3 +- ui/src/delegation/pages/MyNotesPage.test.tsx | 11 +- ui/src/delegation/pages/MyNotesPage.tsx | 136 ++++++++++++- .../delegation/pages/NoteDetailPage.test.tsx | 6 +- ui/src/delegation/pages/NoteDetailPage.tsx | 99 ++++++++- 17 files changed, 709 insertions(+), 45 deletions(-) create mode 100644 hardhat/test/DelegatableNotes.partialTakeback.test.js diff --git a/TODO.md b/TODO.md index 47824af55..4cd083002 100644 --- a/TODO.md +++ b/TODO.md @@ -24,8 +24,6 @@ Getting **testnet to a two-person shared lab** is also a standing plan, not a pi - One voice for delegation copy. The donor is authorizing an address to spend a stated amount on projects in Commonality. The delegate promises nothing. A stated intent is public and does not bind the spend. Unspent funds stay revocable by the donor. Commonality does not hold the funds, choose the delegate, or supervise the spending. Remove the steward voice: entrusting money to a scout, program-officer framing, "money under management," and any Commonality ranking whose job is to send people to a delegate. A public history of what an address already funded can stay. "Scout" as the early contributor who may later be reimbursed at cost can stay; do not let that word mean a manager of other people's money. Start with `specs/product/legal/retroactive-funding-redesign.md` (Design 2) and `docs/end-user/lazyGiving/` (`retroactive-funding.md`, `index.md`, `fund-something.md`, `get-your-project-funded.md`). No delegate marketplace. -- Implement [partial takeback](specs/tech/subsystems/delegation/partial-takeback.md), including its "Implementing this" section. Also follow the fold and SDK notes in [the delegation README](specs/tech/subsystems/delegation/README.md), the notes-page controls in [ui.md](specs/tech/subsystems/delegation/ui.md), and the names in [the glossary](specs/glossary.md) (Takeback, Partial takeback). - - 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. diff --git a/hardhat/contracts/delegation/DelegatableNotes.sol b/hardhat/contracts/delegation/DelegatableNotes.sol index c5f7194e7..68dfab5bb 100644 --- a/hardhat/contracts/delegation/DelegatableNotes.sol +++ b/hardhat/contracts/delegation/DelegatableNotes.sol @@ -191,6 +191,17 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { */ event NoteRevoked(uint256 indexed noteId, address indexed revoker); + /** + * @notice The root took an amount back. The original note stays delegated. + * @dev `sliceNoteId` is a new note she alone holds. This is not a revocation + * and not a chain split: the slice does not inherit the delegated chain. + */ + event NotePartiallyTakenBack( + uint256 indexed noteId, + uint256 indexed sliceNoteId, + uint256 amount + ); + /** * @notice The root replaced the current delegate with a new note. * @dev The original note keeps its chain. A full replacement retires it. @@ -1155,6 +1166,52 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { emit NoteRevoked(noteId, caller); } + /** + * @notice The root takes part of a delegated note back. The rest stays delegated. + * @dev Does not clear a pending spend. The slice is a new root-only note. + * `amount` must be greater than zero and less than the balance. + * @return sliceNoteId The new note she alone holds + */ + function partialTakeback( + uint256 noteId, + address[] calldata owners, + uint256 amount + ) external nonReentrant returns (uint256 sliceNoteId) { + Note storage note = notes[noteId]; + if (note.chainHash == bytes32(0)) revert NoteDoesNotExist(); + if (note.chainHash != _verifyAndComputeChainHash(owners)) revert InvalidChain(); + if (owners[owners.length - 1] != _msgSender()) revert NotNoteRoot(); + // A note she already holds is not a takeback. Longer chains are outside the hop limit. + if (owners.length != 2) revert DelegationHopLimit(); + if (pendingSpends[noteId].exists) revert SpendAlreadyScheduled(); + if (amount == 0 || amount >= note.amount) revert SplitAmountMustBePartial(); + + address root = owners[1]; + sliceNoteId = nextNoteId++; + notes[sliceNoteId] = Note({ + chainHash: _computeChainHash(root, bytes32(0)), + amount: amount, + token: note.token, + tokenType: note.tokenType, + tokenId: note.tokenId + }); + address[] memory flaggers = spendFlaggerList[noteId]; + _writePolicy( + sliceNoteId, + spendPolicies[noteId].delay, + spendPolicies[noteId].unsuspiciousDelay, + spendPolicies[noteId].strictMode, + flaggers, + owners[0] + ); + _copyFineList(noteId, sliceNoteId); + _moveClaimPortion(noteId, sliceNoteId, amount); + note.amount -= amount; + + emit NoteCreated(sliceNoteId, root, amount, note.token, note.tokenType, note.tokenId); + emit NotePartiallyTakenBack(noteId, sliceNoteId, amount); + } + // ============ Purchase Functions ============ diff --git a/hardhat/test/DelegatableNotes.partialTakeback.test.js b/hardhat/test/DelegatableNotes.partialTakeback.test.js new file mode 100644 index 000000000..17501cfbb --- /dev/null +++ b/hardhat/test/DelegatableNotes.partialTakeback.test.js @@ -0,0 +1,189 @@ +import { expect } from "chai"; +import hre from "hardhat"; + +const { ethers } = hre; + +describe("DelegatableNotes partial takeback", function () { + let notes, alice, bob, carol, seller, paymentToken, erc1155Token, assuranceContract; + + beforeEach(async function () { + [alice, bob, carol, seller] = await ethers.getSigners(); + + const PremintingERC20 = await ethers.getContractFactory("PremintingERC20"); + paymentToken = await PremintingERC20.deploy( + seller.address, + "Delegation Payment Token", + "DPT", + "https://example.com/payment-token.json" + ); + await paymentToken.connect(seller).mint(alice.address, ethers.parseEther("1000")); + await paymentToken.connect(seller).mint(seller.address, ethers.parseEther("1000")); + + const AssuranceContractFactory = await ethers.getContractFactory("AssuranceContractFactory"); + const assuranceFactory = await AssuranceContractFactory.deploy(); + const DelegatableNotes = await ethers.getContractFactory("DelegatableNotes"); + notes = await DelegatableNotes.deploy(await assuranceFactory.getAddress()); + + const PremintingERC1155 = await ethers.getContractFactory("PremintingERC1155"); + erc1155Token = await PremintingERC1155.deploy( + seller.address, + "https://example.com/token/{id}.json", + "https://example.com/contract.json" + ); + + const latestBlock = await ethers.provider.getBlock("latest"); + const deadline = latestBlock.timestamp + 86400; + const tx = await assuranceFactory.createAssuranceContract( + seller.address, + seller.address, + await paymentToken.getAddress(), + await erc1155Token.getAddress(), + "QmTest123" + ); + const receipt = await tx.wait(); + const acEvent = receipt.logs.find( + log => log.fragment && log.fragment.name === "LazyGivingAssuranceContractCreated" + ); + const MultiERC1155AssuranceContract = await ethers.getContractFactory("MultiERC1155AssuranceContract"); + assuranceContract = MultiERC1155AssuranceContract.attach(acEvent.args[0]); + const ValueThresholdCondition = await ethers.getContractFactory("ValueThresholdCondition"); + const condition = await ValueThresholdCondition.deploy( + acEvent.args[0], + ethers.parseEther("0.1"), + deadline + ); + await assuranceContract.connect(seller).setCondition(await condition.getAddress()); + await erc1155Token.connect(seller).mintBatch(await assuranceContract.getAddress(), [1], [1000]); + await assuranceContract.connect(seller).setPricesERC1155([1], [ethers.parseEther("0.1")]); + await erc1155Token.connect(seller).setReceiptTransferBridge(await assuranceContract.getAddress(), true); + }); + + const chain = () => [bob.address, alice.address]; + const beneficiaryId = ethers.id("beneficiary"); + + async function delegatedPayment(amount, delay = 3600) { + await paymentToken.connect(alice).approve(await notes.getAddress(), amount); + const noteId = await notes.connect(alice).deposit.staticCall(await paymentToken.getAddress(), 0, 0, amount); + await notes.connect(alice).deposit(await paymentToken.getAddress(), 0, 0, amount); + await notes.connect(alice).delegateWithDelay(noteId, [alice.address], bob.address, amount, delay); + return noteId; + } + + it("moves a partial amount onto a root-only note and leaves the parent delegated", async function () { + const amount = ethers.parseEther("1"); + const slice = ethers.parseEther("0.4"); + const noteId = await delegatedPayment(amount); + await notes.connect(alice).setUnsuspiciousDelay(noteId, chain(), 1200); + await notes.connect(alice).setStrictMode(noteId, chain(), true); + await notes.connect(alice).setSpendFlagger(noteId, chain(), carol.address, true); + await notes.connect(alice).setFineListed(noteId, chain(), beneficiaryId, true); + const parentHash = (await notes.notes(noteId)).chainHash; + + const tx = notes.connect(alice).partialTakeback(noteId, chain(), slice); + const sliceId = noteId + 1n; + await expect(tx).to.emit(notes, "NoteCreated").withArgs( + sliceId, + alice.address, + slice, + await paymentToken.getAddress(), + 0, + 0 + ); + await expect(tx).to.emit(notes, "NotePartiallyTakenBack").withArgs(noteId, sliceId, slice); + await expect(tx).to.not.emit(notes, "NoteRevoked"); + await expect(tx).to.not.emit(notes, "ChainSplit"); + + const parent = await notes.notes(noteId); + const child = await notes.notes(sliceId); + expect(parent.amount).to.equal(amount - slice); + expect(parent.chainHash).to.equal(parentHash); + const rootOnly = ethers.keccak256(ethers.solidityPacked(["address", "bytes32"], [alice.address, ethers.ZeroHash])); + expect(child.chainHash).to.equal(rootOnly); + expect(child.amount).to.equal(slice); + + const parentPolicy = await notes.spendPolicies(noteId); + const childPolicy = await notes.spendPolicies(sliceId); + expect(childPolicy.delay).to.equal(parentPolicy.delay); + expect(childPolicy.unsuspiciousDelay).to.equal(1200); + expect(childPolicy.strictMode).to.equal(true); + expect(await notes.spendFlaggers(sliceId)).to.deep.equal([carol.address]); + expect(await notes.fineList(sliceId)).to.deep.equal([beneficiaryId]); + expect(await notes.isSpendFlagger(sliceId, carol.address)).to.equal(true); + expect(await notes.fineListed(sliceId, beneficiaryId)).to.equal(true); + }); + + it("reverts for the whole balance, for zero, and while a spend is pending", async function () { + const amount = ethers.parseEther("0.1"); + const noteId = await delegatedPayment(amount); + await expect(notes.connect(alice).partialTakeback(noteId, chain(), amount)) + .to.be.revertedWithCustomError(notes, "SplitAmountMustBePartial"); + await expect(notes.connect(alice).partialTakeback(noteId, chain(), 0)) + .to.be.revertedWithCustomError(notes, "SplitAmountMustBePartial"); + await expect(notes.connect(bob).partialTakeback(noteId, chain(), 1)) + .to.be.revertedWithCustomError(notes, "NotNoteRoot"); + await expect(notes.connect(alice).partialTakeback(noteId, [alice.address], 1)) + .to.be.revertedWithCustomError(notes, "InvalidChain"); + + await notes.connect(bob).scheduleSpend( + noteId, + chain(), + assuranceContract.target, + erc1155Token.target, + 1, + 1 + ); + const nonce = (await notes.pendingSpends(noteId)).nonce; + await expect(notes.connect(alice).partialTakeback(noteId, chain(), 1)) + .to.be.revertedWithCustomError(notes, "SpendAlreadyScheduled"); + const pending = await notes.pendingSpends(noteId); + expect(pending.exists).to.equal(true); + expect(pending.nonce).to.equal(nonce); + expect((await notes.notes(noteId)).amount).to.equal(amount); + }); + + it("lets her purchase the slice immediately even though the parent delay was copied", async function () { + const noteId = await delegatedPayment(ethers.parseEther("0.2"), 3600); + await notes.connect(alice).partialTakeback(noteId, chain(), ethers.parseEther("0.1")); + const sliceId = noteId + 1n; + expect((await notes.spendPolicies(sliceId)).delay).to.equal(3600); + + await expect(notes.connect(bob).purchaseFromPrimaryMarket( + [{ noteId, chain: chain(), shares: 1 }], + assuranceContract.target, + erc1155Token.target, + 1, + 1 + )).to.be.revertedWithCustomError(notes, "SpendMustBeScheduled"); + + await notes.connect(alice).purchaseFromPrimaryMarket( + [{ noteId: sliceId, chain: [alice.address], shares: 1 }], + assuranceContract.target, + erc1155Token.target, + 1, + 1 + ); + expect((await notes.notes(sliceId)).chainHash).to.equal(ethers.ZeroHash); + }); + + it("moves a proportional reimbursement claim on a receipt note", async function () { + const contribution = ethers.parseEther("0.3"); + await paymentToken.connect(alice).approve(await notes.getAddress(), contribution); + await notes.connect(alice).deposit(await paymentToken.getAddress(), 0, 0, contribution); + await notes.connect(alice).purchaseFromPrimaryMarket( + [{ noteId: 1, chain: [alice.address], shares: 3 }], + assuranceContract.target, + erc1155Token.target, + 1, + 3 + ); + const receiptId = 2n; + await notes.connect(alice).delegate(receiptId, [alice.address], bob.address, 3); + await notes.connect(alice).partialTakeback(receiptId, chain(), 1); + + expect((await notes.reimbursementClaims(receiptId)).contribution).to.equal(ethers.parseEther("0.2")); + expect((await notes.reimbursementClaims(3)).contribution).to.equal(ethers.parseEther("0.1")); + expect((await notes.notes(receiptId)).amount).to.equal(2); + expect((await notes.notes(3)).amount).to.equal(1); + expect((await notes.notes(3)).tokenType).to.equal(1); + }); +}); diff --git a/indexer/abis/DelegatableNotesAbi.ts b/indexer/abis/DelegatableNotesAbi.ts index 9a479b5a9..351de08ca 100644 --- a/indexer/abis/DelegatableNotesAbi.ts +++ b/indexer/abis/DelegatableNotesAbi.ts @@ -561,6 +561,31 @@ export const DelegatableNotesAbi = [ "name": "NoteDelegated", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "sliceNoteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "NotePartiallyTakenBack", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -1653,6 +1678,35 @@ export const DelegatableNotesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "partialTakeback", + "outputs": [ + { + "internalType": "uint256", + "name": "sliceNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/indexer/src/events-cache/index.ts b/indexer/src/events-cache/index.ts index 29822f41b..da2773b37 100644 --- a/indexer/src/events-cache/index.ts +++ b/indexer/src/events-cache/index.ts @@ -71,6 +71,7 @@ register("DelegatableNotes:NoteCreated"); register("DelegatableNotes:NoteDelegated"); register("DelegatableNotes:ChainSplit"); register("DelegatableNotes:NoteRevoked"); +register("DelegatableNotes:NotePartiallyTakenBack"); register("DelegatableNotes:NoteDelegateReplaced"); register("DelegatableNotes:FundsReclaimed"); register("DelegatableNotes:NoteConsumed"); diff --git a/sdk/abis/DelegatableNotesAbi.ts b/sdk/abis/DelegatableNotesAbi.ts index 9a479b5a9..351de08ca 100644 --- a/sdk/abis/DelegatableNotesAbi.ts +++ b/sdk/abis/DelegatableNotesAbi.ts @@ -561,6 +561,31 @@ export const DelegatableNotesAbi = [ "name": "NoteDelegated", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "sliceNoteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "NotePartiallyTakenBack", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -1653,6 +1678,35 @@ export const DelegatableNotesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "partialTakeback", + "outputs": [ + { + "internalType": "uint256", + "name": "sliceNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/sdk/src/subsystems/delegation/actions.ts b/sdk/src/subsystems/delegation/actions.ts index 3f29d0921..77ab49731 100644 --- a/sdk/src/subsystems/delegation/actions.ts +++ b/sdk/src/subsystems/delegation/actions.ts @@ -249,25 +249,41 @@ export async function replaceDelegate( } /** - * Revoke a delegated note back to a position in the chain - * - * Revokes a delegation by calling this function from a parent position in the delegation - * chain. This burns the delegated note and returns control to the revoker. - * - * @param clients - Test wallet and public clients for interacting with the blockchain - * @param delegatableNotesContract - The DelegatableNotes contract instance - * @param params - Revocation parameters - * @param params.noteId - The ID of the note to revoke - * @param params.owners - Current delegation chain (leaf first, root last) - * @returns Transaction hash - * - * @example - * ```typescript - * await revokeNote(clients, contract, { - * noteId: 2n, - * owners: [bob.address, alice.address] - * }); - * ``` + * The 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. + */ +export async function partialTakeback( + clients: WriteClients, + delegatableNotesContract: DelegatableNotesContract, + params: { + noteId: bigint; + owners: Address[]; + amount: bigint; + } +): Promise<{ hash: Hash; sliceNoteId: bigint }> { + const hash = await clients.walletClient.writeContract({ + address: delegatableNotesContract.address, + abi: delegatableNotesContract.abi, + functionName: 'partialTakeback', + args: [params.noteId, params.owners, params.amount], + chain: clients.walletClient.chain, + account: clients.walletClient.account!, + }); + + const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }); + const logs = parseEventLogs({ + abi: DelegatableNotesAbi, + eventName: 'NotePartiallyTakenBack', + logs: receipt.logs, + }); + const sliceNoteId = logs[0]?.args.sliceNoteId ?? 0n; + return { hash, sliceNoteId }; +} + +/** + * Revoke a delegated note back to a position in the chain. + * The donor's full takeback, and the delegate handing the note back, are both this call. */ export async function revokeNote( clients: WriteClients, diff --git a/sdk/src/subsystems/delegation/events.ts b/sdk/src/subsystems/delegation/events.ts index 655d83e0e..aa0d198a1 100644 --- a/sdk/src/subsystems/delegation/events.ts +++ b/sdk/src/subsystems/delegation/events.ts @@ -34,6 +34,12 @@ export interface NoteRevokedEvent extends RawEvent { revoker: `0x${string}`; } +export interface NotePartiallyTakenBackEvent extends RawEvent { + noteId: bigint; + sliceNoteId: bigint; + amount: bigint; +} + export interface NoteDelegateReplacedEvent extends RawEvent { fromNoteId: bigint; toNoteId: bigint; diff --git a/sdk/src/subsystems/delegation/folds.test.ts b/sdk/src/subsystems/delegation/folds.test.ts index 115bf9ec5..8a3cfe976 100644 --- a/sdk/src/subsystems/delegation/folds.test.ts +++ b/sdk/src/subsystems/delegation/folds.test.ts @@ -13,6 +13,7 @@ import type { ChainSplitEvent, NoteRevokedEvent, NoteDelegateReplacedEvent, + NotePartiallyTakenBackEvent, FundsReclaimedEvent, NoteConsumedEvent, ERC1155PurchasedEvent, @@ -106,6 +107,20 @@ function makeNoteDelegateReplaced(overrides: Partial }; } +function makeNotePartiallyTakenBack(overrides: Partial = {}): NotePartiallyTakenBackEvent { + return { + contractAddress: NOTE_CONTRACT, + noteId: 1n, + sliceNoteId: 2n, + amount: 40n, + blockNumber: 102n, + blockTimestamp: 1700000200n, + transactionHash: TX_HASH, + logIndex: 1, + ...overrides, + }; +} + function makeNoteRevoked(overrides: Partial = {}): NoteRevokedEvent { return { contractAddress: NOTE_CONTRACT, @@ -381,6 +396,30 @@ describe('foldDelegationState', () => { assert.strictEqual(replaced.chainHash, expectedChainHash([ALICE, CAROL])); }); + it('reduces the parent balance and leaves the slice as the root alone', () => { + const events: DelegationEvent[] = [ + { type: 'noteCreated', event: makeNoteCreated() }, + { type: 'noteDelegated', event: makeNoteDelegated({ delegate: BOB }) }, + { type: 'noteCreated', event: makeNoteCreated({ noteId: 2n, owner: ALICE, amount: 40n, blockNumber: 102n, logIndex: 0 }) }, + { type: 'notePartiallyTakenBack', event: makeNotePartiallyTakenBack() }, + ]; + const { notes, chains } = foldDelegationState(events); + + const parent = notes.get('1'); + assert.ok(parent); + assert.strictEqual(parent.active, true); + assert.strictEqual(parent.amount, '60'); + assert.strictEqual(parent.owner, BOB); + assert.deepStrictEqual(chains.get('1')?.map(link => link.address), [ALICE, BOB]); + + const slice = notes.get('2'); + assert.ok(slice); + assert.strictEqual(slice.amount, '40'); + assert.strictEqual(slice.owner, ALICE); + assert.strictEqual(slice.rootOwner, ALICE); + assert.deepStrictEqual(chains.get('2')?.map(link => link.address), [ALICE]); + }); + it('truncates to the root when the root revokes', () => { const events: DelegationEvent[] = [ { type: 'noteCreated', event: makeNoteCreated() }, diff --git a/sdk/src/subsystems/delegation/folds.ts b/sdk/src/subsystems/delegation/folds.ts index ad2e766fb..10b5734d5 100644 --- a/sdk/src/subsystems/delegation/folds.ts +++ b/sdk/src/subsystems/delegation/folds.ts @@ -6,6 +6,7 @@ import type { ChainSplitEvent, NoteSplitSameChainEvent, NoteRevokedEvent, + NotePartiallyTakenBackEvent, NoteDelegateReplacedEvent, FundsReclaimedEvent, NoteConsumedEvent, @@ -23,6 +24,7 @@ export type DelegationEvent = | { type: 'chainSplit'; event: ChainSplitEvent } | { type: 'noteSplitSameChain'; event: NoteSplitSameChainEvent } | { type: 'noteRevoked'; event: NoteRevokedEvent } + | { type: 'notePartiallyTakenBack'; event: NotePartiallyTakenBackEvent } | { type: 'noteDelegateReplaced'; event: NoteDelegateReplacedEvent } | { type: 'fundsReclaimed'; event: FundsReclaimedEvent } | { type: 'noteConsumed'; event: NoteConsumedEvent } @@ -291,6 +293,18 @@ export function foldDelegationState( break; } + case 'notePartiallyTakenBack': { + // NoteCreated already recorded the slice as the root alone. Leave that + // chain as it is. The parent stays delegated and only loses the amount. + const { noteId, amount, blockTimestamp } = ev.event; + const parent = stateMap.get(contractScopedId(ev.event.contractAddress, noteId)); + if (parent) { + parent.amount = parent.amount > amount ? parent.amount - amount : 0n; + parent.updatedAt = blockTimestamp.toString(); + } + break; + } + case 'noteRevoked': { const { noteId, revoker, blockTimestamp } = ev.event; const id = contractScopedId(ev.event.contractAddress, noteId); diff --git a/sdk/src/subsystems/delegation/queries.ts b/sdk/src/subsystems/delegation/queries.ts index 81ab83a70..202404920 100644 --- a/sdk/src/subsystems/delegation/queries.ts +++ b/sdk/src/subsystems/delegation/queries.ts @@ -21,6 +21,7 @@ import { decodeChainSplitEvent, decodeNoteSplitSameChainEvent, decodeNoteRevokedEvent, + decodeNotePartiallyTakenBackEvent, decodeNoteDelegateReplacedEvent, decodeFundsReclaimedEvent, decodeNoteConsumedEvent, @@ -62,6 +63,11 @@ function decodeDelegationEvents(rawEvents: Awaited { const eventNames = [ - 'NoteCreated', 'NoteDelegated', 'ChainSplit', 'NoteSplitSameChain', 'NoteRevoked', 'FundsReclaimed', + 'NoteCreated', 'NoteDelegated', 'ChainSplit', 'NoteSplitSameChain', 'NoteRevoked', 'NotePartiallyTakenBack', 'FundsReclaimed', 'NoteConsumed', 'ERC1155Purchased', 'RefundedIntoNote', 'ReimbursementClaimedIntoNote', ]; return (await Promise.all(eventNames.map(eventName => fetchEventsComplete(machinery, { eventName })))).flat(); diff --git a/ui/src/delegation/pages/MyNotesPage.test.tsx b/ui/src/delegation/pages/MyNotesPage.test.tsx index 381eca28d..f1e168212 100644 --- a/ui/src/delegation/pages/MyNotesPage.test.tsx +++ b/ui/src/delegation/pages/MyNotesPage.test.tsx @@ -24,6 +24,7 @@ vi.mock('@commonality/sdk/delegation', async () => { getDelegationChain: vi.fn(), delegateNote: vi.fn(), revokeNote: vi.fn(), + partialTakeback: vi.fn(), reclaimFunds: vi.fn(), getActiveStandingPledgesByUser: vi.fn(), getDonationActivityByRoot: vi.fn(), @@ -353,7 +354,8 @@ describe('MyNotesPage', () => { render() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Revoke' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Takeback' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Partial takeback' })).toBeInTheDocument() }) }) @@ -560,9 +562,10 @@ describe('MyNotesPage', () => { render() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Revoke' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Takeback' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Partial takeback' })).toBeInTheDocument() }) - fireEvent.click(screen.getByRole('button', { name: 'Revoke' })) + fireEvent.click(screen.getByRole('button', { name: 'Takeback' })) await waitFor(() => { expect(getDelegationChain).toHaveBeenCalledWith(mockMachinery, '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1') @@ -594,7 +597,7 @@ describe('MyNotesPage', () => { render() await waitFor(() => { - fireEvent.click(screen.getByRole('button', { name: 'Revoke' })) + fireEvent.click(screen.getByRole('button', { name: 'Takeback' })) }) await waitFor(() => { diff --git a/ui/src/delegation/pages/MyNotesPage.tsx b/ui/src/delegation/pages/MyNotesPage.tsx index da28b7d40..b63e7ea4c 100644 --- a/ui/src/delegation/pages/MyNotesPage.tsx +++ b/ui/src/delegation/pages/MyNotesPage.tsx @@ -22,11 +22,11 @@ import { TextField, } from '@mui/material' import { Link as RouterLink } from 'react-router-dom' -import { useAccount } from 'wagmi' +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, 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, revokeNote, reclaimFunds, getActiveStandingPledgesByUser, cancelStandingPledge, type DonationActivity, type Note, type StandingPledge, type DelegatableNotesContract, type RecurringPledgesContract } from '@commonality/sdk/delegation' import { fetchEvents, type Currency, type IpfsCidV1 } from '@commonality/sdk/utils' import { getDomainUrl, useMachinery } from '../../shared' import { useWriteClients } from '../../shared' @@ -76,6 +76,7 @@ function NoteCard({ showDelegatedFrom, showCurrentOwner, showRevoke, + showPartialTakeback, showReclaim, showDelegate, showReplace, @@ -83,6 +84,7 @@ function NoteCard({ onDelegate, onReplace, onRevoke, + onPartialTakeback, onReclaim, onGiveBack, }: { @@ -90,6 +92,7 @@ function NoteCard({ showDelegatedFrom?: boolean showCurrentOwner?: boolean showRevoke?: boolean + showPartialTakeback?: boolean showReclaim?: boolean showDelegate?: boolean showReplace?: boolean @@ -97,9 +100,34 @@ function NoteCard({ onDelegate?: (note: Note) => void onReplace?: (note: Note) => void onRevoke?: (note: Note) => void + onPartialTakeback?: (note: Note, amount: string) => void onReclaim?: (note: Note) => void onGiveBack?: (note: Note) => void }) { + const publicClient = usePublicClient() + const [spendPending, setSpendPending] = useState(false) + const [partialOpen, setPartialOpen] = useState(false) + + useEffect(() => { + if (!showPartialTakeback || !publicClient) { + setSpendPending(false) + return + } + let cancelled = false + ;(async () => { + const pending = await publicClient.readContract({ + address: note.contractAddress as `0x${string}`, + abi: DelegatableNotesAbi, + functionName: 'pendingSpends', + args: [BigInt(note.id)], + }) as readonly [unknown, unknown, bigint, bigint, bigint, bigint, bigint, boolean, boolean] + if (!cancelled) setSpendPending(pending[8]) + })().catch(() => { + if (!cancelled) setSpendPending(false) + }) + return () => { cancelled = true } + }, [publicClient, showPartialTakeback, note.contractAddress, note.id]) + return ( @@ -135,7 +163,7 @@ function NoteCard({ - {(showDelegate || showReplace || showGiveBack || showRevoke || showReclaim) && ( + {(showDelegate || showReplace || showGiveBack || showRevoke || showPartialTakeback || showReclaim) && ( {showDelegate && ( )} {showRevoke && ( @@ -172,7 +200,17 @@ function NoteCard({ color="warning" onClick={(e) => { e.preventDefault(); onRevoke?.(note) }} > - Revoke + Takeback + + )} + {showPartialTakeback && ( + )} {showReclaim && ( @@ -187,10 +225,67 @@ function NoteCard({ )} )} + setPartialOpen(false)} + onSubmit={(amount) => onPartialTakeback?.(note, amount)} + /> ) } +function PartialTakebackDialog({ + open, + note, + onClose, + onSubmit, +}: { + open: boolean + note: Note | null + onClose: () => void + onSubmit: (amount: string) => void +}) { + const [amount, setAmount] = useState('') + const balance = note ? BigInt(note.amount) : 0n + let parsed: bigint | null = null + try { + if (amount) parsed = parseEther(amount) + } catch { + parsed = null + } + const valid = parsed !== null && parsed > 0n && parsed < balance + + return ( + + Partial takeback of fund #{note?.id} + + + You are taking this amount back. The rest stays with the delegate under the same rules. This does not approve a payment. + + setAmount(e.target.value)} + fullWidth + margin="normal" + helperText={note ? `Greater than zero and less than ${formatEther(balance)} ETH` : ''} + /> + + + + + + + ) +} + function DelegateDialog({ open, note, @@ -586,8 +681,33 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg }) await loadNotes() } catch (err) { - console.error('Revoke failed:', err) - setActionError(err instanceof Error ? err.message : 'Revocation failed') + console.error('Takeback failed:', err) + setActionError(err instanceof Error ? err.message : 'Takeback failed') + } finally { + setActionLoading(false) + } + } + + const handlePartialTakeback = async (note: Note, amount: string) => { + const clients = getClients() + const contract = getContract(note.contractAddress) + if (!clients || !contract) return + try { + setActionLoading(true) + setActionError(null) + const chain = await getDelegationChain(machinery, noteScopedKey(note)) + const owners = chain + .sort((a, b) => b.position - a.position) + .map(link => link.address as `0x${string}`) + await partialTakeback(clients, contract, { + noteId: BigInt(note.id), + owners, + amount: parseEther(amount), + }) + await loadNotes() + } catch (err) { + console.error('Partial takeback failed:', err) + setActionError(err instanceof Error ? err.message : 'Partial takeback failed') } finally { setActionLoading(false) } @@ -757,12 +877,14 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg note={note} showCurrentOwner showRevoke={isDelegate(note)} + showPartialTakeback={isDelegate(note)} showReplace={isDelegate(note)} showReclaim={!isDelegate(note)} showDelegate={!isDelegate(note)} onDelegate={handleDelegate} onReplace={handleReplace} onRevoke={handleRevoke} + onPartialTakeback={handlePartialTakeback} onReclaim={handleReclaim} /> ))} diff --git a/ui/src/delegation/pages/NoteDetailPage.test.tsx b/ui/src/delegation/pages/NoteDetailPage.test.tsx index 9f1ca8ff9..b5234e004 100644 --- a/ui/src/delegation/pages/NoteDetailPage.test.tsx +++ b/ui/src/delegation/pages/NoteDetailPage.test.tsx @@ -353,7 +353,8 @@ describe('NoteDetailPage', () => { render() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Revoke' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Takeback' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Partial takeback' })).toBeInTheDocument() }) }) @@ -473,7 +474,8 @@ describe('NoteDetailPage', () => { render() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Revoke' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Takeback' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Partial takeback' })).toBeInTheDocument() }) }) }) diff --git a/ui/src/delegation/pages/NoteDetailPage.tsx b/ui/src/delegation/pages/NoteDetailPage.tsx index 62e4ddca3..86be97601 100644 --- a/ui/src/delegation/pages/NoteDetailPage.tsx +++ b/ui/src/delegation/pages/NoteDetailPage.tsx @@ -20,10 +20,10 @@ import { DialogActions, Autocomplete, } from '@mui/material' -import { useAccount } from 'wagmi' +import { useAccount, usePublicClient } from 'wagmi' import { formatEther, parseEther } from 'viem' import { DelegatableNotesAbi, NoteIntentAbi } from '@commonality/sdk/abis' -import { getNote, getDelegationChain, getNoteIntentAttestation, attestNoteIntent, delegateNote, replaceDelegate, revokeNote, reclaimFunds, purchaseFromPrimaryMarketWithNotes, refundNote, type Note, type NoteIntentAttestation, type DelegationChainLink, type NoteIntentContract } from '@commonality/sdk/delegation' +import { getNote, getDelegationChain, getNoteIntentAttestation, attestNoteIntent, delegateNote, partialTakeback, replaceDelegate, revokeNote, reclaimFunds, purchaseFromPrimaryMarketWithNotes, refundNote, type Note, type NoteIntentAttestation, type DelegationChainLink, type NoteIntentContract } from '@commonality/sdk/delegation' import { getStatement, type StatementListItem } from '@commonality/sdk/conceptspace' import type { IpfsCidV1 } from '@commonality/sdk/utils' import { getProjectsFiltered, type ProjectWithMetrics, getProjectTokens, type ProjectToken } from '@commonality/sdk/lazy-giving' @@ -317,6 +317,7 @@ export function NoteDetailPage() { const { address } = useAccount() const writeClients = useWriteClients(address) const machinery = useMachinery() + const publicClient = usePublicClient() const [note, setNote] = useState(null) const [chain, setChain] = useState([]) @@ -325,6 +326,9 @@ export function NoteDetailPage() { const [actionError, setActionError] = useState(null) const [actionLoading, setActionLoading] = useState(false) const [delegateDialogOpen, setDelegateDialogOpen] = useState(false) + const [partialOpen, setPartialOpen] = useState(false) + const [partialAmount, setPartialAmount] = useState('') + const [spendPending, setSpendPending] = useState(false) const [delegateMode, setDelegateMode] = useState<'delegate' | 'replace'>('delegate') const [spendDialogOpen, setSpendDialogOpen] = useState(false) const [projects, setProjects] = useState([]) @@ -417,6 +421,26 @@ export function NoteDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [routeNoteId]) + useEffect(() => { + if (!publicClient || !note) { + setSpendPending(false) + return + } + let cancelled = false + ;(async () => { + const pending = await publicClient.readContract({ + address: note.contractAddress as `0x${string}`, + abi: DelegatableNotesAbi, + functionName: 'pendingSpends', + args: [BigInt(note.id)], + }) as readonly [unknown, unknown, bigint, bigint, bigint, bigint, bigint, boolean, boolean] + if (!cancelled) setSpendPending(pending[8]) + })().catch(() => { + if (!cancelled) setSpendPending(false) + }) + return () => { cancelled = true } + }, [publicClient, note]) + useEffect(() => { if (spendDialogOpen && projects.length === 0) { loadProjects() @@ -487,8 +511,35 @@ export function NoteDetailPage() { }) await loadNoteData() } catch (err) { - console.error('Revoke failed:', err) - setActionError(err instanceof Error ? err.message : 'Revocation failed') + console.error('Takeback failed:', err) + setActionError(err instanceof Error ? err.message : 'Takeback failed') + } finally { + setActionLoading(false) + } + } + + const handlePartialTakeback = async () => { + if (!note) return + const clients = getClients() + const contract = getContract(note.contractAddress) + if (!clients || !contract) return + try { + setActionLoading(true) + setActionError(null) + const owners = [...chain] + .sort((a, b) => b.position - a.position) + .map(link => link.address as `0x${string}`) + await partialTakeback(clients, contract, { + noteId: BigInt(note.id), + owners, + amount: parseEther(partialAmount), + }) + setPartialOpen(false) + setPartialAmount('') + await loadNoteData() + } catch (err) { + console.error('Partial takeback failed:', err) + setActionError(err instanceof Error ? err.message : 'Partial takeback failed') } finally { setActionLoading(false) } @@ -661,12 +712,12 @@ export function NoteDetailPage() { const isCurrentLeafOwner = note.owner.toLowerCase() === address?.toLowerCase() const isRootOwner = note.rootOwner.toLowerCase() === address?.toLowerCase() - const isChainMember = chain.some(link => link.address.toLowerCase() === address?.toLowerCase()) const isUndelegated = !isDelegate(note) const canDelegate = isCurrentLeafOwner && chain.length <= 1 const canReplace = isRootOwner && chain.length === 2 const canResign = isCurrentLeafOwner && chain.length > 1 - const canRevoke = isChainMember && !isCurrentLeafOwner + const canTakeback = isRootOwner && chain.length > 1 + const canPartialTakeback = canTakeback const canReclaim = isRootOwner && isUndelegated const canSpend = isCurrentLeafOwner && isEthNote(note) && chain.length <= 2 const canRefund = note.active && isCurrentLeafOwner && note.tokenType === 1 && refundProject !== null @@ -796,12 +847,17 @@ export function NoteDetailPage() { )} {canResign && ( )} - {canRevoke && ( + {canTakeback && ( + )} + {canPartialTakeback && ( + )} {canReclaim && ( @@ -819,7 +875,7 @@ export function NoteDetailPage() { Refund into a Fund )} - {!canDelegate && !canReplace && !canResign && !canRevoke && !canReclaim && !canSpend && !canRefund && ( + {!canDelegate && !canReplace && !canResign && !canTakeback && !canPartialTakeback && !canReclaim && !canSpend && !canRefund && ( You don't have any actions available for this note. @@ -836,6 +892,29 @@ export function NoteDetailPage() { onSubmit={handleDelegateSubmit} /> + setPartialOpen(false)} maxWidth="sm" fullWidth> + Partial takeback of fund #{note.id} + + + You are taking this amount back. The rest stays with the delegate under the same rules. This does not approve a payment. + + setPartialAmount(e.target.value)} + fullWidth + margin="normal" + helperText={`Greater than zero and less than ${formatEther(BigInt(note.amount))} ETH`} + /> + + + + + + + Date: Sat, 26 Sep 2026 20:38:55 -0400 Subject: [PATCH 5/5] Pin the fixed-controller registry and close the spend-classification review gaps. The factory only accepts its deployed payout registry. Pending deadlines keep the standing delay from schedule time, the indexer watches the new factory, and the one-hop revoke integration test matches the hop limit. --- TODO.md | 3 +- docker-compose.yml | 3 + .../contracts/delegation/DelegatableNotes.sol | 34 +++++- .../FixedControllerFactory.sol | 8 ++ hardhat/scripts/deploy-incremental.js | 2 +- .../ConceptspaceAcceptanceJourneys.test.js | 2 +- ...legatableNotes.spendClassification.test.js | 70 ++++++++++- .../DelegatableNotes.waitingPeriod.test.js | 8 ++ hardhat/test/ProjectFactory.test.js | 2 +- hardhat/test/RecurringPledges.test.js | 37 ++++++ indexer/abis/DelegatableNotesAbi.ts | 19 +++ indexer/abis/FixedControllerFactoryAbi.ts | 115 ++++++++++++++++++ indexer/scripts/sync-abis.ts | 1 + indexer/src/api/index.ts | 3 +- indexer/src/events-cache/index.ts | 2 + .../indexing/conceptspaceConfigGraph.test.ts | 1 + .../src/indexing/contractCapabilities.test.ts | 22 ++++ indexer/src/indexing/contractCapabilities.ts | 2 + indexer/src/indexing/fundingContracts.ts | 27 ++++ .../src/delegation/delegation-basic.test.ts | 31 ++--- sdk/abis/DelegatableNotesAbi.ts | 19 +++ sdk/src/subsystems/delegation/actions.test.ts | 67 ++++++++++ sdk/src/subsystems/delegation/actions.ts | 14 ++- .../delegation/donation-activity.test.ts | 1 + sdk/src/subsystems/delegation/queries.ts | 1 + sdk/src/subsystems/delegation/types.ts | 1 + .../delegation/spend-classification.md | 4 +- .../components/DonorPendingSpends.tsx | 13 +- .../delegation/components/FineListPanel.tsx | 27 +++- .../components/PendingSpendCard.tsx | 24 +++- .../components/SpendPolicyPanel.tsx | 21 +++- ui/src/delegation/pages/MyNotesPage.test.tsx | 1 + ui/src/delegation/pages/MyNotesPage.tsx | 32 ++--- ui/src/delegation/pages/NoteDetailPage.tsx | 17 ++- ui/src/delegation/spendClass.test.ts | 4 +- ui/src/delegation/spendClass.ts | 5 +- ui/src/delegation/utils.test.ts | 18 ++- ui/src/delegation/utils.ts | 10 ++ .../components/PendingProjectSpends.tsx | 25 +++- .../lazy-giving/pages/ProjectDetailPage.tsx | 2 +- 40 files changed, 608 insertions(+), 90 deletions(-) create mode 100644 indexer/abis/FixedControllerFactoryAbi.ts create mode 100644 sdk/src/subsystems/delegation/actions.test.ts diff --git a/TODO.md b/TODO.md index 4cd083002..d67366e84 100644 --- a/TODO.md +++ b/TODO.md @@ -22,11 +22,12 @@ 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). + - 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/docker-compose.yml b/docker-compose.yml index bd828ba9b..926770f58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -641,6 +641,9 @@ services: - IPFS_GATEWAY=http://ipfs:8080/ipfs - IPFS_API=http://ipfs:5001 - HOME=/tmp + # Ponder starts Vite/Chokidar. This host's inotify instance cap is 128, + # and watching /app exhausts it (EMFILE). Match the Render blueprint. + - CHOKIDAR_USEPOLLING=true healthcheck: test: | curl -f -X POST -H "Content-Type: application/json" \ diff --git a/hardhat/contracts/delegation/DelegatableNotes.sol b/hardhat/contracts/delegation/DelegatableNotes.sol index 68dfab5bb..339162bd1 100644 --- a/hardhat/contracts/delegation/DelegatableNotes.sol +++ b/hardhat/contracts/delegation/DelegatableNotes.sol @@ -134,6 +134,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { mapping(uint256 => ReimbursementClaim) public reimbursementClaims; mapping(uint256 => SpendPolicy) public spendPolicies; mapping(uint256 => PendingSpend) public pendingSpends; + // Standing-delay edits affect future schedules only, including an implicit U clamp. + mapping(uint256 => SpendPolicy) private pendingPolicies; mapping(uint256 => mapping(bytes32 => bool)) public fineListed; mapping(uint256 => bytes32[]) private fineListIds; mapping(uint256 => mapping(address => bool)) public isSpendFlagger; @@ -599,6 +601,15 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { }); _moveClaimPortion(noteId, delegatedNoteId, amountToDelegate); + _writePolicy( + delegatedNoteId, + spendPolicies[noteId].delay, + spendPolicies[noteId].unsuspiciousDelay, + spendPolicies[noteId].strictMode, + spendFlaggerList[noteId], + delegateTo + ); + _copyFineList(noteId, delegatedNoteId); // Update original note with remainder (keep same chain) note.amount = remainderAmount; @@ -738,6 +749,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { _requireRoot(noteId, owners); if (delay > spendPolicies[noteId].delay) revert UnsuspiciousDelayExceedsStanding(); spendPolicies[noteId].unsuspiciousDelay = delay; + pendingPolicies[noteId].unsuspiciousDelay = delay; emit UnsuspiciousDelaySet(noteId, delay); _revisePending(noteId); } @@ -875,7 +887,6 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { (uint256 delay, uint8 class) = _classification(noteId, primaryMarket); if (delay == 0) { - emit SpendClassResolved(noteId, class, _routeBeneficiaryId(primaryMarket)); PurchaseShare[] memory shares = new PurchaseShare[](1); shares[0] = PurchaseShare({ noteId: noteId, chain: owners, shares: count }); _purchaseFromPrimaryMarket(shares, primaryMarket, erc1155Contract, tokenId, count); @@ -884,6 +895,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { uint256 nonce = nextScheduleNonce++; uint256 scheduledAt = block.timestamp; + pendingPolicies[noteId] = spendPolicies[noteId]; uint256 deadline = scheduledAt + delay; pendingSpends[noteId] = PendingSpend({ primaryMarket: primaryMarket, @@ -970,6 +982,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { (, uint8 class) = _classification(noteId, pending.primaryMarket); emit SpendClassResolved(noteId, class, _routeBeneficiaryId(pending.primaryMarket)); delete pendingSpends[noteId]; + delete pendingPolicies[noteId]; scheduledExecution = true; PurchaseShare[] memory shares = new PurchaseShare[](1); shares[0] = PurchaseShare({ noteId: noteId, chain: owners, shares: pending.count }); @@ -1021,6 +1034,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { if (!pending.exists) return; uint256 nonce = pending.nonce; delete pendingSpends[noteId]; + delete pendingPolicies[noteId]; emit SpendScheduleCleared(noteId, nonce); } @@ -1060,14 +1074,25 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { function _revisePending(uint256 noteId) private { PendingSpend storage pending = pendingSpends[noteId]; if (!pending.exists) return; - (uint256 delay, uint8 class) = _classification(noteId, pending.primaryMarket); - uint256 deadline = pending.scheduledAt + delay; + uint256 deadline = effectivePendingSpendDeadline(noteId); if (deadline == pending.deadline) return; pending.deadline = deadline; + (, uint8 class) = _classification(noteId, pending.primaryMarket); emit SpendDeadlineRevised(noteId, pending.nonce, deadline); emit SpendClassResolved(noteId, class, _routeBeneficiaryId(pending.primaryMarket)); } + /// @notice Current deadline, including registry changes not yet stored by an execution attempt. + function effectivePendingSpendDeadline(uint256 noteId) public view returns (uint256) { + PendingSpend storage pending = pendingSpends[noteId]; + if (!pending.exists) return 0; + (, uint8 class) = _classification(noteId, pending.primaryMarket); + uint256 delay = class == CLASS_UNSUSPICIOUS + ? pendingPolicies[noteId].unsuspiciousDelay + : pendingPolicies[noteId].delay; + return pending.scheduledAt + delay; + } + function _classification(uint256 noteId, address market) private view returns (uint256 delay, uint8 class) { delay = spendPolicies[noteId].delay; class = CLASS_UNMARKED; @@ -1475,8 +1500,9 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { if (note.chainHash != expectedHash) revert InvalidChain(); if (pendingSpends[purchaseShare.noteId].exists) revert SpendAlreadyScheduled(); if (!scheduledExecution && purchaseShare.chain.length > 1) { - (uint256 effectiveDelay,) = _classification(purchaseShare.noteId, primaryMarket); + (uint256 effectiveDelay, uint8 class) = _classification(purchaseShare.noteId, primaryMarket); if (effectiveDelay != 0) revert SpendMustBeScheduled(); + emit SpendClassResolved(purchaseShare.noteId, class, _routeBeneficiaryId(primaryMarket)); } if (!scheduledExecution && purchaseShare.chain[0] != caller) revert NotNoteOwner(); if (purchaseShare.chain.length > 2) revert DelegationHopLimit(); diff --git a/hardhat/contracts/individual-projects/FixedControllerFactory.sol b/hardhat/contracts/individual-projects/FixedControllerFactory.sol index 8738ee936..2b94e7724 100644 --- a/hardhat/contracts/individual-projects/FixedControllerFactory.sol +++ b/hardhat/contracts/individual-projects/FixedControllerFactory.sol @@ -6,8 +6,15 @@ import {FixedControllerAssuranceContract} from "./FixedControllerAssuranceContra /// @notice Deploys fixed-controller assurance contracts. DelegatableNotes authorizes /// this factory on its own so AssuranceContractFactory stays under the size limit. contract FixedControllerFactory { + error InvalidRegistry(); + address public immutable beneficiaryRegistry; mapping(address => bool) public isDeployedPrimaryMarket; + constructor(address registry) { + if (registry == address(0)) revert InvalidRegistry(); + beneficiaryRegistry = registry; + } + event FixedControllerAssuranceCreated(address indexed assuranceContract); function create( @@ -19,6 +26,7 @@ contract FixedControllerFactory { bytes32 beneficiaryId, address registry ) external returns (FixedControllerAssuranceContract ac) { + if (registry != beneficiaryRegistry) revert InvalidRegistry(); ac = new FixedControllerAssuranceContract( owner, recipient, paymentToken, erc1155Addr, projectMetadataCid, beneficiaryId, registry ); diff --git a/hardhat/scripts/deploy-incremental.js b/hardhat/scripts/deploy-incremental.js index 2391b1fea..e34d7168a 100644 --- a/hardhat/scripts/deploy-incremental.js +++ b/hardhat/scripts/deploy-incremental.js @@ -336,7 +336,7 @@ async function main() { } await deployOrReuse('NudgePublications', 'NudgePublications'); await deployOrReuse('PublishedData', 'PublishedData'); - await deployOrReuse('FixedControllerFactory', 'FixedControllerFactory'); + await deployOrReuse('FixedControllerFactory', 'FixedControllerFactory', [addresses.BeneficiaryRegistry]); if (addresses.DelegatableNotes && addresses.FixedControllerFactory && (freshlyDeployed.has('DelegatableNotes') || freshlyDeployed.has('FixedControllerFactory'))) { const d = await ownerCapable(await ethers.getContractAt('DelegatableNotes', addresses.DelegatableNotes)); if (!(await d.authorizedPrimaryMarketFactories(addresses.FixedControllerFactory))) { diff --git a/hardhat/test/ConceptspaceAcceptanceJourneys.test.js b/hardhat/test/ConceptspaceAcceptanceJourneys.test.js index e2e69396c..132a5c241 100644 --- a/hardhat/test/ConceptspaceAcceptanceJourneys.test.js +++ b/hardhat/test/ConceptspaceAcceptanceJourneys.test.js @@ -98,12 +98,12 @@ describe("Conceptspace acceptance journeys", function () { const tokenFactory = await ethers.deployContract("PremintingERC1155Factory"); const assuranceFactory = await ethers.deployContract("AssuranceContractFactory"); - const fixedControllerFactory = await ethers.deployContract("FixedControllerFactory"); const conditionFactory = await ethers.deployContract("ValueThresholdConditionFactory"); const verifier = await ethers.deployContract("MockBeneficiaryVerifier"); const paymentToken = await ethers.deployContract("FreeERC20", ["USD Coin", "USDC", 6]); const beneficiaryIdentity = await ethers.deployContract("BeneficiaryIdentity", [verifier.target]); const beneficiaryRegistry = await ethers.deployContract("BeneficiaryRegistry", [beneficiaryIdentity.target]); + const fixedControllerFactory = await ethers.deployContract("FixedControllerFactory", [beneficiaryRegistry.target]); const beneficiaryEscrow = await ethers.deployContract("BeneficiaryEscrow", [ beneficiaryRegistry.target, paymentToken.target, diff --git a/hardhat/test/DelegatableNotes.spendClassification.test.js b/hardhat/test/DelegatableNotes.spendClassification.test.js index abaef75a1..a0bad0935 100644 --- a/hardhat/test/DelegatableNotes.spendClassification.test.js +++ b/hardhat/test/DelegatableNotes.spendClassification.test.js @@ -4,7 +4,7 @@ import hre from "hardhat"; const { ethers } = hre; describe("DelegatableNotes spend classification", function () { - let notes, alice, bob, market, registry; + let notes, alice, bob, market, registry, token; const beneficiaryId = ethers.keccak256(ethers.toUtf8Bytes("dns:example.org")); beforeEach(async function () { @@ -17,7 +17,7 @@ describe("DelegatableNotes spend classification", function () { await registry.setPayout(bob.address); await market.setRoute(beneficiaryId, bob.address, await registry.getAddress()); - const token = await ethers.deployContract("PremintingERC20", [ + token = await ethers.deployContract("PremintingERC20", [ alice.address, "Token", "TKN", "https://example.com/t.json", ]); await token.connect(alice).mint(alice.address, ethers.parseEther("10")); @@ -55,6 +55,8 @@ describe("DelegatableNotes spend classification", function () { expect(first.deadline).to.equal(first.scheduledAt + 10n); await registry.setPayout(alice.address); + expect(await notes.effectivePendingSpendDeadline(1)).to.equal(first.scheduledAt + 100n); + expect((await notes.pendingSpends(1)).deadline).to.equal(first.scheduledAt + 10n); await notes.connect(bob).executeScheduledSpend(1, owners()); const revised = await notes.pendingSpends(1); expect(revised.exists).to.equal(true); @@ -68,6 +70,60 @@ describe("DelegatableNotes spend classification", function () { ).to.be.revertedWithCustomError(notes, "UnsuspiciousDelayExceedsStanding"); }); + it("preserves scheduled delays across standing-delay changes and reclassification", async function () { + await notes.connect(alice).setUnsuspiciousDelay(1, owners(), 40); + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, true); + await notes.connect(bob).scheduleSpend(1, owners(), market.target, alice.address, 1, 1); + const first = await notes.pendingSpends(1); + await notes.connect(alice).setSpendDelay(1, owners(), 10); + await expect(notes.connect(bob).executeScheduledSpend(1, owners())) + .to.be.revertedWithCustomError(notes, "SpendNotDue"); + expect((await notes.pendingSpends(1)).deadline).to.equal(first.deadline); + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, false); + expect((await notes.pendingSpends(1)).deadline).to.equal(first.scheduledAt + 100n); + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, true); + expect((await notes.pendingSpends(1)).deadline).to.equal(first.scheduledAt + 40n); + await notes.connect(alice).setUnsuspiciousDelay(1, owners(), 5); + expect((await notes.pendingSpends(1)).deadline).to.equal(first.scheduledAt + 5n); + }); + + it("rejects a forged beneficiary registry on an authorized fixed-controller factory", async function () { + const factory = await ethers.deployContract("FixedControllerFactory", [registry.target]); + await notes.setPrimaryMarketFactoryAuthorization(factory.target, true); + const forged = await ethers.deployContract("MockPayoutRegistry"); + await forged.setPayout(bob.address); + await expect(factory.connect(bob).create( + bob.address, bob.address, alice.address, alice.address, "ipfs://fake", + beneficiaryId, forged.target + )).to.be.revertedWithCustomError(factory, "InvalidRegistry"); + }); + + it("records an immediate direct unsuspicious purchase from an authorized fixed route", async function () { + const factory = await ethers.deployContract("FixedControllerFactory", [registry.target]); + await notes.setPrimaryMarketFactoryAuthorization(factory.target, true); + const receiptToken = await ethers.deployContract("PremintingERC1155", [ + bob.address, "ipfs://tokens/{id}", "ipfs://contract", + ]); + const tx = await factory.create( + bob.address, bob.address, token.target, receiptToken.target, "ipfs://project", beneficiaryId, registry.target + ); + const receipt = await tx.wait(); + const event = receipt.logs.find(log => log.fragment?.name === "FixedControllerAssuranceCreated"); + const fixed = await ethers.getContractAt("FixedControllerAssuranceContract", event.args.assuranceContract); + const deadline = (await ethers.provider.getBlock("latest")).timestamp + 3600; + const condition = await ethers.deployContract("ValueThresholdCondition", [fixed.target, ethers.parseEther("0.1"), deadline]); + await fixed.connect(bob).setCondition(condition.target); + await receiptToken.connect(bob).mintBatch(fixed.target, [1], [10]); + await receiptToken.connect(bob).setReceiptTransferBridge(fixed.target, true); + await fixed.connect(bob).setPricesERC1155([1], [ethers.parseEther("0.1")]); + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, true); + await expect(notes.connect(bob).purchaseFromPrimaryMarket( + [{ noteId: 1, chain: owners(), shares: 1 }], fixed.target, receiptToken.target, 1, 1 + )).to.emit(notes, "SpendClassResolved").withArgs(1, 1, beneficiaryId); + expect((await notes.pendingSpends(1)).exists).to.equal(false); + expect((await notes.notes(1)).chainHash).to.equal(ethers.ZeroHash); + }); + it("does not treat a claim-later route as unsuspicious", async function () { await market.setRoute(beneficiaryId, await market.getAddress(), await registry.getAddress()); await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, true); @@ -75,4 +131,14 @@ describe("DelegatableNotes spend classification", function () { expect(delay).to.equal(100n); expect(spendClass).to.equal(0); }); + + it("keeps the fine list and delays when a takeback is partially delegated again", async function () { + await notes.connect(alice).setUnsuspiciousDelay(1, owners(), 40); + await notes.connect(alice).setFineListed(1, owners(), beneficiaryId, true); + await notes.connect(alice).partialTakeback(1, owners(), ethers.parseEther("0.05")); + await notes.connect(alice).delegate(2, [alice.address], bob.address, ethers.parseEther("0.02")); + expect(await notes.fineList(3)).to.deep.equal([beneficiaryId]); + expect((await notes.spendPolicies(3)).delay).to.equal(100); + expect((await notes.spendPolicies(3)).unsuspiciousDelay).to.equal(40); + }); }); diff --git a/hardhat/test/DelegatableNotes.waitingPeriod.test.js b/hardhat/test/DelegatableNotes.waitingPeriod.test.js index cb25770aa..c563d77ba 100644 --- a/hardhat/test/DelegatableNotes.waitingPeriod.test.js +++ b/hardhat/test/DelegatableNotes.waitingPeriod.test.js @@ -194,6 +194,14 @@ describe("DelegatableNotes waiting period", function () { await notes.connect(alice).setSpendDelay(noteId, [bob.address, alice.address], 10); expect((await notes.pendingSpends(noteId)).deadline).to.equal(deadline); expect((await notes.spendPolicies(noteId)).delay).to.equal(10); + await time.increase(20); + await expect(notes.connect(bob).executeScheduledSpend(noteId, [bob.address, alice.address])) + .to.be.revertedWithCustomError(notes, "SpendNotDue"); + await notes.connect(alice).setUnsuspiciousDelay(noteId, [bob.address, alice.address], 5); + expect((await notes.pendingSpends(noteId)).deadline).to.equal(deadline); + await time.increaseTo(deadline); + await notes.connect(bob).executeScheduledSpend(noteId, [bob.address, alice.address]); + expect((await notes.pendingSpends(noteId)).exists).to.equal(false); }); it("does not apply the delay when the donor spends the note herself", async function () { diff --git a/hardhat/test/ProjectFactory.test.js b/hardhat/test/ProjectFactory.test.js index 93069d36c..6ff3cc586 100644 --- a/hardhat/test/ProjectFactory.test.js +++ b/hardhat/test/ProjectFactory.test.js @@ -6,11 +6,11 @@ const { ethers } = hardhat; async function deployProjectFactory() { const tokenFactory = await ethers.deployContract('PremintingERC1155Factory'); const assuranceFactory = await ethers.deployContract('AssuranceContractFactory'); - const fixedControllerFactory = await ethers.deployContract('FixedControllerFactory'); const conditionFactory = await ethers.deployContract('ValueThresholdConditionFactory'); const verifier = await ethers.deployContract('MockBeneficiaryVerifier'); const beneficiaryIdentity = await ethers.deployContract('BeneficiaryIdentity', [verifier.target]); const beneficiaryRegistry = await ethers.deployContract('BeneficiaryRegistry', [beneficiaryIdentity.target]); + const fixedControllerFactory = await ethers.deployContract('FixedControllerFactory', [beneficiaryRegistry.target]); const paymentToken = await ethers.deployContract('FreeERC20', ['USD Coin', 'USDC', 6]); const beneficiaryEscrow = await ethers.deployContract('BeneficiaryEscrow', [beneficiaryRegistry.target, paymentToken.target]); const projectFactory = await ethers.deployContract('ProjectFactory', [ diff --git a/hardhat/test/RecurringPledges.test.js b/hardhat/test/RecurringPledges.test.js index 92b869bc3..0495fb079 100644 --- a/hardhat/test/RecurringPledges.test.js +++ b/hardhat/test/RecurringPledges.test.js @@ -28,6 +28,43 @@ async function deployFixture() { } describe("RecurringPledges", function () { + 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] + ); + await expect(recurringPledges.connect(bob).setPledgeFineListed(1, id, true)) + .to.be.revertedWithCustomError(recurringPledges, "NotPledgeOwner"); + await expect(recurringPledges.connect(bob).setPledgeUnsuspiciousDelay(1, 20)) + .to.be.revertedWithCustomError(recurringPledges, "NotPledgeOwner"); + await expect(recurringPledges.setPledgeUnsuspiciousDelay(1, 101)) + .to.be.revertedWithCustomError(recurringPledges, "UnsuspiciousDelayExceedsStanding"); + await recurringPledges.setPledgeFineListed(1, id, true); + await recurringPledges.setPledgeUnsuspiciousDelay(1, 40); + await time.increase(60); + await recurringPledges.executeDue(1); + expect(await notes.fineList(1)).to.deep.equal([]); + expect((await notes.spendPolicies(1)).unsuspiciousDelay).to.equal(0); + expect(await notes.fineList(2)).to.deep.equal([id]); + expect((await notes.spendPolicies(2)).unsuspiciousDelay).to.equal(40); + expect((await notes.spendPolicies(2)).strictMode).to.equal(true); + expect(await notes.isSpendFlagger(2, carol.address)).to.equal(true); + await recurringPledges.setPledgeFineListed(1, id, false); + await recurringPledges.updateSpendPolicy(1, 10, false, []); + expect((await recurringPledges.pledges(1)).unsuspiciousDelay).to.equal(10); + await time.increase(60); + await recurringPledges.executeDue(1); + expect(await notes.fineList(3)).to.deep.equal([]); + expect((await notes.spendPolicies(3)).unsuspiciousDelay).to.equal(10); + expect(await notes.fineList(2)).to.deep.equal([id]); + expect((await notes.spendPolicies(2)).unsuspiciousDelay).to.equal(40); + await recurringPledges.cancelStandingPledge(1); + await expect(recurringPledges.setPledgeFineListed(1, id, true)) + .to.be.revertedWithCustomError(recurringPledges, "PledgeInactive"); + }); + it("creates a public pledge intent and executes the first note immediately", async function () { const { alice, bob, notes, recurringPledges, token } = await deployFixture(); const amount = 10_000n; diff --git a/indexer/abis/DelegatableNotesAbi.ts b/indexer/abis/DelegatableNotesAbi.ts index 351de08ca..7e773481d 100644 --- a/indexer/abis/DelegatableNotesAbi.ts +++ b/indexer/abis/DelegatableNotesAbi.ts @@ -1371,6 +1371,25 @@ export const DelegatableNotesAbi = [ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + } + ], + "name": "effectivePendingSpendDeadline", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/indexer/abis/FixedControllerFactoryAbi.ts b/indexer/abis/FixedControllerFactoryAbi.ts new file mode 100644 index 000000000..c5b91bbc2 --- /dev/null +++ b/indexer/abis/FixedControllerFactoryAbi.ts @@ -0,0 +1,115 @@ +// Auto-generated from hardhat/contracts - DO NOT EDIT MANUALLY +// Run `npm run sync-abis` to regenerate + +export const FixedControllerFactoryAbi = [ + { + "inputs": [ + { + "internalType": "address", + "name": "registry", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "InvalidRegistry", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "assuranceContract", + "type": "address" + } + ], + "name": "FixedControllerAssuranceCreated", + "type": "event" + }, + { + "inputs": [], + "name": "beneficiaryRegistry", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "address", + "name": "paymentToken", + "type": "address" + }, + { + "internalType": "address", + "name": "erc1155Addr", + "type": "address" + }, + { + "internalType": "string", + "name": "projectMetadataCid", + "type": "string" + }, + { + "internalType": "bytes32", + "name": "beneficiaryId", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "registry", + "type": "address" + } + ], + "name": "create", + "outputs": [ + { + "internalType": "contract FixedControllerAssuranceContract", + "name": "ac", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "isDeployedPrimaryMarket", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] as const; diff --git a/indexer/scripts/sync-abis.ts b/indexer/scripts/sync-abis.ts index da37d0ba7..5daf3c70f 100644 --- a/indexer/scripts/sync-abis.ts +++ b/indexer/scripts/sync-abis.ts @@ -52,6 +52,7 @@ const CONTRACTS_TO_SYNC: Record = { ProjectFactory: { artifactPath: "individual-projects/ProjectFactory.sol/ProjectFactory.json", outputFile: "ProjectFactoryAbi.ts", capability: "funding" }, PremintingERC1155Factory: { artifactPath: "individual-projects/ProjectFactory.sol/PremintingERC1155Factory.json", outputFile: "PremintingERC1155FactoryAbi.ts", capability: "funding" }, AssuranceContractFactory: { artifactPath: "individual-projects/ProjectFactory.sol/AssuranceContractFactory.json", outputFile: "AssuranceContractFactoryAbi.ts", capability: "funding" }, + FixedControllerFactory: { artifactPath: "individual-projects/FixedControllerFactory.sol/FixedControllerFactory.json", outputFile: "FixedControllerFactoryAbi.ts", capability: "funding" }, ValueThresholdConditionFactory: { artifactPath: "individual-projects/ProjectFactory.sol/ValueThresholdConditionFactory.json", outputFile: "ValueThresholdConditionFactoryAbi.ts", capability: "funding" }, ContentRegistry: { artifactPath: "content-funding/ContentRegistry.sol/ContentRegistry.json", outputFile: "ContentRegistryAbi.ts", capability: "funding" }, BeneficiaryRegistry: { artifactPath: "content-funding/BeneficiaryRegistry.sol/BeneficiaryRegistry.json", outputFile: "BeneficiaryRegistryAbi.ts", capability: "funding" }, diff --git a/indexer/src/api/index.ts b/indexer/src/api/index.ts index 48f26fe45..4d75d2c9d 100644 --- a/indexer/src/api/index.ts +++ b/indexer/src/api/index.ts @@ -278,7 +278,7 @@ app.get("/api/events", async (c) => { } }); -const PROJECT_CREATION_EVENTS = ["LazyGivingAssuranceContractCreated", "CreatorContractCreated"]; +const PROJECT_CREATION_EVENTS = ["LazyGivingAssuranceContractCreated", "CreatorContractCreated", "FixedControllerAssuranceCreated"]; app.get("/api/project-read-demand", async (c) => { if (!fundingIndexerRoutesEnabled()) { @@ -295,6 +295,7 @@ app.get("/api/project-read-demand", async (c) => { or( eq(schema.events.eventName, PROJECT_CREATION_EVENTS[0]!), eq(schema.events.eventName, PROJECT_CREATION_EVENTS[1]!), + eq(schema.events.eventName, PROJECT_CREATION_EVENTS[2]!), ), )).limit(1); const factoryEvent = created[0]; diff --git a/indexer/src/events-cache/index.ts b/indexer/src/events-cache/index.ts index da2773b37..8d4407280 100644 --- a/indexer/src/events-cache/index.ts +++ b/indexer/src/events-cache/index.ts @@ -40,6 +40,7 @@ register("Implications:ImplicationRevoked"); // LAZYGIVING: Factory + AssuranceContract + non-transferable ERC1155 receipts register("AssuranceContractFactory:LazyGivingAssuranceContractCreated"); +register("FixedControllerFactory:FixedControllerAssuranceCreated"); register("ProjectFactory:ProjectCreated"); register("ERC1155Factory:LazyGivingERC1155ContractCreated"); const assuranceContractEvents = [ @@ -56,6 +57,7 @@ const assuranceContractEvents = [ for (const contractName of [ "AssuranceContract", + "FixedControllerAssuranceContract", "CreatorAssuranceContract", "ProspectiveContentAssuranceContract", ] as const) { diff --git a/indexer/src/indexing/conceptspaceConfigGraph.test.ts b/indexer/src/indexing/conceptspaceConfigGraph.test.ts index 343b75ee5..b38fa24e7 100644 --- a/indexer/src/indexing/conceptspaceConfigGraph.test.ts +++ b/indexer/src/indexing/conceptspaceConfigGraph.test.ts @@ -10,6 +10,7 @@ const indexerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), " const fundingAbiModules = [ "AssuranceContractAbi", "AssuranceContractFactoryAbi", + "FixedControllerFactoryAbi", "PremintingERC1155FactoryAbi", "ProjectFactoryAbi", "PremintingERC1155Abi", diff --git a/indexer/src/indexing/contractCapabilities.test.ts b/indexer/src/indexing/contractCapabilities.test.ts index 14cb5a535..3d98da784 100644 --- a/indexer/src/indexing/contractCapabilities.test.ts +++ b/indexer/src/indexing/contractCapabilities.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { fundingContracts } from "./fundingContracts"; +import { loadIndexerDeploymentContext } from "./ponderEnv"; import { conceptspaceContractNames, fundingContractNames, @@ -9,6 +11,24 @@ import { selectIndexerContracts, } from "./contractCapabilities"; +test("fixed-controller projects are discovered and indexed from their factory deployment", () => { + const address = "0x1111111111111111111111111111111111111111" as const; + const context = loadIndexerDeploymentContext(); + context.getDeployments = (name) => name === "FixedControllerFactory" + ? [{ address, startBlock: 123 }] + : []; + const contracts = fundingContracts(context); + assert.equal(contracts.FixedControllerFactory.address, address); + assert.equal(contracts.FixedControllerFactory.startBlock, 123); + const market = contracts.FixedControllerAssuranceContract; + assert.equal(market.startBlock, 123); + assert.equal(market.address?.address, address); + assert.equal(market.address?.event.name, "FixedControllerAssuranceCreated"); + assert.equal(market.address?.parameter, "assuranceContract"); + assert.ok(market.abi.some(item => item.type === "event" && item.name === "AssuranceContractInitialized")); + assert.ok(market.abi.some(item => item.type === "event" && item.name === "ERC1155Bought")); +}); + test("conceptspace indexing does not enable funding contracts", () => { assert.equal(indexerContractEnabled("BeneficiaryIdentity", "conceptspace"), true); assert.equal(indexerContractEnabled("DelegatableNotes", "conceptspace"), false); @@ -34,6 +54,8 @@ test("conceptspace selection drops funding contracts", () => { PublishedData: { kind: "conceptspace" }, BeneficiaryIdentity: { kind: "conceptspace" }, AssuranceContractFactory: { kind: "funding" }, + FixedControllerFactory: { kind: "funding" }, + FixedControllerAssuranceContract: { kind: "funding" }, ProjectFactory: { kind: "funding" }, ERC1155Factory: { kind: "funding" }, AssuranceContract: { kind: "funding" }, diff --git a/indexer/src/indexing/contractCapabilities.ts b/indexer/src/indexing/contractCapabilities.ts index 395997f5d..ae34e6b7d 100644 --- a/indexer/src/indexing/contractCapabilities.ts +++ b/indexer/src/indexing/contractCapabilities.ts @@ -23,6 +23,8 @@ export const conceptspaceContractNames = [ export const fundingContractNames = [ "AssuranceContractFactory", + "FixedControllerFactory", + "FixedControllerAssuranceContract", "ProjectFactory", "ERC1155Factory", "AssuranceContract", diff --git a/indexer/src/indexing/fundingContracts.ts b/indexer/src/indexing/fundingContracts.ts index 6145e66bb..54be9845e 100644 --- a/indexer/src/indexing/fundingContracts.ts +++ b/indexer/src/indexing/fundingContracts.ts @@ -1,5 +1,6 @@ import { factory } from "ponder"; import { AssuranceContractFactoryAbi } from "../../abis/AssuranceContractFactoryAbi"; +import { FixedControllerFactoryAbi } from "../../abis/FixedControllerFactoryAbi"; import { PremintingERC1155FactoryAbi } from "../../abis/PremintingERC1155FactoryAbi"; import { ProjectFactoryAbi } from "../../abis/ProjectFactoryAbi"; import { AssuranceContractAbi } from "../../abis/AssuranceContractAbi"; @@ -19,6 +20,9 @@ import type { IndexerDeploymentContext } from "./ponderEnv"; const assuranceContractCreatedEvent = AssuranceContractFactoryAbi.find( (item) => item.type === "event" && item.name === "LazyGivingAssuranceContractCreated", )!; +const fixedControllerCreatedEvent = FixedControllerFactoryAbi.find( + (item) => item.type === "event" && item.name === "FixedControllerAssuranceCreated", +)!; const erc1155ContractCreatedEvent = PremintingERC1155FactoryAbi.find( (item) => item.type === "event" && item.name === "LazyGivingERC1155ContractCreated", )!; @@ -44,6 +48,11 @@ export function fundingContracts(context: IndexerDeploymentContext) { "PROJECT_FACTORY_ADDRESS", context.contractStartBlock("PROJECT_FACTORY_START_BLOCK", context.lazyGivingStartBlock), ); + const fixedControllerFactory = context.getDeployments( + "FixedControllerFactory", + "FIXED_CONTROLLER_FACTORY_ADDRESS", + context.contractStartBlock("FIXED_CONTROLLER_FACTORY_START_BLOCK", context.lazyGivingStartBlock), + ); const erc1155Factory = context.getDeployments( "ERC1155Factory", "ERC1155_FACTORY_ADDRESS", @@ -96,11 +105,29 @@ export function fundingContracts(context: IndexerDeploymentContext) { ); const assuranceFactoryAddress = context.factoryAddress(assuranceFactory); + const fixedControllerFactoryAddress = context.factoryAddress(fixedControllerFactory); const erc1155FactoryAddress = context.factoryAddress(erc1155Factory); const prospectiveFactoryAddress = context.factoryAddress(prospectiveFactory); const creatorFactoryAddress = context.factoryAddress(creatorFactory); return { + FixedControllerFactory: { + abi: FixedControllerFactoryAbi, + chain: context.chain, + ...context.deploymentConfig(fixedControllerFactory, context.lazyGivingStartBlock), + }, + FixedControllerAssuranceContract: { + abi: AssuranceContractAbi, + chain: context.chain, + address: fixedControllerFactoryAddress + ? factory({ + ...fixedControllerFactoryAddress, + event: fixedControllerCreatedEvent, + parameter: "assuranceContract", + }) + : undefined, + startBlock: context.deploymentStartBlock(fixedControllerFactory, context.lazyGivingStartBlock), + }, AssuranceContractFactory: { abi: AssuranceContractFactoryAbi, chain: context.chain, diff --git a/integration-tests/src/delegation/delegation-basic.test.ts b/integration-tests/src/delegation/delegation-basic.test.ts index 2a7883a83..ffcaf04ad 100644 --- a/integration-tests/src/delegation/delegation-basic.test.ts +++ b/integration-tests/src/delegation/delegation-basic.test.ts @@ -241,7 +241,6 @@ describe('Delegation System', () => { const user1 = createIsolatedWriteClients(SUITE_NAME, 0, RPC_URL); const user2 = createIsolatedWriteClients(SUITE_NAME, 1, RPC_URL); - const user3 = createIsolatedWriteClients(SUITE_NAME, 2, RPC_URL); // User 1 deposits await publishDocument(machinery.ipfsConfig, createStatement({ @@ -258,7 +257,7 @@ describe('Delegation System', () => { } ); - // User 1 -> User 2 -> User 3 delegation chain + // One hop: user1 delegates the whole note to user2. const { delegatedNoteId: note2 } = await delegateNoteChecked( user1, delegatableNotesContract, @@ -271,39 +270,25 @@ describe('Delegation System', () => { } ); - const { delegatedNoteId: note3 } = await delegateNoteChecked( - user2, + // The root takes the note back. A second hop is not allowed. + await revokeNoteChecked( + user1, delegatableNotesContract, machinery, { noteId: note2, owners: [user2.account, user1.account], - delegateTo: user3.account, - amount: depositAmount, - } - ); - - // User 2 revokes (takes back control from user3) - await revokeNoteChecked( - user2, - delegatableNotesContract, - machinery, - { - noteId: note3, - owners: [user3.account, user2.account, user1.account], // Current chain } ); - // The middle revoker regains spending authority; the original root is retained. - const revokedNote = await getNote(machinery, note3.toString()); + const revokedNote = await getNote(machinery, note2.toString()); assert.ok(revokedNote, 'Revoked note'); - assert.strictEqual(revokedNote.owner.toLowerCase(), user2.account.toLowerCase(), 'Owner should be the revoker'); + assert.strictEqual(revokedNote.owner.toLowerCase(), user1.account.toLowerCase(), 'Owner should be the root'); assert.strictEqual(revokedNote.rootOwner.toLowerCase(), user1.account.toLowerCase(), 'Root should remain the original depositor'); - // SDK chains are root-first: user1 -> user2, with user3 removed. - const revokedChain = await getDelegationChain(machinery, note3.toString()); + const revokedChain = await getDelegationChain(machinery, note2.toString()); assert.deepStrictEqual(revokedChain.map(link => link.address.toLowerCase()), [ - user1.account.toLowerCase(), user2.account.toLowerCase(), + user1.account.toLowerCase(), ]); }); diff --git a/sdk/abis/DelegatableNotesAbi.ts b/sdk/abis/DelegatableNotesAbi.ts index 351de08ca..7e773481d 100644 --- a/sdk/abis/DelegatableNotesAbi.ts +++ b/sdk/abis/DelegatableNotesAbi.ts @@ -1371,6 +1371,25 @@ export const DelegatableNotesAbi = [ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + } + ], + "name": "effectivePendingSpendDeadline", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/sdk/src/subsystems/delegation/actions.test.ts b/sdk/src/subsystems/delegation/actions.test.ts new file mode 100644 index 000000000..1d5de2892 --- /dev/null +++ b/sdk/src/subsystems/delegation/actions.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { encodeAbiParameters, encodeEventTopics, type Address, type Hash } from 'viem'; +import { DelegatableNotesAbi } from '../../abis.js'; +import type { WriteClients } from '../../utils/ethereum.js'; +import { partialTakeback } from './actions.js'; + +const ADDRESS = '0xaaaa000000000000000000000000000000000000' as Address; +const OTHER = '0xbbbb000000000000000000000000000000000000' as Address; +const HASH = `0x${'12'.repeat(32)}` as Hash; +const contract = { address: ADDRESS, abi: DelegatableNotesAbi }; +const params = { noteId: 7n, owners: [OTHER, ADDRESS], amount: 40n }; + +function takebackLog(address = ADDRESS, noteId = 7n, amount = 40n, sliceNoteId = 8n) { + return { + address, + topics: encodeEventTopics({ + abi: DelegatableNotesAbi, + eventName: 'NotePartiallyTakenBack', + args: { noteId, sliceNoteId }, + }), + data: encodeAbiParameters([{ type: 'uint256' }], [amount]), + }; +} + +function mockClients(status: 'success' | 'reverted', logs: ReturnType[]) { + return { + walletClient: { + account: { address: ADDRESS }, + writeContract: async () => HASH, + }, + publicClient: { + waitForTransactionReceipt: async ({ hash }: { hash: Hash }) => { + assert.equal(hash, HASH); + return { status, logs }; + }, + }, + } as unknown as WriteClients; +} + +describe('partialTakeback', () => { + it('returns the slice ID from the matching contract and takeback', async () => { + const clients = mockClients('success', [ + takebackLog(OTHER, 7n, 40n, 99n), + takebackLog(ADDRESS, 6n, 40n, 98n), + takebackLog(), + ]); + assert.deepEqual(await partialTakeback(clients, contract, params), { hash: HASH, sliceNoteId: 8n }); + }); + + it('rejects a transaction that was mined but reverted', async () => { + await assert.rejects(partialTakeback(mockClients('reverted', []), contract, params), /transaction reverted/); + }); + + for (const [description, logs] of [ + ['missing events', []], + ['another contract', [takebackLog(OTHER)]], + ['another note', [takebackLog(ADDRESS, 6n)]], + ['another amount', [takebackLog(ADDRESS, 7n, 39n)]], + ] as const) { + it(`rejects a successful receipt with ${description}`, async () => { + await assert.rejects( + partialTakeback(mockClients('success', [...logs]), contract, params), + /Failed to find matching NotePartiallyTakenBack event/, + ); + }); + } +}); diff --git a/sdk/src/subsystems/delegation/actions.ts b/sdk/src/subsystems/delegation/actions.ts index 77ab49731..905ace281 100644 --- a/sdk/src/subsystems/delegation/actions.ts +++ b/sdk/src/subsystems/delegation/actions.ts @@ -272,13 +272,23 @@ export async function partialTakeback( }); const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status === 'reverted') { + throw new Error(`Partial takeback transaction reverted: ${hash}`); + } const logs = parseEventLogs({ abi: DelegatableNotesAbi, eventName: 'NotePartiallyTakenBack', logs: receipt.logs, }); - const sliceNoteId = logs[0]?.args.sliceNoteId ?? 0n; - return { hash, sliceNoteId }; + const takenBack = logs.find(log => + log.address.toLowerCase() === delegatableNotesContract.address.toLowerCase() + && log.args.noteId === params.noteId + && log.args.amount === params.amount, + ); + if (!takenBack) { + throw new Error(`Failed to find matching NotePartiallyTakenBack event in transaction logs: ${hash}`); + } + return { hash, sliceNoteId: takenBack.args.sliceNoteId }; } /** diff --git a/sdk/src/subsystems/delegation/donation-activity.test.ts b/sdk/src/subsystems/delegation/donation-activity.test.ts index a32fcc4ed..45085729a 100644 --- a/sdk/src/subsystems/delegation/donation-activity.test.ts +++ b/sdk/src/subsystems/delegation/donation-activity.test.ts @@ -54,6 +54,7 @@ describe('foldDonationActivityByRoot', () => { const [activity] = foldDonationActivityByRoot(ROOT, purchaseEvents(), [intent], [project]); assert.equal(activity.amount, '250'); + assert.equal(activity.noteContract, NOTES); assert.equal(activity.directedBy, DELEGATE); assert.equal(activity.projectAddress, PROJECT); assert.deepEqual(activity.receiptNoteIds, ['2']); diff --git a/sdk/src/subsystems/delegation/queries.ts b/sdk/src/subsystems/delegation/queries.ts index 202404920..f9f209324 100644 --- a/sdk/src/subsystems/delegation/queries.ts +++ b/sdk/src/subsystems/delegation/queries.ts @@ -177,6 +177,7 @@ export function foldDonationActivityByRoot( return [{ id: `${contract}:${purchase.transactionHash.toLowerCase()}:${purchase.logIndex}:${root}`, + noteContract: purchase.contractAddress, transactionHash: purchase.transactionHash, createdAt: purchase.blockTimestamp.toString(), blockNumber: purchase.blockNumber.toString(), diff --git a/sdk/src/subsystems/delegation/types.ts b/sdk/src/subsystems/delegation/types.ts index 2b992f54b..6972af79b 100644 --- a/sdk/src/subsystems/delegation/types.ts +++ b/sdk/src/subsystems/delegation/types.ts @@ -130,6 +130,7 @@ export type DonationActivityStatus = 'receipt active' | 'refunded' | 'reimbursed /** One project allocation made from notes rooted in a donor's wallet. */ export interface DonationActivity { id: string; + noteContract: string; transactionHash: string; createdAt: string; blockNumber: string; diff --git a/specs/tech/subsystems/delegation/spend-classification.md b/specs/tech/subsystems/delegation/spend-classification.md index 60a2ad387..98e5466f7 100644 --- a/specs/tech/subsystems/delegation/spend-classification.md +++ b/specs/tech/subsystems/delegation/spend-classification.md @@ -2,7 +2,7 @@ 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). -This file is the proposal the beneficiary-identity item in [TODO.md](/TODO.md) asked for. It does not change 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. +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. ## Classes @@ -42,7 +42,7 @@ Escrow does not match. Neither does a project whose proceeds stay in the contrac A third-party project matches if, and only if, it meets the three conditions above. The class does not mean the beneficiary endorses the project. Disavowal does not change it. -A verified identity is created as `FixedControllerAssuranceContract`: the recipient is the registry payout at creation, and `beneficiaryId` is stored on the contract. An identity that is not verified yet stays on `BeneficiaryAssuranceContract`, whose recipient is the contract itself until claim. That route does not match. Do not invent a match by reading metadata, and do not add payout-attestation machinery. +A verified identity is created as `FixedControllerAssuranceContract`: the recipient is the registry payout at creation, and `beneficiaryId` is stored on the contract. Its authorized factory pins the beneficiary registry at deployment and rejects a caller-supplied replacement registry. An identity that is not verified yet stays on `BeneficiaryAssuranceContract`, whose recipient is the contract itself until claim. That route does not match. Do not invent a match by reading metadata, and do not add payout-attestation machinery. The pending spend shows that on-chain route. It does not substitute the registry's current wallet when the project pays something else. diff --git a/ui/src/delegation/components/DonorPendingSpends.tsx b/ui/src/delegation/components/DonorPendingSpends.tsx index a0d53b52c..99c3ecd13 100644 --- a/ui/src/delegation/components/DonorPendingSpends.tsx +++ b/ui/src/delegation/components/DonorPendingSpends.tsx @@ -3,11 +3,10 @@ import { Chip, Paper, Stack, Typography } from '@mui/material' import { Link as RouterLink } from 'react-router-dom' import { usePublicClient } from 'wagmi' import type { Address } from 'viem' -import { formatEther } from 'viem' import { DelegatableNotesAbi } from '@commonality/sdk/abis' import type { Note } from '@commonality/sdk/delegation' import { formatPendingSpendDeadline, isSuspiciousClass, spendClassLabel } from '../spendClass' -import { noteDetailPathFor } from '../utils' +import { formatNoteAmount, noteDetailPathFor } from '../utils' type Row = { note: Note @@ -41,9 +40,14 @@ export function DonorPendingSpends({ notes }: { notes: Note[] }) { functionName: 'effectiveSpendDelay', args: [BigInt(note.id), pending[0]], }) as readonly [bigint, number] + const deadline = await publicClient.readContract({ + ...contract, + functionName: 'effectivePendingSpendDeadline', + args: [BigInt(note.id)], + }) found.push({ note, - deadline: pending[5], + deadline, paused: pending[7], spendClass: Number(classified[1]), }) @@ -78,7 +82,7 @@ export function DonorPendingSpends({ notes }: { notes: Note[] }) { to={noteDetailPathFor(row.note.contractAddress, row.note.id)} sx={{ fontWeight: 700 }} > - {formatEther(BigInt(row.note.amount))} ETH + {formatNoteAmount(row.note)} {row.paused && } @@ -92,4 +96,3 @@ export function DonorPendingSpends({ notes }: { notes: Note[] }) { ) } - diff --git a/ui/src/delegation/components/FineListPanel.tsx b/ui/src/delegation/components/FineListPanel.tsx index 5ce42d386..4bb87d673 100644 --- a/ui/src/delegation/components/FineListPanel.tsx +++ b/ui/src/delegation/components/FineListPanel.tsx @@ -12,10 +12,12 @@ export function FineListPanel({ noteId, contractAddress, owners, + onChanged, }: { noteId: bigint contractAddress: Address owners: Address[] + onChanged?: () => Promise }) { const publicClient = usePublicClient() const clients = useWriteClients() @@ -68,13 +70,16 @@ export function FineListPanel({ setError(null) try { const canonical = normalizeDnsBeneficiary(domain) - await setFineListed(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { + const hash = await setFineListed(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { noteId, owners, beneficiaryId: hashBeneficiaryId('dns', canonical), allowed: true, }) + const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new Error('The fine list update reverted') setDomain('') + await onChanged?.() } catch (err) { setError(err instanceof Error ? err.message : 'Could not add that name') } finally { @@ -82,6 +87,24 @@ export function FineListPanel({ } } + async function removeName(beneficiaryId: `0x${string}`) { + if (!clients) return + setBusy(true) + setError(null) + try { + const hash = await setFineListed(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { + noteId, owners, beneficiaryId, allowed: false, + }) + const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new Error('The fine list update reverted') + await onChanged?.() + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not remove that name') + } finally { + setBusy(false) + } + } + return ( Fine list @@ -93,7 +116,7 @@ export function FineListPanel({ )} {names.length === 0 && No names yet.} - {names.map((id) => )} + {names.map((id) => { void removeName(id as `0x${string}`) }} />)} setDomain(event.target.value)} placeholder="example.org" /> diff --git a/ui/src/delegation/components/PendingSpendCard.tsx b/ui/src/delegation/components/PendingSpendCard.tsx index b79f59260..23e84a56c 100644 --- a/ui/src/delegation/components/PendingSpendCard.tsx +++ b/ui/src/delegation/components/PendingSpendCard.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' import { Alert, Button, Chip, Paper, Stack, Typography } from '@mui/material' import { usePublicClient } from 'wagmi' import type { Address } from 'viem' -import { formatEther } from 'viem' +import type { Currency } from '@commonality/sdk/utils' +import { formatCurrencyAmount } from '../../shared/funding' import { DelegatableNotesAbi } from '@commonality/sdk/abis' import { approveScheduledSpend, cancelScheduledSpend } from '@commonality/sdk/delegation' import { useWriteClients } from '../../shared' @@ -21,15 +22,19 @@ export function PendingSpendCard({ contractAddress, owners, amount, + currency, canApprove, canCancel, + onChanged, }: { noteId: bigint contractAddress: Address owners: Address[] amount: bigint + currency: Currency canApprove: boolean canCancel: boolean + onChanged?: () => Promise }) { const publicClient = usePublicClient() const clients = useWriteClients() @@ -56,10 +61,15 @@ export function PendingSpendCard({ functionName: 'effectiveSpendDelay', args: [noteId, pending[0]], }) as readonly [bigint, number] + const deadline = await publicClient.readContract({ + ...contract, + functionName: 'effectivePendingSpendDeadline', + args: [noteId], + }) if (!cancelled) { setRow({ primaryMarket: pending[0], - deadline: pending[5], + deadline, paused: pending[7], amount, spendClass: Number(classified[1]), @@ -82,8 +92,12 @@ export function PendingSpendCard({ try { const contract = { address: contractAddress, abi: DelegatableNotesAbi } const params = { noteId, owners } - if (kind === 'approve') await approveScheduledSpend(clients, contract, params) - else await cancelScheduledSpend(clients, contract, params) + const hash = kind === 'approve' + ? await approveScheduledSpend(clients, contract, params) + : await cancelScheduledSpend(clients, contract, params) + const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new Error('The pending spend update reverted') + await onChanged?.() } catch (err) { setError(err instanceof Error ? err.message : 'Could not update the pending spend') } finally { @@ -98,7 +112,7 @@ export function PendingSpendCard({ {row.paused && } - {formatEther(row.amount)} ETH + {formatCurrencyAmount(row.amount, currency)} {row.paused ? 'Paused. The delegate cannot cancel it. You can approve it or cancel it. It is not counted as raised.' diff --git a/ui/src/delegation/components/SpendPolicyPanel.tsx b/ui/src/delegation/components/SpendPolicyPanel.tsx index ff7e8577a..5967f8175 100644 --- a/ui/src/delegation/components/SpendPolicyPanel.tsx +++ b/ui/src/delegation/components/SpendPolicyPanel.tsx @@ -12,10 +12,12 @@ export function SpendPolicyPanel({ noteId, contractAddress, owners, + onChanged, }: { noteId: bigint contractAddress: Address owners: Address[] + onChanged?: () => Promise }) { const publicClient = usePublicClient() const clients = useWriteClients() @@ -27,6 +29,12 @@ export function SpendPolicyPanel({ const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + async function waitForUpdate(hash: `0x${string}`) { + if (!clients) return + const receipt = await clients.publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new Error('The spend policy update reverted') + } + useEffect(() => { if (!publicClient) return let cancelled = false @@ -69,8 +77,9 @@ export function SpendPolicyPanel({ setError(null) try { const contract = { address: contractAddress, abi: DelegatableNotesAbi } - await setSpendDelay(clients, contract, { noteId, owners, delay }) - await setUnsuspiciousDelay(clients, contract, { noteId, owners, delay: unsuspicious }) + await waitForUpdate(await setSpendDelay(clients, contract, { noteId, owners, delay })) + await waitForUpdate(await setUnsuspiciousDelay(clients, contract, { noteId, owners, delay: unsuspicious })) + await onChanged?.() } catch (err) { setError(err instanceof Error ? err.message : 'Could not save the delay') } finally { @@ -83,8 +92,9 @@ export function SpendPolicyPanel({ setBusy(true) setError(null) try { - await setStrictMode(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { noteId, owners, enabled }) + await waitForUpdate(await setStrictMode(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { noteId, owners, enabled })) setStrict(enabled) + await onChanged?.() } catch (err) { setError(err instanceof Error ? err.message : 'Could not change strict mode') } finally { @@ -97,10 +107,11 @@ export function SpendPolicyPanel({ setBusy(true) setError(null) try { - await setSpendFlagger(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { + await waitForUpdate(await setSpendFlagger(clients, { address: contractAddress, abi: DelegatableNotesAbi }, { noteId, owners, flagger, allowed, - }) + })) setFlaggerInput('') + await onChanged?.() } catch (err) { setError(err instanceof Error ? err.message : 'Could not change that flagger') } finally { diff --git a/ui/src/delegation/pages/MyNotesPage.test.tsx b/ui/src/delegation/pages/MyNotesPage.test.tsx index f1e168212..0e9fcbf8f 100644 --- a/ui/src/delegation/pages/MyNotesPage.test.tsx +++ b/ui/src/delegation/pages/MyNotesPage.test.tsx @@ -240,6 +240,7 @@ describe('MyNotesPage', () => { vi.mocked(getNotesByRoot).mockResolvedValue([]) vi.mocked(getDonationActivityByRoot).mockResolvedValue([{ id: 'allocation-1', + noteContract: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', transactionHash: '0xabc', createdAt: '1700000000', blockNumber: '100', diff --git a/ui/src/delegation/pages/MyNotesPage.tsx b/ui/src/delegation/pages/MyNotesPage.tsx index b63e7ea4c..cd0c2c084 100644 --- a/ui/src/delegation/pages/MyNotesPage.tsx +++ b/ui/src/delegation/pages/MyNotesPage.tsx @@ -27,11 +27,11 @@ 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 { fetchEvents, type Currency, type IpfsCidV1 } from '@commonality/sdk/utils' +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 } from '../utils' +import { formatNoteAmount, isDelegate, truncateAddress, isEthNote, noteDetailPath, noteScopedKey, parsePartialTakebackAmount } from '../utils' import { DonorPendingSpends } from '../components/DonorPendingSpends' import { spendClassLabel } from '../spendClass' import { readLazyGivingProjectMetadata } from '../../lazy-giving/metadata' @@ -247,14 +247,8 @@ function PartialTakebackDialog({ onSubmit: (amount: string) => void }) { const [amount, setAmount] = useState('') - const balance = note ? BigInt(note.amount) : 0n - let parsed: bigint | null = null - try { - if (amount) parsed = parseEther(amount) - } catch { - parsed = null - } - const valid = parsed !== null && parsed > 0n && parsed < balance + const parsed = note ? parsePartialTakebackAmount(amount, note) : null + const valid = parsed !== null return ( @@ -264,12 +258,12 @@ function PartialTakebackDialog({ You are taking this amount back. The rest stays with the delegate under the same rules. This does not approve a payment. setAmount(e.target.value)} fullWidth margin="normal" - helperText={note ? `Greater than zero and less than ${formatEther(balance)} ETH` : ''} + helperText={note ? `Greater than zero and less than ${formatNoteAmount(note)}` : ''} /> @@ -487,7 +481,7 @@ function DonationActivityFeed({ activities, projectTitles, causeTitles, classByN {formatCurrencyAmount(activity.amount, activity.currency)} {(() => { - const value = activity.inputNoteIds.map((id) => classByNoteId[id]).find((item) => item !== undefined) + const value = activity.inputNoteIds.map((id) => classByNoteId[`${activity.noteContract?.toLowerCase()}:${activity.transactionHash.toLowerCase()}:${id}`]).find((item) => item !== undefined) if (value === undefined) return null const label = spendClassLabel(value) return @@ -569,10 +563,8 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg setDepositedNotes(deposited.filter(n => n.active)) setStandingPledges(activePledges) setDonationActivity(activity) - const notesAddress = import.meta.env.VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS as string | undefined - if (isDonate && notesAddress && machinery.eventCacheUrl) { - const events = await fetchEvents(machinery, { - contractAddress: notesAddress, + if (isDonate && machinery.eventCacheUrl) { + const events = await fetchEventsComplete(machinery, { eventName: 'SpendClassResolved', }).catch(() => []) const labels: Record = {} @@ -584,7 +576,7 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg topics: [event.topic0 as Hex, event.topic1 as Hex], data: event.data as Hex, }) - labels[decoded.args.noteId.toString()] = Number(decoded.args.class) + labels[`${event.contractAddress.toLowerCase()}:${event.transactionHash.toLowerCase()}:${decoded.args.noteId}`] = Number(decoded.args.class) } setClassByNoteId(labels) } else { @@ -689,6 +681,8 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg } const handlePartialTakeback = async (note: Note, amount: string) => { + const parsed = parsePartialTakebackAmount(amount, note) + if (parsed === null) return const clients = getClients() const contract = getContract(note.contractAddress) if (!clients || !contract) return @@ -702,7 +696,7 @@ export function MyNotesPage({ experience = 'delegation' }: { experience?: 'deleg await partialTakeback(clients, contract, { noteId: BigInt(note.id), owners, - amount: parseEther(amount), + amount: parsed, }) await loadNotes() } catch (err) { diff --git a/ui/src/delegation/pages/NoteDetailPage.tsx b/ui/src/delegation/pages/NoteDetailPage.tsx index 86be97601..f3bacdfa5 100644 --- a/ui/src/delegation/pages/NoteDetailPage.tsx +++ b/ui/src/delegation/pages/NoteDetailPage.tsx @@ -29,7 +29,8 @@ import type { IpfsCidV1 } from '@commonality/sdk/utils' import { getProjectsFiltered, type ProjectWithMetrics, getProjectTokens, type ProjectToken } from '@commonality/sdk/lazy-giving' import { StatementPicker, useMachinery } from '../../shared' import { useWriteClients } from '../../shared' -import { formatNoteAmount, isDelegate, truncateAddress, isEthNote, parseNoteRouteId, noteDetailPathFor } from '../utils' +import { formatNoteAmount, isDelegate, truncateAddress, isEthNote, parseNoteRouteId, noteDetailPathFor, parsePartialTakebackAmount } from '../utils' +import { getCurrencyForNote } from '../../shared/funding' import { FineListPanel } from '../components/FineListPanel' import { PendingSpendCard } from '../components/PendingSpendCard' import { SpendPolicyPanel } from '../components/SpendPolicyPanel' @@ -520,6 +521,8 @@ export function NoteDetailPage() { const handlePartialTakeback = async () => { if (!note) return + const amount = parsePartialTakebackAmount(partialAmount, note) + if (amount === null) return const clients = getClients() const contract = getContract(note.contractAddress) if (!clients || !contract) return @@ -532,7 +535,7 @@ export function NoteDetailPage() { await partialTakeback(clients, contract, { noteId: BigInt(note.id), owners, - amount: parseEther(partialAmount), + amount, }) setPartialOpen(false) setPartialAmount('') @@ -746,11 +749,13 @@ export function NoteDetailPage() { {isRootOwner && ( <> b.position - a.position).map((link) => link.address as `0x${string}`)} /> b.position - a.position).map((link) => link.address as `0x${string}`)} @@ -763,8 +768,10 @@ export function NoteDetailPage() { contractAddress={note.contractAddress as `0x${string}`} owners={[...chain].sort((a, b) => b.position - a.position).map((link) => link.address as `0x${string}`)} amount={BigInt(note.amount)} + currency={getCurrencyForNote(note)} canApprove={isRootOwner} canCancel={isRootOwner || isCurrentLeafOwner} + onChanged={loadNoteData} /> @@ -899,17 +906,17 @@ export function NoteDetailPage() { You are taking this amount back. The rest stays with the delegate under the same rules. This does not approve a payment. setPartialAmount(e.target.value)} fullWidth margin="normal" - helperText={`Greater than zero and less than ${formatEther(BigInt(note.amount))} ETH`} + helperText={`Greater than zero and less than ${formatNoteAmount(note)}`} /> - diff --git a/ui/src/delegation/spendClass.test.ts b/ui/src/delegation/spendClass.test.ts index 8af21d091..55e190a81 100644 --- a/ui/src/delegation/spendClass.test.ts +++ b/ui/src/delegation/spendClass.test.ts @@ -16,6 +16,8 @@ describe('spend class labels', () => { expect(hoursInputToSeconds('2')).toBe(7200n) expect(hoursInputToSeconds('1.5')).toBe(5400n) expect(hoursInputToSeconds('soon')).toBeNull() + expect(hoursInputToSeconds('9'.repeat(309))).toBeNull() + expect(hoursInputToSeconds('9'.repeat(306))).toBeNull() }) it('describes a pending deadline as cancellable money, not raised', () => { @@ -23,4 +25,4 @@ describe('spend class labels', () => { expect(text).toContain('not counted as raised') expect(text).toContain('cancelled') }) -}) \ No newline at end of file +}) diff --git a/ui/src/delegation/spendClass.ts b/ui/src/delegation/spendClass.ts index 6f9085b21..403ae6a39 100644 --- a/ui/src/delegation/spendClass.ts +++ b/ui/src/delegation/spendClass.ts @@ -18,8 +18,9 @@ export function hoursInputToSeconds(input: string): bigint | null { const trimmed = input.trim() if (!/^\d+(\.\d+)?$/.test(trimmed)) return null const hours = Number(trimmed) - if (!Number.isFinite(hours)) return null - return BigInt(Math.round(hours * 3600)) + const seconds = Math.round(hours * 3600) + if (!Number.isSafeInteger(seconds)) return null + return BigInt(seconds) } export function formatPendingSpendDeadline(deadlineSeconds: bigint, nowSeconds = Math.floor(Date.now() / 1000)): string { diff --git a/ui/src/delegation/utils.test.ts b/ui/src/delegation/utils.test.ts index 1e8ae3eab..80e236b2a 100644 --- a/ui/src/delegation/utils.test.ts +++ b/ui/src/delegation/utils.test.ts @@ -1,8 +1,24 @@ import { describe, it, expect } from 'vitest' -import { isEthNote, formatNoteAmount, truncateAddress, isDelegate, noteDetailPath, noteDetailPathFor, parseNoteRouteId } from './utils' +import { isEthNote, formatNoteAmount, truncateAddress, isDelegate, noteDetailPath, noteDetailPathFor, parseNoteRouteId, parsePartialTakebackAmount } from './utils' const ETH_ADDRESS = '0x0000000000000000000000000000000000000000' +describe('partial takeback amounts', () => { + it('parses ETH decimals and rejects zero, the whole balance, and excess precision', () => { + const note = makeNote() + expect(parsePartialTakebackAmount('0.25', note)).toBe(250000000000000000n) + for (const input of ['0', '1', '2', '-1', 'bad', '0.0000000000000000001']) { + expect(parsePartialTakebackAmount(input, note)).toBeNull() + } + }) + + it('takes receipt tokens back as whole units instead of applying ETH decimals', () => { + const note = makeNote({ tokenType: 1, amount: '10' }) + expect(parsePartialTakebackAmount('3', note)).toBe(3n) + expect(parsePartialTakebackAmount('0.5', note)).toBeNull() + }) +}) + function makeNote(overrides: Record = {}) { return { id: '1', diff --git a/ui/src/delegation/utils.ts b/ui/src/delegation/utils.ts index 84787da42..7dfa3d9bc 100644 --- a/ui/src/delegation/utils.ts +++ b/ui/src/delegation/utils.ts @@ -1,4 +1,5 @@ import type { Note } from '@commonality/sdk/delegation' +import { parseUnits } from 'viem' import { formatCurrencyAmount, getCurrencyForNote } from '../shared/funding' const ETH_ADDRESS = '0x0000000000000000000000000000000000000000' @@ -11,6 +12,15 @@ export function formatNoteAmount(note: Note): string { return formatCurrencyAmount(note.amount, getCurrencyForNote(note)) } +export function parsePartialTakebackAmount(input: string, note: Note): bigint | null { + const decimals = getCurrencyForNote(note).decimals + const value = input.trim() + if (!/^\d+(\.\d+)?$/.test(value)) return null + if ((value.split('.')[1]?.length ?? 0) > decimals) return null + const amount = parseUnits(value, decimals) + return amount > 0n && amount < BigInt(note.amount) ? amount : null +} + export { truncateAddress } from '../shared' export function isDelegate(note: Note): boolean { diff --git a/ui/src/lazy-giving/components/PendingProjectSpends.tsx b/ui/src/lazy-giving/components/PendingProjectSpends.tsx index 8c98bd00a..3889f7130 100644 --- a/ui/src/lazy-giving/components/PendingProjectSpends.tsx +++ b/ui/src/lazy-giving/components/PendingProjectSpends.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react' import { Paper, Stack, Typography } from '@mui/material' -import { decodeEventLog, formatEther, type Address, type Hex } from 'viem' +import { decodeEventLog, type Address, type Hex } from 'viem' import { usePublicClient } from 'wagmi' import { DelegatableNotesAbi } from '@commonality/sdk/abis' -import { fetchEvents } from '@commonality/sdk/utils' +import { fetchEventsComplete, type Currency } from '@commonality/sdk/utils' +import { formatCurrencyAmount } from '../../shared/funding' import { useMachinery } from '../../shared' import { formatPendingSpendDeadline } from '../../delegation/spendClass' @@ -13,7 +14,7 @@ const NOTES = import.meta.env.VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS as string * Scheduled spends are not purchases, so they are absent from the raised total. * This lists the ones still pending against this project's assurance contract. */ -export function PendingProjectSpends({ primaryMarket }: { primaryMarket: string }) { +export function PendingProjectSpends({ primaryMarket, currency }: { primaryMarket: string; currency?: Currency }) { const publicClient = usePublicClient() const machinery = useMachinery() const [lines, setLines] = useState<{ noteId: string; amount: bigint; deadline: bigint }[]>([]) @@ -25,7 +26,7 @@ export function PendingProjectSpends({ primaryMarket }: { primaryMarket: string } let cancelled = false ;(async () => { - const events = await fetchEvents(machinery, { + const events = await fetchEventsComplete(machinery, { contractAddress: NOTES, eventName: 'SpendScheduled', }) @@ -52,7 +53,19 @@ export function PendingProjectSpends({ primaryMarket }: { primaryMarket: string args: [noteId], }) as readonly [Address, Address, bigint, bigint, bigint, bigint, bigint, boolean, boolean] if (!pending[8] || pending[0].toLowerCase() !== market) continue - next.push({ noteId: key, amount: decoded.args.amount, deadline: pending[5] }) + const deadline = await publicClient.readContract({ + address: NOTES as Address, + abi: DelegatableNotesAbi, + functionName: 'effectivePendingSpendDeadline', + args: [noteId], + }) + const note = await publicClient.readContract({ + address: NOTES as Address, + abi: DelegatableNotesAbi, + functionName: 'notes', + args: [noteId], + }) + next.push({ noteId: key, amount: note[1], deadline }) } if (!cancelled) setLines(next) })().catch(() => { @@ -71,7 +84,7 @@ export function PendingProjectSpends({ primaryMarket }: { primaryMarket: string {lines.map((line) => ( - {formatEther(line.amount)} ETH. {formatPendingSpendDeadline(line.deadline)} + {formatCurrencyAmount(line.amount, currency)}. {formatPendingSpendDeadline(line.deadline)} ))} diff --git a/ui/src/lazy-giving/pages/ProjectDetailPage.tsx b/ui/src/lazy-giving/pages/ProjectDetailPage.tsx index cfe1a89b6..2117c47d3 100644 --- a/ui/src/lazy-giving/pages/ProjectDetailPage.tsx +++ b/ui/src/lazy-giving/pages/ProjectDetailPage.tsx @@ -384,7 +384,7 @@ export function ProjectDetailPage({ return ( - {projectContractAddress && } + {projectContractAddress && } {projectContractAddress && disavowedProjects.has(projectContractAddress.toLowerCase()) && (