diff --git a/TODO.md b/TODO.md index 59c10963..0b6c140b 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 ---- -- Optional donor-set waiting period on delegated spends. When the donor delegates (including a recurring pledge), she can set a delay, including zero. A delegated spend is a scheduled spend on the existing note, not a second escrow: it records the project, the amount, and her stated intent as a public label. She can revoke the unspent funds or tap Approve to release early; if she does neither, the spend completes when the delay ends. The UI states the pending amount and project plainly. Intent is not a condition the contract enforces. Notify her off-app (email or push she opted into) only when someone she already trusts flags the pending spend; other people can leave a public note that does not page her. Default: a trusted flag notifies her and the countdown continues. She can opt into a stricter mode where a trusted flag pauses the clock until she approves or revokes. Do not let a stranger's flag freeze the spend. Write the proposal against `specs/tech/subsystems/delegation/` before changing contracts. +- **(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. - One voice for delegation copy. The donor is authorizing an address to spend a stated amount on projects in Commonality. The delegate promises nothing. A stated intent is public and does not bind the spend. Unspent funds stay revocable by the donor. Commonality does not hold the funds, choose the delegate, or supervise the spending. Remove the steward voice: entrusting money to a scout, program-officer framing, "money under management," and any Commonality ranking whose job is to send people to a delegate. A public history of what an address already funded can stay. "Scout" as the early contributor who may later be reimbursed at cost can stay; do not let that word mean a manager of other people's money. Start with `specs/product/legal/retroactive-funding-redesign.md` (Design 2) and `docs/end-user/lazyGiving/` (`retroactive-funding.md`, `index.md`, `fund-something.md`, `get-your-project-funded.md`). No delegate marketplace. diff --git a/hardhat/contracts/delegation/DelegatableNotes.sol b/hardhat/contracts/delegation/DelegatableNotes.sol index 8b11231a..8e88a31b 100644 --- a/hardhat/contracts/delegation/DelegatableNotes.sol +++ b/hardhat/contracts/delegation/DelegatableNotes.sol @@ -70,6 +70,16 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { error DelegationHopLimit(); error ReplaceRequiresOneDelegate(); error NotNoteRoot(); + error SpendMustBeScheduled(); + error ScheduledSpendMustUseWholeNote(uint256 cost, uint256 noteAmount); + error SpendAlreadyScheduled(); + error NoScheduledSpend(); + error SpendNotDue(); + error SpendPaused(); + error NotSpendFlagger(); + error FlaggerCannotBeDelegate(); + error FlaggerCannotBeMarket(); + error SplitAmountMustBePartial(); enum TokenType { ERC20, ERC1155 } @@ -93,12 +103,34 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { uint256 withdrawn; } + struct SpendPolicy { + uint256 delay; + bool strictMode; + } + + struct PendingSpend { + address primaryMarket; + address erc1155Contract; + uint256 tokenId; + uint256 count; + uint256 deadline; + uint256 nonce; + bool paused; + bool exists; + } + // Depth limit to prevent gas exhaustion from extremely long chains uint256 public constant MAX_DELEGATION_DEPTH = 200; uint256 public nextNoteId = 1; + uint256 public nextScheduleNonce = 1; mapping(uint256 => Note) public notes; mapping(uint256 => ReimbursementClaim) public reimbursementClaims; + mapping(uint256 => SpendPolicy) public spendPolicies; + mapping(uint256 => PendingSpend) public pendingSpends; + mapping(uint256 => mapping(address => bool)) public isSpendFlagger; + mapping(uint256 => address[]) private spendFlaggerList; + bool private scheduledExecution; mapping(address => bool) public authorizedPrimaryMarketFactories; mapping(address => bool) private knownPrimaryMarketFactories; address public recurringPledgeRegistry; @@ -196,6 +228,26 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { uint256 splitAmount ); + event SpendDelaySet(uint256 indexed noteId, uint256 delay); + 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); + event SpendScheduled( + uint256 indexed noteId, + uint256 indexed nonce, + address indexed delegate, + address primaryMarket, + address erc1155Contract, + uint256 tokenId, + uint256 count, + uint256 amount, + uint256 deadline + ); + event SpendFlagged(uint256 indexed noteId, uint256 indexed nonce, address indexed flagger, bool paused); + event SpendCancelled(uint256 indexed noteId, uint256 indexed nonce, address indexed caller); + event SpendScheduleCleared(uint256 indexed noteId, uint256 indexed nonce); + event SpendExecuted(uint256 indexed noteId, uint256 indexed nonce, address indexed caller, bool early); + /** * @notice Emitted when a note's balance is consumed (spent) during a purchase * @param noteId The note being consumed @@ -334,7 +386,10 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { address rootOwner, address token, uint256 amount, - address delegateTo + address delegateTo, + uint256 spendDelay, + bool strictMode, + address[] calldata flaggers ) external nonReentrant returns (uint256) { if (_msgSender() != recurringPledgeRegistry) revert UnauthorizedRecurringPledgeRegistry(); if (rootOwner == address(0) || token == address(0) || delegateTo == address(0)) revert ZeroAddress(); @@ -352,6 +407,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { tokenType: TokenType.ERC20, tokenId: 0 }); + _writePolicy(noteId, spendDelay, strictMode, flaggers, delegateTo); emit NoteCreated(noteId, rootOwner, amount, token, TokenType.ERC20, 0); emit NoteDelegated(noteId, noteId, delegateTo, amount); @@ -478,7 +534,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { address[] calldata owners, address delegateTo, uint256 amountToDelegate - ) external nonReentrant returns (uint256 delegatedNoteId, uint256 remainderNoteId) { + ) public nonReentrant returns (uint256 delegatedNoteId, uint256 remainderNoteId) { Note storage note = notes[noteId]; if (note.chainHash == bytes32(0)) revert NoteDoesNotExist(); @@ -542,6 +598,27 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { address newDelegate, uint256 amount ) external nonReentrant returns (uint256 replacedNoteId, uint256 remainderNoteId) { + return _replaceDelegate(noteId, owners, newDelegate, amount, spendPolicies[noteId].delay); + } + + /// @notice Replacement that also sets the new note's delay in the same action. + function replaceDelegateWithDelay( + uint256 noteId, + address[] calldata owners, + address newDelegate, + uint256 amount, + uint256 newDelay + ) external nonReentrant returns (uint256 replacedNoteId, uint256 remainderNoteId) { + return _replaceDelegate(noteId, owners, newDelegate, amount, newDelay); + } + + function _replaceDelegate( + uint256 noteId, + address[] calldata owners, + address newDelegate, + uint256 amount, + uint256 newDelay + ) private returns (uint256 replacedNoteId, uint256 remainderNoteId) { Note storage note = notes[noteId]; if (note.chainHash == bytes32(0)) revert NoteDoesNotExist(); @@ -557,6 +634,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { address token = note.token; TokenType tokenType = note.tokenType; uint256 tokenId = note.tokenId; + bool strictMode = spendPolicies[noteId].strictMode; + _clearPending(noteId); bytes32 newChainHash = _computeChainHash(newDelegate, _computeChainHash(root, bytes32(0))); replacedNoteId = nextNoteId++; @@ -568,10 +647,13 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { tokenId: tokenId }); _moveClaimPortion(noteId, replacedNoteId, amount); + address[] memory flaggers = spendFlaggerList[noteId]; + _writePolicy(replacedNoteId, newDelay, strictMode, flaggers, newDelegate); if (amount == note.amount) { delete notes[noteId]; delete reimbursementClaims[noteId]; + _deleteSpendPolicy(noteId); remainderNoteId = 0; } else { note.amount -= amount; @@ -598,6 +680,276 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { } + // ============ Waiting period ============ + + function delegateWithDelay( + uint256 noteId, + address[] calldata owners, + address delegateTo, + uint256 amountToDelegate, + uint256 delay + ) external returns (uint256 delegatedNoteId, uint256 remainderNoteId) { + (delegatedNoteId, remainderNoteId) = delegate(noteId, owners, delegateTo, amountToDelegate); + spendPolicies[delegatedNoteId].delay = delay; + emit SpendDelaySet(delegatedNoteId, delay); + } + + function setSpendDelay(uint256 noteId, address[] calldata owners, uint256 delay) external { + _requireRoot(noteId, owners); + spendPolicies[noteId].delay = delay; + emit SpendDelaySet(noteId, delay); + } + + function setStrictMode(uint256 noteId, address[] calldata owners, bool enabled) external { + _requireRoot(noteId, owners); + spendPolicies[noteId].strictMode = enabled; + emit StrictModeSet(noteId, enabled); + } + + function setSpendFlagger( + uint256 noteId, + address[] calldata owners, + address flagger, + bool allowed + ) external { + _requireRoot(noteId, owners); + if (flagger == address(0)) revert ZeroAddress(); + if (owners.length > 1 && flagger == owners[0]) revert FlaggerCannotBeDelegate(); + if (allowed == isSpendFlagger[noteId][flagger]) { + emit SpendFlaggerSet(noteId, flagger, allowed); + return; + } + if (allowed) { + isSpendFlagger[noteId][flagger] = true; + spendFlaggerList[noteId].push(flagger); + } else { + isSpendFlagger[noteId][flagger] = false; + address[] storage list = spendFlaggerList[noteId]; + for (uint256 i = 0; i < list.length; i++) { + if (list[i] == flagger) { + list[i] = list[list.length - 1]; + list.pop(); + break; + } + } + } + emit SpendFlaggerSet(noteId, flagger, allowed); + } + + function spendFlaggers(uint256 noteId) external view returns (address[] memory) { + return spendFlaggerList[noteId]; + } + + function splitNote( + uint256 noteId, + address[] calldata owners, + uint256 amount + ) external nonReentrant returns (uint256 newNoteId) { + Note storage note = _requireDelegatedLeaf(noteId, owners); + if (pendingSpends[noteId].exists) revert SpendAlreadyScheduled(); + if (amount == 0 || amount >= note.amount) revert SplitAmountMustBePartial(); + + newNoteId = nextNoteId++; + notes[newNoteId] = Note({ + chainHash: note.chainHash, + amount: amount, + token: note.token, + tokenType: note.tokenType, + tokenId: note.tokenId + }); + address[] memory flaggers = spendFlaggerList[noteId]; + _writePolicy(newNoteId, spendPolicies[noteId].delay, spendPolicies[noteId].strictMode, flaggers, owners[0]); + _moveClaimPortion(noteId, newNoteId, amount); + note.amount -= amount; + emit NoteSplitSameChain(noteId, newNoteId, amount); + } + + function scheduleSpend( + uint256 noteId, + address[] calldata owners, + address primaryMarket, + address erc1155Contract, + uint256 tokenId, + uint256 count + ) external nonReentrant { + Note storage note = _requireDelegatedLeaf(noteId, owners); + if (pendingSpends[noteId].exists) revert SpendAlreadyScheduled(); + if (count == 0) revert AmountMustBeGreaterThanZero(); + + _requireScheduledSpendUsesWholeNote( + primaryMarket, + erc1155Contract, + tokenId, + count, + note.amount + ); + + if (spendPolicies[noteId].delay == 0) { + PurchaseShare[] memory shares = new PurchaseShare[](1); + shares[0] = PurchaseShare({ noteId: noteId, chain: owners, shares: count }); + _purchaseFromPrimaryMarket(shares, primaryMarket, erc1155Contract, tokenId, count); + return; + } + + uint256 nonce = nextScheduleNonce++; + uint256 deadline = block.timestamp + spendPolicies[noteId].delay; + pendingSpends[noteId] = PendingSpend({ + primaryMarket: primaryMarket, + erc1155Contract: erc1155Contract, + tokenId: tokenId, + count: count, + deadline: deadline, + nonce: nonce, + paused: false, + exists: true + }); + emit SpendScheduled( + noteId, + nonce, + owners[0], + primaryMarket, + erc1155Contract, + tokenId, + count, + note.amount, + deadline + ); + } + + function cancelScheduledSpend(uint256 noteId, address[] calldata owners) external nonReentrant { + _requireChain(noteId, owners); + PendingSpend storage pending = pendingSpends[noteId]; + if (!pending.exists) revert NoScheduledSpend(); + address caller = _msgSender(); + bool isRoot = caller == owners[owners.length - 1]; + bool isLeaf = caller == owners[0]; + if (!isRoot && !isLeaf) revert NotNoteOwner(); + if (isLeaf && !isRoot && pending.paused) revert SpendPaused(); + uint256 nonce = pending.nonce; + delete pendingSpends[noteId]; + emit SpendCancelled(noteId, nonce, caller); + } + + function approveScheduledSpend(uint256 noteId, address[] calldata owners) external nonReentrant { + _requireRoot(noteId, owners); + _executePending(noteId, owners, true); + } + + function executeScheduledSpend(uint256 noteId, address[] calldata owners) external nonReentrant { + PendingSpend storage pending = pendingSpends[noteId]; + if (!pending.exists) revert NoScheduledSpend(); + if (pending.paused) revert SpendPaused(); + if (block.timestamp < pending.deadline) revert SpendNotDue(); + _executePending(noteId, owners, false); + } + + function flagScheduledSpend(uint256 noteId, address[] calldata owners) external { + _requireChain(noteId, owners); + PendingSpend storage pending = pendingSpends[noteId]; + if (!pending.exists) revert NoScheduledSpend(); + address caller = _msgSender(); + if (!isSpendFlagger[noteId][caller]) revert NotSpendFlagger(); + if (caller == owners[0]) revert FlaggerCannotBeDelegate(); + if (caller == pending.primaryMarket) revert FlaggerCannotBeMarket(); + bool pause = spendPolicies[noteId].strictMode; + if (pause) pending.paused = true; + emit SpendFlagged(noteId, pending.nonce, caller, pause); + } + + function _executePending(uint256 noteId, address[] calldata owners, bool early) private { + PendingSpend memory pending = pendingSpends[noteId]; + if (!pending.exists) revert NoScheduledSpend(); + _requireScheduledSpendUsesWholeNote( + pending.primaryMarket, + pending.erc1155Contract, + pending.tokenId, + pending.count, + notes[noteId].amount + ); + delete pendingSpends[noteId]; + scheduledExecution = true; + PurchaseShare[] memory shares = new PurchaseShare[](1); + shares[0] = PurchaseShare({ noteId: noteId, chain: owners, shares: pending.count }); + _purchaseFromPrimaryMarket( + shares, + pending.primaryMarket, + pending.erc1155Contract, + pending.tokenId, + pending.count + ); + scheduledExecution = false; + emit SpendExecuted(noteId, pending.nonce, _msgSender(), early); + } + + function _requireRoot(uint256 noteId, address[] calldata owners) private view { + _requireChain(noteId, owners); + if (owners[owners.length - 1] != _msgSender()) revert NotNoteRoot(); + } + + function _requireDelegatedLeaf(uint256 noteId, address[] calldata owners) private view returns (Note storage note) { + note = _requireChain(noteId, owners); + if (owners.length != 2) revert DelegationHopLimit(); + if (owners[0] != _msgSender()) revert NotNoteOwner(); + } + + function _requireChain(uint256 noteId, address[] calldata owners) private view returns (Note storage note) { + note = notes[noteId]; + if (note.chainHash == bytes32(0)) revert NoteDoesNotExist(); + if (note.chainHash != _verifyAndComputeChainHash(owners)) revert InvalidChain(); + } + + function _requireScheduledSpendUsesWholeNote( + address primaryMarket, + address erc1155Contract, + uint256 tokenId, + uint256 count, + uint256 noteAmount + ) private view { + uint256[] memory tokenIds = new uint256[](1); + uint256[] memory counts = new uint256[](1); + tokenIds[0] = tokenId; + counts[0] = count; + uint256 cost = IFundingMarket(primaryMarket).erc1155TotalCost(erc1155Contract, tokenIds, counts); + if (cost != noteAmount) revert ScheduledSpendMustUseWholeNote(cost, noteAmount); + } + + function _clearPending(uint256 noteId) private { + PendingSpend storage pending = pendingSpends[noteId]; + if (!pending.exists) return; + uint256 nonce = pending.nonce; + delete pendingSpends[noteId]; + emit SpendScheduleCleared(noteId, nonce); + } + + function _deleteSpendPolicy(uint256 noteId) private { + delete spendPolicies[noteId]; + address[] storage list = spendFlaggerList[noteId]; + for (uint256 i = 0; i < list.length; i++) { + delete isSpendFlagger[noteId][list[i]]; + } + delete spendFlaggerList[noteId]; + } + + function _writePolicy( + uint256 noteId, + uint256 delay, + bool strictMode, + address[] memory flaggers, + address delegateTo + ) private { + spendPolicies[noteId].delay = delay; + spendPolicies[noteId].strictMode = strictMode; + emit SpendDelaySet(noteId, delay); + emit StrictModeSet(noteId, strictMode); + for (uint256 i = 0; i < flaggers.length; i++) { + address flagger = flaggers[i]; + if (flagger == address(0) || flagger == delegateTo || isSpendFlagger[noteId][flagger]) continue; + isSpendFlagger[noteId][flagger] = true; + spendFlaggerList[noteId].push(flagger); + emit SpendFlaggerSet(noteId, flagger, true); + } + } + // ============ Revocation ============ /** @@ -625,6 +977,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { } if (!found) revert CallerNotInChain(); + _clearPending(noteId); + // Ancestors revoke back to themselves; a leaf caller returns control to its parent. uint256 newLeafIndex = callerIndex == 0 && owners.length > 1 ? 1 : callerIndex; bytes32 newHash = bytes32(0); @@ -655,6 +1009,16 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { uint256 tokenId, uint256 count ) external nonReentrant { + _purchaseFromPrimaryMarket(purchaseShares, primaryMarket, erc1155Contract, tokenId, count); + } + + function _purchaseFromPrimaryMarket( + PurchaseShare[] memory purchaseShares, + address primaryMarket, + address erc1155Contract, + uint256 tokenId, + uint256 count + ) private { if (count == 0) revert AmountMustBeGreaterThanZero(); if (!isAuthorizedPrimaryMarket(primaryMarket)) revert UnauthorizedMarket(); @@ -855,7 +1219,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { // ============ Purchase Helpers ============ function _executeSharePurchase( - PurchaseShare[] calldata purchaseShares, + PurchaseShare[] memory purchaseShares, uint256 outputCount, uint256 requiredPayment, address paymentToken @@ -878,7 +1242,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { uint256[] memory spentAmounts = new uint256[](purchaseShares.length); for (uint256 i = 0; i < purchaseShares.length; i++) { - PurchaseShare calldata purchaseShare = purchaseShares[i]; + PurchaseShare memory purchaseShare = purchaseShares[i]; if (purchaseShare.shares == 0) revert InvalidPurchaseShares(); Note storage note = notes[purchaseShare.noteId]; @@ -886,7 +1250,11 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder { bytes32 expectedHash = _verifyAndComputeChainHash(purchaseShare.chain); if (note.chainHash != expectedHash) revert InvalidChain(); - if (purchaseShare.chain[0] != caller) revert NotNoteOwner(); + if (pendingSpends[purchaseShare.noteId].exists) revert SpendAlreadyScheduled(); + if (!scheduledExecution && spendPolicies[purchaseShare.noteId].delay != 0) { + revert SpendMustBeScheduled(); + } + if (!scheduledExecution && purchaseShare.chain[0] != caller) revert NotNoteOwner(); if (purchaseShare.chain.length > 2) revert DelegationHopLimit(); if (note.tokenType != TokenType.ERC20 || note.token != paymentToken) { revert InvalidPaymentTokenForPurchase(); diff --git a/hardhat/contracts/delegation/RecurringPledges.sol b/hardhat/contracts/delegation/RecurringPledges.sol index 6d5cb7d4..754281e2 100644 --- a/hardhat/contracts/delegation/RecurringPledges.sol +++ b/hardhat/contracts/delegation/RecurringPledges.sol @@ -9,7 +9,10 @@ interface IDelegatableNotesForRecurringPledges { address rootOwner, address token, uint256 amount, - address delegateTo + address delegateTo, + uint256 spendDelay, + bool strictMode, + address[] calldata flaggers ) external returns (uint256); } @@ -30,6 +33,8 @@ contract RecurringPledges is ReentrancyGuard { BackingType backingType; uint256 lastExecuted; bool active; + uint256 spendDelay; + bool strictMode; } error ZeroAddress(); @@ -49,7 +54,15 @@ contract RecurringPledges is ReentrancyGuard { uint256 amountPerPeriod, uint256 period, string causeRef, - BackingType backingType + BackingType backingType, + uint256 spendDelay, + bool strictMode + ); + + event PledgeSpendPolicyUpdated( + uint256 indexed pledgeId, + uint256 spendDelay, + bool strictMode ); event StandingPledgeExecuted( @@ -63,6 +76,7 @@ contract RecurringPledges is ReentrancyGuard { IDelegatableNotesForRecurringPledges public immutable delegatableNotes; uint256 public nextPledgeId = 1; mapping(uint256 => Pledge) public pledges; + mapping(uint256 => address[]) private pledgeFlaggerList; constructor(address delegatableNotesAddress) { if (delegatableNotesAddress == address(0)) revert ZeroAddress(); @@ -74,7 +88,10 @@ contract RecurringPledges is ReentrancyGuard { address token, uint256 amountPerPeriod, uint256 period, - string calldata causeRef + string calldata causeRef, + uint256 spendDelay, + bool strictMode, + address[] calldata flaggers ) external nonReentrant returns (uint256 pledgeId, uint256 firstNoteId) { address rootOwner = msg.sender; if (rootOwner == address(0) || delegateTo == address(0) || token == address(0)) revert ZeroAddress(); @@ -92,8 +109,15 @@ contract RecurringPledges is ReentrancyGuard { causeRef: causeRef, backingType: BackingType.AutoPull, lastExecuted: 0, - active: true + active: true, + spendDelay: spendDelay, + strictMode: strictMode }); + for (uint256 i = 0; i < flaggers.length; i++) { + if (flaggers[i] != address(0) && flaggers[i] != delegateTo) { + pledgeFlaggerList[pledgeId].push(flaggers[i]); + } + } emit StandingPledgeCreated( pledgeId, @@ -103,7 +127,9 @@ contract RecurringPledges is ReentrancyGuard { amountPerPeriod, period, causeRef, - BackingType.AutoPull + BackingType.AutoPull, + spendDelay, + strictMode ); firstNoteId = _execute(pledgeId); @@ -119,6 +145,31 @@ contract RecurringPledges is ReentrancyGuard { emit StandingPledgeCancelled(pledgeId, pledge.rootOwner); } + function updateSpendPolicy( + uint256 pledgeId, + uint256 spendDelay, + bool strictMode, + address[] calldata flaggers + ) 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(); + pledge.spendDelay = spendDelay; + pledge.strictMode = strictMode; + delete pledgeFlaggerList[pledgeId]; + for (uint256 i = 0; i < flaggers.length; i++) { + if (flaggers[i] != address(0) && flaggers[i] != pledge.delegateTo) { + pledgeFlaggerList[pledgeId].push(flaggers[i]); + } + } + emit PledgeSpendPolicyUpdated(pledgeId, spendDelay, strictMode); + } + + function pledgeFlaggers(uint256 pledgeId) external view returns (address[] memory) { + return pledgeFlaggerList[pledgeId]; + } + function executeDue(uint256 pledgeId) external nonReentrant returns (uint256 noteId) { Pledge storage pledge = pledges[pledgeId]; if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist(); @@ -151,7 +202,10 @@ contract RecurringPledges is ReentrancyGuard { pledge.rootOwner, pledge.token, pledge.amountPerPeriod, - pledge.delegateTo + pledge.delegateTo, + pledge.spendDelay, + pledge.strictMode, + pledgeFlaggerList[pledgeId] ); emit StandingPledgeExecuted(pledgeId, noteId, executedAt); } diff --git a/hardhat/test/DelegatableNotes.waitingPeriod.test.js b/hardhat/test/DelegatableNotes.waitingPeriod.test.js new file mode 100644 index 00000000..b7d9a02e --- /dev/null +++ b/hardhat/test/DelegatableNotes.waitingPeriod.test.js @@ -0,0 +1,198 @@ +import { expect } from "chai"; +import hre from "hardhat"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; + +const { ethers } = hre; + +describe("DelegatableNotes waiting period", 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")); + + 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); + }); + + async function delegateAmount(amount, delay) { + 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; + } + + const marketArgs = (noteId = 1) => [ + noteId, + [bob.address, alice.address], + assuranceContract.target, + erc1155Token.target, + 1, + 1, + ]; + + it("spends immediately when the delay is zero and blocks a direct spend when it is not", async function () { + const immediate = await delegateAmount(ethers.parseEther("0.1"), 0); + await notes.connect(bob).scheduleSpend( + immediate, + [bob.address, alice.address], + assuranceContract.target, + erc1155Token.target, + 1, + 1 + ); + expect((await notes.notes(immediate)).chainHash).to.equal(ethers.ZeroHash); + + const delayed = await delegateAmount(ethers.parseEther("0.1"), 3600); + await expect( + notes.connect(bob).purchaseFromPrimaryMarket( + [{ noteId: delayed, chain: [bob.address, alice.address], shares: 1 }], + assuranceContract.target, + erc1155Token.target, + 1, + 1 + ) + ).to.be.revertedWithCustomError(notes, "SpendMustBeScheduled"); + }); + + it("lets anyone complete a scheduled spend after the delay, and the donor approve it early", async function () { + const noteId = await delegateAmount(ethers.parseEther("0.2"), 3600); + await notes.connect(bob).splitNote(noteId, [bob.address, alice.address], ethers.parseEther("0.1")); + const splitId = noteId + 1n; + await notes.connect(bob).scheduleSpend( + splitId, + [bob.address, alice.address], + assuranceContract.target, + erc1155Token.target, + 1, + 1 + ); + + await expect(notes.connect(carol).executeScheduledSpend(splitId, [bob.address, alice.address])) + .to.be.revertedWithCustomError(notes, "SpendNotDue"); + await expect(notes.connect(bob).splitNote(splitId, [bob.address, alice.address], 1)) + .to.be.revertedWithCustomError(notes, "SpendAlreadyScheduled"); + + await notes.connect(alice).approveScheduledSpend(splitId, [bob.address, alice.address]); + expect((await notes.pendingSpends(splitId)).exists).to.equal(false); + + await notes.connect(bob).scheduleSpend( + noteId, + [bob.address, alice.address], + assuranceContract.target, + erc1155Token.target, + 1, + 1 + ); + await time.increase(3600); + await notes.connect(carol).executeScheduledSpend(noteId, [bob.address, alice.address]); + expect((await notes.pendingSpends(noteId)).exists).to.equal(false); + }); + + it("pauses only in strict mode and only the donor can move a paused spend", async function () { + const noteId = await delegateAmount(ethers.parseEther("0.1"), 3600); + await notes.connect(alice).setSpendFlagger(noteId, [bob.address, alice.address], carol.address, true); + await notes.connect(bob).scheduleSpend(...marketArgs(noteId)); + + await notes.connect(carol).flagScheduledSpend(noteId, [bob.address, alice.address]); + expect((await notes.pendingSpends(noteId)).paused).to.equal(false); + + await notes.connect(alice).setStrictMode(noteId, [bob.address, alice.address], true); + expect((await notes.pendingSpends(noteId)).paused).to.equal(false); + await notes.connect(carol).flagScheduledSpend(noteId, [bob.address, alice.address]); + expect((await notes.pendingSpends(noteId)).paused).to.equal(true); + + await time.increase(3600); + await expect(notes.connect(carol).executeScheduledSpend(noteId, [bob.address, alice.address])) + .to.be.revertedWithCustomError(notes, "SpendPaused"); + await expect(notes.connect(bob).cancelScheduledSpend(noteId, [bob.address, alice.address])) + .to.be.revertedWithCustomError(notes, "SpendPaused"); + + await notes.connect(alice).setStrictMode(noteId, [bob.address, alice.address], false); + expect((await notes.pendingSpends(noteId)).paused).to.equal(true); + await notes.connect(alice).cancelScheduledSpend(noteId, [bob.address, alice.address]); + expect((await notes.pendingSpends(noteId)).exists).to.equal(false); + }); + + it("clears a pending spend when the donor revokes", async function () { + const noteId = await delegateAmount(ethers.parseEther("0.1"), 60); + await notes.connect(bob).scheduleSpend(...marketArgs(noteId)); + const nonce = (await notes.pendingSpends(noteId)).nonce; + await expect(notes.connect(alice).revoke(noteId, [bob.address, alice.address])) + .to.emit(notes, "SpendScheduleCleared") + .withArgs(noteId, nonce); + expect((await notes.pendingSpends(noteId)).exists).to.equal(false); + }); + + it("clears a pending spend when the donor replaces the delegate", async function () { + const noteId = await delegateAmount(ethers.parseEther("0.1"), 60); + await notes.connect(bob).scheduleSpend(...marketArgs(noteId)); + await notes.connect(alice).replaceDelegate( + noteId, + [bob.address, alice.address], + carol.address, + ethers.parseEther("0.1") + ); + expect((await notes.pendingSpends(noteId)).exists).to.equal(false); + }); + + it("rejects a schedule whose price is not the whole note", async function () { + const noteId = await delegateAmount(ethers.parseEther("0.2"), 3600); + await expect( + notes.connect(bob).scheduleSpend(...marketArgs(noteId)) + ).to.be.revertedWithCustomError(notes, "ScheduledSpendMustUseWholeNote"); + }); + + it("keeps an in-flight deadline when she changes the delay", async function () { + const noteId = await delegateAmount(ethers.parseEther("0.1"), 1000); + await notes.connect(bob).scheduleSpend(...marketArgs(noteId)); + const deadline = (await notes.pendingSpends(noteId)).deadline; + 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); + }); +}); diff --git a/hardhat/test/RecurringPledges.test.js b/hardhat/test/RecurringPledges.test.js index 38d48496..a05cf2e8 100644 --- a/hardhat/test/RecurringPledges.test.js +++ b/hardhat/test/RecurringPledges.test.js @@ -40,12 +40,15 @@ describe("RecurringPledges", function () { await token.getAddress(), amount, period, - "bafy-cause" + "bafy-cause", + 0, + false, + [] ); await expect(tx) .to.emit(recurringPledges, "StandingPledgeCreated") - .withArgs(1, alice.address, bob.address, await token.getAddress(), amount, period, "bafy-cause", 0) + .withArgs(1, alice.address, bob.address, await token.getAddress(), amount, period, "bafy-cause", 0, 0, false) .and.to.emit(notes, "NoteCreated") .withArgs(1, alice.address, amount, await token.getAddress(), 0, 0) .and.to.emit(notes, "NoteDelegated") @@ -77,7 +80,10 @@ describe("RecurringPledges", function () { await token.getAddress(), amount, period, - "bafy-cause" + "bafy-cause", + 0, + false, + [] ); await expect(recurringPledges.connect(carol).executeDue(1)) @@ -105,7 +111,10 @@ describe("RecurringPledges", function () { await token.getAddress(), amount, 60, - "bafy-cause" + "bafy-cause", + 0, + false, + [] ); await expect(recurringPledges.connect(carol).cancelStandingPledge(1)) @@ -127,7 +136,10 @@ describe("RecurringPledges", function () { alice.address, await token.getAddress(), 10_000n, - bob.address + bob.address, + 0, + false, + [] )).to.be.revertedWithCustomError(notes, "UnauthorizedRecurringPledgeRegistry"); }); diff --git a/indexer/abis/DelegatableNotesAbi.ts b/indexer/abis/DelegatableNotesAbi.ts index 5c4b1df5..1b3d115c 100644 --- a/indexer/abis/DelegatableNotesAbi.ts +++ b/indexer/abis/DelegatableNotesAbi.ts @@ -63,6 +63,16 @@ export const DelegatableNotesAbi = [ "name": "EmptyChain", "type": "error" }, + { + "inputs": [], + "name": "FlaggerCannotBeDelegate", + "type": "error" + }, + { + "inputs": [], + "name": "FlaggerCannotBeMarket", + "type": "error" + }, { "inputs": [], "name": "InsufficientBalance", @@ -113,6 +123,11 @@ export const DelegatableNotesAbi = [ "name": "NoReimbursementAvailable", "type": "error" }, + { + "inputs": [], + "name": "NoScheduledSpend", + "type": "error" + }, { "inputs": [], "name": "NotNoteOwner", @@ -128,6 +143,11 @@ export const DelegatableNotesAbi = [ "name": "NotRootNoteOrNotOwner", "type": "error" }, + { + "inputs": [], + "name": "NotSpendFlagger", + "type": "error" + }, { "inputs": [], "name": "NoteDoesNotExist", @@ -191,6 +211,47 @@ export const DelegatableNotesAbi = [ "name": "SafeERC20FailedOperation", "type": "error" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "cost", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "noteAmount", + "type": "uint256" + } + ], + "name": "ScheduledSpendMustUseWholeNote", + "type": "error" + }, + { + "inputs": [], + "name": "SpendAlreadyScheduled", + "type": "error" + }, + { + "inputs": [], + "name": "SpendMustBeScheduled", + "type": "error" + }, + { + "inputs": [], + "name": "SpendNotDue", + "type": "error" + }, + { + "inputs": [], + "name": "SpendPaused", + "type": "error" + }, + { + "inputs": [], + "name": "SplitAmountMustBePartial", + "type": "error" + }, { "inputs": [], "name": "UnauthorizedMarket", @@ -489,6 +550,31 @@ export const DelegatableNotesAbi = [ "name": "NoteRevoked", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "fromNoteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "newNoteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "NoteSplitSameChain", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -633,181 +719,542 @@ export const DelegatableNotesAbi = [ "type": "event" }, { - "inputs": [], - "name": "MAX_DELEGATION_DEPTH", - "outputs": [ + "anonymous": false, + "inputs": [ { + "indexed": true, "internalType": "uint256", - "name": "", + "name": "noteId", "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" } ], - "stateMutability": "view", - "type": "function" + "name": "SpendCancelled", + "type": "event" }, { + "anonymous": false, "inputs": [ { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "authorizedPrimaryMarketFactories", - "outputs": [ + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, { - "internalType": "bool", - "name": "", - "type": "bool" + "indexed": false, + "internalType": "uint256", + "name": "delay", + "type": "uint256" } ], - "stateMutability": "view", - "type": "function" + "name": "SpendDelaySet", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "uint256", - "name": "receiptNoteId", + "name": "noteId", "type": "uint256" }, { - "internalType": "address[]", - "name": "chain", - "type": "address[]" + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" }, { + "indexed": true, "internalType": "address", - "name": "primaryMarket", + "name": "caller", "type": "address" - } - ], - "name": "claimReimbursementIntoNote", - "outputs": [ + }, { - "internalType": "uint256", - "name": "reimbursementNoteId", - "type": "uint256" + "indexed": false, + "internalType": "bool", + "name": "early", + "type": "bool" } ], - "stateMutability": "nonpayable", - "type": "function" + "name": "SpendExecuted", + "type": "event" }, { + "anonymous": false, "inputs": [ { - "internalType": "address", - "name": "rootOwner", - "type": "address" - }, - { - "internalType": "address", - "name": "token", - "type": "address" + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" }, { + "indexed": true, "internalType": "uint256", - "name": "amount", + "name": "nonce", "type": "uint256" }, { + "indexed": true, "internalType": "address", - "name": "delegateTo", + "name": "flagger", "type": "address" - } - ], - "name": "createDelegatedNoteFor", - "outputs": [ + }, { - "internalType": "uint256", - "name": "", - "type": "uint256" + "indexed": false, + "internalType": "bool", + "name": "paused", + "type": "bool" } ], - "stateMutability": "nonpayable", - "type": "function" + "name": "SpendFlagged", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "uint256", "name": "noteId", "type": "uint256" }, { - "internalType": "address[]", - "name": "owners", - "type": "address[]" - }, - { + "indexed": true, "internalType": "address", - "name": "delegateTo", + "name": "flagger", "type": "address" }, { - "internalType": "uint256", - "name": "amountToDelegate", - "type": "uint256" + "indexed": false, + "internalType": "bool", + "name": "allowed", + "type": "bool" } ], - "name": "delegate", - "outputs": [ + "name": "SpendFlaggerSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ { + "indexed": true, "internalType": "uint256", - "name": "delegatedNoteId", + "name": "noteId", "type": "uint256" }, { + "indexed": true, "internalType": "uint256", - "name": "remainderNoteId", + "name": "nonce", "type": "uint256" } ], - "stateMutability": "nonpayable", - "type": "function" + "name": "SpendScheduleCleared", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": true, "internalType": "address", - "name": "token", + "name": "delegate", "type": "address" }, { - "internalType": "enum DelegatableNotes.TokenType", - "name": "tokenType", - "type": "uint8" + "indexed": false, + "internalType": "address", + "name": "primaryMarket", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "erc1155Contract", + "type": "address" }, { + "indexed": false, "internalType": "uint256", "name": "tokenId", "type": "uint256" }, { + "indexed": false, + "internalType": "uint256", + "name": "count", + "type": "uint256" + }, + { + "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" - } - ], - "name": "deposit", - "outputs": [ + }, { + "indexed": false, "internalType": "uint256", - "name": "", + "name": "deadline", "type": "uint256" } ], - "stateMutability": "payable", - "type": "function" + "name": "SpendScheduled", + "type": "event" }, { + "anonymous": false, "inputs": [ { - "internalType": "address", - "name": "primaryMarket", - "type": "address" - } - ], + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "StrictModeSet", + "type": "event" + }, + { + "inputs": [], + "name": "MAX_DELEGATION_DEPTH", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + } + ], + "name": "approveScheduledSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "authorizedPrimaryMarketFactories", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + } + ], + "name": "cancelScheduledSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "receiptNoteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "chain", + "type": "address[]" + }, + { + "internalType": "address", + "name": "primaryMarket", + "type": "address" + } + ], + "name": "claimReimbursementIntoNote", + "outputs": [ + { + "internalType": "uint256", + "name": "reimbursementNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "rootOwner", + "type": "address" + }, + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "address", + "name": "delegateTo", + "type": "address" + }, + { + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" + }, + { + "internalType": "address[]", + "name": "flaggers", + "type": "address[]" + } + ], + "name": "createDelegatedNoteFor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "delegateTo", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountToDelegate", + "type": "uint256" + } + ], + "name": "delegate", + "outputs": [ + { + "internalType": "uint256", + "name": "delegatedNoteId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "remainderNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "delegateTo", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountToDelegate", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "delay", + "type": "uint256" + } + ], + "name": "delegateWithDelay", + "outputs": [ + { + "internalType": "uint256", + "name": "delegatedNoteId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "remainderNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "enum DelegatableNotes.TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "deposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + } + ], + "name": "executeScheduledSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + } + ], + "name": "flagScheduledSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "primaryMarket", + "type": "address" + } + ], "name": "isAuthorizedPrimaryMarket", "outputs": [ { @@ -819,6 +1266,30 @@ export const DelegatableNotesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "isSpendFlagger", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "nextNoteId", @@ -832,6 +1303,19 @@ export const DelegatableNotesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "nextScheduleNonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -914,49 +1398,103 @@ export const DelegatableNotesAbi = [ "inputs": [ { "internalType": "address", - "name": "", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "onERC1155Received", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "pendingSpends", + "outputs": [ + { + "internalType": "address", + "name": "primaryMarket", "type": "address" }, { "internalType": "address", - "name": "", + "name": "erc1155Contract", "type": "address" }, { "internalType": "uint256", - "name": "", + "name": "tokenId", "type": "uint256" }, { "internalType": "uint256", - "name": "", + "name": "count", "type": "uint256" }, { - "internalType": "bytes", - "name": "", - "type": "bytes" - } - ], - "name": "onERC1155Received", - "outputs": [ + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, { - "internalType": "bytes4", - "name": "", - "type": "bytes4" - } - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "owner", - "outputs": [ + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, { - "internalType": "address", - "name": "", - "type": "address" + "internalType": "bool", + "name": "paused", + "type": "bool" + }, + { + "internalType": "bool", + "name": "exists", + "type": "bool" } ], "stateMutability": "view", @@ -1174,6 +1712,50 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "newDelegate", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "newDelay", + "type": "uint256" + } + ], + "name": "replaceDelegateWithDelay", + "outputs": [ + { + "internalType": "uint256", + "name": "replacedNoteId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "remainderNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1192,6 +1774,44 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "primaryMarket", + "type": "address" + }, + { + "internalType": "address", + "name": "erc1155Contract", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "count", + "type": "uint256" + } + ], + "name": "scheduleSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1223,6 +1843,152 @@ 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": "setSpendDelay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "flagger", + "type": "address" + }, + { + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "setSpendFlagger", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "setStrictMode", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + } + ], + "name": "spendFlaggers", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "spendPolicies", + "outputs": [ + { + "internalType": "uint256", + "name": "delay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "splitNote", + "outputs": [ + { + "internalType": "uint256", + "name": "newNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/indexer/abis/RecurringPledgesAbi.ts b/indexer/abis/RecurringPledgesAbi.ts index 4a1f684e..e0269ca4 100644 --- a/indexer/abis/RecurringPledgesAbi.ts +++ b/indexer/abis/RecurringPledgesAbi.ts @@ -58,6 +58,31 @@ export const RecurringPledgesAbi = [ "name": "ZeroAddress", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "strictMode", + "type": "bool" + } + ], + "name": "PledgeSpendPolicyUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -127,6 +152,18 @@ export const RecurringPledgesAbi = [ "internalType": "enum RecurringPledges.BackingType", "name": "backingType", "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "strictMode", + "type": "bool" } ], "name": "StandingPledgeCreated", @@ -196,6 +233,21 @@ export const RecurringPledgesAbi = [ "internalType": "string", "name": "causeRef", "type": "string" + }, + { + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" + }, + { + "internalType": "address[]", + "name": "flaggers", + "type": "address[]" } ], "name": "createStandingPledge", @@ -297,6 +349,25 @@ export const RecurringPledgesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + } + ], + "name": "pledgeFlaggers", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -351,9 +422,47 @@ export const RecurringPledgesAbi = [ "internalType": "bool", "name": "active", "type": "bool" + }, + { + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" } ], "stateMutability": "view", "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" + }, + { + "internalType": "address[]", + "name": "flaggers", + "type": "address[]" + } + ], + "name": "updateSpendPolicy", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } ] as const; diff --git a/indexer/src/events-cache/index.ts b/indexer/src/events-cache/index.ts index b088ece2..03615b8f 100644 --- a/indexer/src/events-cache/index.ts +++ b/indexer/src/events-cache/index.ts @@ -77,6 +77,16 @@ register("DelegatableNotes:NoteConsumed"); register("DelegatableNotes:ERC1155Purchased"); register("DelegatableNotes:RefundedIntoNote"); register("DelegatableNotes:ReimbursementClaimedIntoNote"); +register("DelegatableNotes:SpendDelaySet"); +register("DelegatableNotes:StrictModeSet"); +register("DelegatableNotes:SpendFlaggerSet"); +register("DelegatableNotes:NoteSplitSameChain"); +register("DelegatableNotes:SpendScheduled"); +register("DelegatableNotes:SpendFlagged"); +register("DelegatableNotes:SpendCancelled"); +register("DelegatableNotes:SpendScheduleCleared"); +register("DelegatableNotes:SpendExecuted"); +register("RecurringPledges:PledgeSpendPolicyUpdated"); register("NoteIntent:NoteIntentAttested"); register("RecurringPledges:StandingPledgeCreated"); register("RecurringPledges:StandingPledgeExecuted"); diff --git a/sdk/abis/DelegatableNotesAbi.ts b/sdk/abis/DelegatableNotesAbi.ts index 5c4b1df5..1b3d115c 100644 --- a/sdk/abis/DelegatableNotesAbi.ts +++ b/sdk/abis/DelegatableNotesAbi.ts @@ -63,6 +63,16 @@ export const DelegatableNotesAbi = [ "name": "EmptyChain", "type": "error" }, + { + "inputs": [], + "name": "FlaggerCannotBeDelegate", + "type": "error" + }, + { + "inputs": [], + "name": "FlaggerCannotBeMarket", + "type": "error" + }, { "inputs": [], "name": "InsufficientBalance", @@ -113,6 +123,11 @@ export const DelegatableNotesAbi = [ "name": "NoReimbursementAvailable", "type": "error" }, + { + "inputs": [], + "name": "NoScheduledSpend", + "type": "error" + }, { "inputs": [], "name": "NotNoteOwner", @@ -128,6 +143,11 @@ export const DelegatableNotesAbi = [ "name": "NotRootNoteOrNotOwner", "type": "error" }, + { + "inputs": [], + "name": "NotSpendFlagger", + "type": "error" + }, { "inputs": [], "name": "NoteDoesNotExist", @@ -191,6 +211,47 @@ export const DelegatableNotesAbi = [ "name": "SafeERC20FailedOperation", "type": "error" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "cost", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "noteAmount", + "type": "uint256" + } + ], + "name": "ScheduledSpendMustUseWholeNote", + "type": "error" + }, + { + "inputs": [], + "name": "SpendAlreadyScheduled", + "type": "error" + }, + { + "inputs": [], + "name": "SpendMustBeScheduled", + "type": "error" + }, + { + "inputs": [], + "name": "SpendNotDue", + "type": "error" + }, + { + "inputs": [], + "name": "SpendPaused", + "type": "error" + }, + { + "inputs": [], + "name": "SplitAmountMustBePartial", + "type": "error" + }, { "inputs": [], "name": "UnauthorizedMarket", @@ -489,6 +550,31 @@ export const DelegatableNotesAbi = [ "name": "NoteRevoked", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "fromNoteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "newNoteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "NoteSplitSameChain", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -633,181 +719,542 @@ export const DelegatableNotesAbi = [ "type": "event" }, { - "inputs": [], - "name": "MAX_DELEGATION_DEPTH", - "outputs": [ + "anonymous": false, + "inputs": [ { + "indexed": true, "internalType": "uint256", - "name": "", + "name": "noteId", "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" } ], - "stateMutability": "view", - "type": "function" + "name": "SpendCancelled", + "type": "event" }, { + "anonymous": false, "inputs": [ { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "authorizedPrimaryMarketFactories", - "outputs": [ + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, { - "internalType": "bool", - "name": "", - "type": "bool" + "indexed": false, + "internalType": "uint256", + "name": "delay", + "type": "uint256" } ], - "stateMutability": "view", - "type": "function" + "name": "SpendDelaySet", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "uint256", - "name": "receiptNoteId", + "name": "noteId", "type": "uint256" }, { - "internalType": "address[]", - "name": "chain", - "type": "address[]" + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" }, { + "indexed": true, "internalType": "address", - "name": "primaryMarket", + "name": "caller", "type": "address" - } - ], - "name": "claimReimbursementIntoNote", - "outputs": [ + }, { - "internalType": "uint256", - "name": "reimbursementNoteId", - "type": "uint256" + "indexed": false, + "internalType": "bool", + "name": "early", + "type": "bool" } ], - "stateMutability": "nonpayable", - "type": "function" + "name": "SpendExecuted", + "type": "event" }, { + "anonymous": false, "inputs": [ { - "internalType": "address", - "name": "rootOwner", - "type": "address" - }, - { - "internalType": "address", - "name": "token", - "type": "address" + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" }, { + "indexed": true, "internalType": "uint256", - "name": "amount", + "name": "nonce", "type": "uint256" }, { + "indexed": true, "internalType": "address", - "name": "delegateTo", + "name": "flagger", "type": "address" - } - ], - "name": "createDelegatedNoteFor", - "outputs": [ + }, { - "internalType": "uint256", - "name": "", - "type": "uint256" + "indexed": false, + "internalType": "bool", + "name": "paused", + "type": "bool" } ], - "stateMutability": "nonpayable", - "type": "function" + "name": "SpendFlagged", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "uint256", "name": "noteId", "type": "uint256" }, { - "internalType": "address[]", - "name": "owners", - "type": "address[]" - }, - { + "indexed": true, "internalType": "address", - "name": "delegateTo", + "name": "flagger", "type": "address" }, { - "internalType": "uint256", - "name": "amountToDelegate", - "type": "uint256" + "indexed": false, + "internalType": "bool", + "name": "allowed", + "type": "bool" } ], - "name": "delegate", - "outputs": [ + "name": "SpendFlaggerSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ { + "indexed": true, "internalType": "uint256", - "name": "delegatedNoteId", + "name": "noteId", "type": "uint256" }, { + "indexed": true, "internalType": "uint256", - "name": "remainderNoteId", + "name": "nonce", "type": "uint256" } ], - "stateMutability": "nonpayable", - "type": "function" + "name": "SpendScheduleCleared", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": true, "internalType": "address", - "name": "token", + "name": "delegate", "type": "address" }, { - "internalType": "enum DelegatableNotes.TokenType", - "name": "tokenType", - "type": "uint8" + "indexed": false, + "internalType": "address", + "name": "primaryMarket", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "erc1155Contract", + "type": "address" }, { + "indexed": false, "internalType": "uint256", "name": "tokenId", "type": "uint256" }, { + "indexed": false, + "internalType": "uint256", + "name": "count", + "type": "uint256" + }, + { + "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" - } - ], - "name": "deposit", - "outputs": [ + }, { + "indexed": false, "internalType": "uint256", - "name": "", + "name": "deadline", "type": "uint256" } ], - "stateMutability": "payable", - "type": "function" + "name": "SpendScheduled", + "type": "event" }, { + "anonymous": false, "inputs": [ { - "internalType": "address", - "name": "primaryMarket", - "type": "address" - } - ], + "indexed": true, + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "StrictModeSet", + "type": "event" + }, + { + "inputs": [], + "name": "MAX_DELEGATION_DEPTH", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + } + ], + "name": "approveScheduledSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "authorizedPrimaryMarketFactories", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + } + ], + "name": "cancelScheduledSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "receiptNoteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "chain", + "type": "address[]" + }, + { + "internalType": "address", + "name": "primaryMarket", + "type": "address" + } + ], + "name": "claimReimbursementIntoNote", + "outputs": [ + { + "internalType": "uint256", + "name": "reimbursementNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "rootOwner", + "type": "address" + }, + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "address", + "name": "delegateTo", + "type": "address" + }, + { + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" + }, + { + "internalType": "address[]", + "name": "flaggers", + "type": "address[]" + } + ], + "name": "createDelegatedNoteFor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "delegateTo", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountToDelegate", + "type": "uint256" + } + ], + "name": "delegate", + "outputs": [ + { + "internalType": "uint256", + "name": "delegatedNoteId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "remainderNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "delegateTo", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountToDelegate", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "delay", + "type": "uint256" + } + ], + "name": "delegateWithDelay", + "outputs": [ + { + "internalType": "uint256", + "name": "delegatedNoteId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "remainderNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "enum DelegatableNotes.TokenType", + "name": "tokenType", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "deposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + } + ], + "name": "executeScheduledSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + } + ], + "name": "flagScheduledSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "primaryMarket", + "type": "address" + } + ], "name": "isAuthorizedPrimaryMarket", "outputs": [ { @@ -819,6 +1266,30 @@ export const DelegatableNotesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "isSpendFlagger", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "nextNoteId", @@ -832,6 +1303,19 @@ export const DelegatableNotesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "nextScheduleNonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -914,49 +1398,103 @@ export const DelegatableNotesAbi = [ "inputs": [ { "internalType": "address", - "name": "", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "onERC1155Received", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "pendingSpends", + "outputs": [ + { + "internalType": "address", + "name": "primaryMarket", "type": "address" }, { "internalType": "address", - "name": "", + "name": "erc1155Contract", "type": "address" }, { "internalType": "uint256", - "name": "", + "name": "tokenId", "type": "uint256" }, { "internalType": "uint256", - "name": "", + "name": "count", "type": "uint256" }, { - "internalType": "bytes", - "name": "", - "type": "bytes" - } - ], - "name": "onERC1155Received", - "outputs": [ + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, { - "internalType": "bytes4", - "name": "", - "type": "bytes4" - } - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "owner", - "outputs": [ + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, { - "internalType": "address", - "name": "", - "type": "address" + "internalType": "bool", + "name": "paused", + "type": "bool" + }, + { + "internalType": "bool", + "name": "exists", + "type": "bool" } ], "stateMutability": "view", @@ -1174,6 +1712,50 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "newDelegate", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "newDelay", + "type": "uint256" + } + ], + "name": "replaceDelegateWithDelay", + "outputs": [ + { + "internalType": "uint256", + "name": "replacedNoteId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "remainderNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1192,6 +1774,44 @@ export const DelegatableNotesAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "primaryMarket", + "type": "address" + }, + { + "internalType": "address", + "name": "erc1155Contract", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "count", + "type": "uint256" + } + ], + "name": "scheduleSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1223,6 +1843,152 @@ 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": "setSpendDelay", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "address", + "name": "flagger", + "type": "address" + }, + { + "internalType": "bool", + "name": "allowed", + "type": "bool" + } + ], + "name": "setSpendFlagger", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "name": "setStrictMode", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + } + ], + "name": "spendFlaggers", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "spendPolicies", + "outputs": [ + { + "internalType": "uint256", + "name": "delay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "noteId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "splitNote", + "outputs": [ + { + "internalType": "uint256", + "name": "newNoteId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/sdk/abis/RecurringPledgesAbi.ts b/sdk/abis/RecurringPledgesAbi.ts index 4a1f684e..e0269ca4 100644 --- a/sdk/abis/RecurringPledgesAbi.ts +++ b/sdk/abis/RecurringPledgesAbi.ts @@ -58,6 +58,31 @@ export const RecurringPledgesAbi = [ "name": "ZeroAddress", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "strictMode", + "type": "bool" + } + ], + "name": "PledgeSpendPolicyUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -127,6 +152,18 @@ export const RecurringPledgesAbi = [ "internalType": "enum RecurringPledges.BackingType", "name": "backingType", "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "strictMode", + "type": "bool" } ], "name": "StandingPledgeCreated", @@ -196,6 +233,21 @@ export const RecurringPledgesAbi = [ "internalType": "string", "name": "causeRef", "type": "string" + }, + { + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" + }, + { + "internalType": "address[]", + "name": "flaggers", + "type": "address[]" } ], "name": "createStandingPledge", @@ -297,6 +349,25 @@ export const RecurringPledgesAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + } + ], + "name": "pledgeFlaggers", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -351,9 +422,47 @@ export const RecurringPledgesAbi = [ "internalType": "bool", "name": "active", "type": "bool" + }, + { + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" } ], "stateMutability": "view", "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "pledgeId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "spendDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "strictMode", + "type": "bool" + }, + { + "internalType": "address[]", + "name": "flaggers", + "type": "address[]" + } + ], + "name": "updateSpendPolicy", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } ] as const; diff --git a/sdk/src/subsystems/delegation/events.ts b/sdk/src/subsystems/delegation/events.ts index 2c2d3e19..655d83e0 100644 --- a/sdk/src/subsystems/delegation/events.ts +++ b/sdk/src/subsystems/delegation/events.ts @@ -16,6 +16,12 @@ export interface NoteDelegatedEvent extends RawEvent { amount: bigint; } +export interface NoteSplitSameChainEvent extends RawEvent { + fromNoteId: bigint; + newNoteId: bigint; + amount: bigint; +} + export interface ChainSplitEvent extends RawEvent { originalLeafId: bigint; splitLeafId: bigint; diff --git a/sdk/src/subsystems/delegation/folds.ts b/sdk/src/subsystems/delegation/folds.ts index 1710a055..ad2e766f 100644 --- a/sdk/src/subsystems/delegation/folds.ts +++ b/sdk/src/subsystems/delegation/folds.ts @@ -4,6 +4,7 @@ import type { NoteCreatedEvent, NoteDelegatedEvent, ChainSplitEvent, + NoteSplitSameChainEvent, NoteRevokedEvent, NoteDelegateReplacedEvent, FundsReclaimedEvent, @@ -20,6 +21,7 @@ export type DelegationEvent = | { type: 'noteCreated'; event: NoteCreatedEvent } | { type: 'noteDelegated'; event: NoteDelegatedEvent } | { type: 'chainSplit'; event: ChainSplitEvent } + | { type: 'noteSplitSameChain'; event: NoteSplitSameChainEvent } | { type: 'noteRevoked'; event: NoteRevokedEvent } | { type: 'noteDelegateReplaced'; event: NoteDelegateReplacedEvent } | { type: 'fundsReclaimed'; event: FundsReclaimedEvent } @@ -185,6 +187,28 @@ export function foldDelegationState( break; } + case 'noteSplitSameChain': { + const { fromNoteId, newNoteId, amount, blockTimestamp } = ev.event; + const originalId = contractScopedId(ev.event.contractAddress, fromNoteId); + const splitId = contractScopedId(ev.event.contractAddress, newNoteId); + const original = stateMap.get(originalId); + if (original) { + stateMap.set(splitId, { + ...original, + id: newNoteId.toString(), + amount, + createdAt: blockTimestamp.toString(), + createdAtBlock: ev.event.blockNumber.toString(), + createdAtLogIndex: ev.event.logIndex, + updatedAt: blockTimestamp.toString(), + chain: original.chain.map((link) => ({ ...link })), + }); + original.amount -= amount; + original.updatedAt = blockTimestamp.toString(); + } + break; + } + case 'chainSplit': { // Partial delegation: create splitLeaf as a copy of originalLeaf with splitAmount. // The original (remainderLeaf) keeps its chain but has its amount reduced. diff --git a/sdk/src/subsystems/delegation/queries.ts b/sdk/src/subsystems/delegation/queries.ts index 35adb6fb..81ab83a7 100644 --- a/sdk/src/subsystems/delegation/queries.ts +++ b/sdk/src/subsystems/delegation/queries.ts @@ -19,6 +19,7 @@ import { decodeNoteCreatedEvent, decodeNoteDelegatedEvent, decodeChainSplitEvent, + decodeNoteSplitSameChainEvent, decodeNoteRevokedEvent, decodeNoteDelegateReplacedEvent, decodeFundsReclaimedEvent, @@ -51,6 +52,11 @@ function decodeDelegationEvents(rawEvents: Awaited { const hash = await clients.walletClient.writeContract({ address: recurringPledgesContract.address, abi: recurringPledgesContract.abi, functionName: 'createStandingPledge', - args: [params.delegateTo, params.token, params.amountPerPeriod, params.period, params.causeRef], + args: [ + params.delegateTo, + params.token, + params.amountPerPeriod, + params.period, + params.causeRef, + params.spendDelay ?? 0n, + params.strictMode ?? false, + params.flaggers ?? [], + ], chain: clients.walletClient.chain, account: clients.walletClient.account!, }); diff --git a/sdk/src/utils/event-decoders/delegation.ts b/sdk/src/utils/event-decoders/delegation.ts index 41a8db52..f45833df 100644 --- a/sdk/src/utils/event-decoders/delegation.ts +++ b/sdk/src/utils/event-decoders/delegation.ts @@ -57,6 +57,29 @@ export function decodeNoteDelegatedEvent( }; } +export function decodeNoteSplitSameChainEvent( + rawEvent: RawEventFromCache, +): { + fromNoteId: bigint; + newNoteId: bigint; + amount: bigint; + contractAddress: `0x${string}`; + blockNumber: bigint; + blockTimestamp: bigint; + transactionHash: `0x${string}`; + logIndex: number; +} | null { + if (rawEvent.eventName !== 'NoteSplitSameChain') return null; + const args = decodeRawEventArgs(rawEvent, DelegatableNotesAbi); + if (!args) return null; + return { + fromNoteId: args.fromNoteId as bigint, + newNoteId: args.newNoteId as bigint, + amount: (args.amount as bigint) ?? 0n, + ...decodedLogMeta(rawEvent), + }; +} + export function decodeChainSplitEvent( rawEvent: RawEventFromCache, ): { diff --git a/sdk/src/utils/eventCacheClient.ts b/sdk/src/utils/eventCacheClient.ts index 4828d272..e7e19891 100644 --- a/sdk/src/utils/eventCacheClient.ts +++ b/sdk/src/utils/eventCacheClient.ts @@ -307,6 +307,7 @@ export async function fetchAllDelegationEvents( 'NoteCreated', 'NoteDelegated', 'ChainSplit', + 'NoteSplitSameChain', 'NoteRevoked', 'FundsReclaimed', 'NoteConsumed', @@ -322,7 +323,7 @@ export async function fetchAllDelegationEventsComplete( machinery: SDKMachinery, ): Promise { const eventNames = [ - 'NoteCreated', 'NoteDelegated', 'ChainSplit', 'NoteRevoked', 'FundsReclaimed', + 'NoteCreated', 'NoteDelegated', 'ChainSplit', 'NoteSplitSameChain', 'NoteRevoked', 'FundsReclaimed', 'NoteConsumed', 'ERC1155Purchased', 'RefundedIntoNote', 'ReimbursementClaimedIntoNote', ]; return (await Promise.all(eventNames.map(eventName => fetchEventsComplete(machinery, { eventName })))).flat(); diff --git a/service-host/src/spendFlagNotifier.ts b/service-host/src/spendFlagNotifier.ts new file mode 100644 index 00000000..cf3276d0 --- /dev/null +++ b/service-host/src/spendFlagNotifier.ts @@ -0,0 +1,13 @@ +/** One off-app page per scheduled spend. Later flags for the same nonce do not page again. */ + +export interface SpendFlagNotice { + noteId: string + nonce: string +} + +export function shouldPageDonor(seen: Set, notice: SpendFlagNotice): boolean { + const key = `${notice.noteId}:${notice.nonce}` + if (seen.has(key)) return false + seen.add(key) + return true +} diff --git a/service-host/test/spendFlagNotifier.test.ts b/service-host/test/spendFlagNotifier.test.ts new file mode 100644 index 00000000..529e85c3 --- /dev/null +++ b/service-host/test/spendFlagNotifier.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'mocha' +import { shouldPageDonor } from '../src/spendFlagNotifier.js' + +describe('spend flag notifier', function () { + it('pages once per note and schedule nonce', function () { + const seen = new Set() + const first = { noteId: '4', nonce: '1' } + assert.equal(shouldPageDonor(seen, first), true) + assert.equal(shouldPageDonor(seen, first), false) + assert.equal(shouldPageDonor(seen, { noteId: '4', nonce: '2' }), true) + assert.equal(shouldPageDonor(seen, { noteId: '5', nonce: '1' }), true) + }) +}) diff --git a/specs/tech/subsystems/delegation/README.md b/specs/tech/subsystems/delegation/README.md index 10586c54..5690e96d 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)). +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). --- diff --git a/specs/tech/subsystems/delegation/waiting-period.md b/specs/tech/subsystems/delegation/waiting-period.md new file mode 100644 index 00000000..fa7bbd4d --- /dev/null +++ b/specs/tech/subsystems/delegation/waiting-period.md @@ -0,0 +1,69 @@ +# Donor-set waiting period + +A donor may set a delay, including zero, on a delegated note. A delay above zero means the delegate cannot pay a project in the same transaction. He schedules the whole note. When the delay ends, anyone may complete that spend. She can release it early or cancel it. This file is the proposal the waiting-period item in [TODO.md](/TODO.md) asked for, and the contracts follow it. + +The delay is her control over one delegate's spends. It is not a second escrow, and it does not interpret a sentence of intent. Cause-level intent stays a [NoteIntent](/hardhat/contracts/delegation/NoteIntent.sol) attestation. The schedule records the project and the amount. + +## 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. + +`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. + +A same-chain `splitNote` is leaf-only on a delegated note. It moves part of the amount to a new note with the same chain and copies the delay, the strict-mode switch, and the flagger list. The new note does not add a hop. A note with a pending spend cannot be split. + +`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. + +## One schedule for the whole note + +The delegate schedules one project for the whole note: primary market, ERC-1155 contract, token id, and count. He splits first when he wants to send only part of the money. Scheduling and completion both revert unless that count's current price equals the note's balance, so a cheaper count cannot lock the note and then leave a remainder, and a price change cannot complete as a partial spend. A second schedule on the same note reverts. + +Delay zero spends in that transaction and stores no pending row. `purchaseFromPrimaryMarket` still spends immediately when the delay is zero, which is the existing path. When the delay is above zero, that direct purchase reverts. The delegate uses `scheduleSpend`. + +After `deadline`, `executeScheduledSpend` is permissionless. It pays the project through the same purchase path. Her `approveScheduledSpend` pays immediately, including while the spend is paused and before the deadline. If the purchase reverts, the schedule stays pending, anyone may retry, and the deadline does not move. + +The delegate's `cancelScheduledSpend` returns the note to him with no project attached. Scheduling again starts a fresh delay and a new nonce. Her cancel does the same and leaves him as delegate. + +## Pause + +Strict mode is off unless she turns it on. She names flaggers with `setSpendFlagger`. The list is not her Subjectiv trust graph. A flagger call reverts when the caller is the current leaf or the scheduled primary market. + +`flagScheduledSpend` always emits `SpendFlagged`. In the default mode the countdown continues. In strict mode that call sets `paused`. Turning strict mode on does not pause a spend that was already flagged. Removing a flagger, or turning strict mode off, does not clear a pause that already happened. + +While `paused` is true, `executeScheduledSpend` reverts and the delegate cannot cancel. She can approve, cancel, or revoke. `replaceDelegate` is her transaction, so it also clears the schedule. + +## Recurring pledges + +A standing pledge stores `spendDelay`, `strictMode`, and its own flagger list. Each note it mints copies all three. `updateSpendPolicy` changes what later notes receive. Notes already minted keep the settings they were born with. She can still edit those notes in place. + +## Events + +| Event | When | +|---|---| +| `SpendDelaySet` | She sets the delay on a note | +| `StrictModeSet` | She turns strict mode on or off | +| `SpendFlaggerSet` | She adds or removes a flagger | +| `NoteSplitSameChain` | The delegate splits a note without adding a hop | +| `SpendScheduled` | A pending spend is recorded. `nonce` identifies that schedule | +| `SpendFlagged` | A flagger flags it. `paused` is true only in strict mode | +| `SpendCancelled` | She or the delegate cancels it | +| `SpendScheduleCleared` | Revoke or replacement drops it | +| `SpendExecuted` | Approve or permissionless completion pays the project | + +`StandingPledgeCreated` carries `spendDelay` and `strictMode`. `PledgeSpendPolicyUpdated` records a later edit. + +## Off-app notification + +A follower of `SpendFlagged` pages her only when she has opted into a channel, and only once per `(noteId, nonce)`. Later flags still emit, and a later flag in strict mode can still pause, but they do not page her again. A new schedule has a new nonce and can page her once. The decision lives in `service-host/src/spendFlagNotifier.ts`. The chain does not store messages or email addresses. + +Public remarks from people who are not flaggers are a later UI feature. They are not stored on-chain. The schedule is the on-chain record. + +## 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. + +## Out of scope + +Beneficiary allow-lists, donor exceptions, refund authority, and a prose label on the schedule are other items. This design does not add them.