Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,10 @@ Getting **testnet to a two-person shared lab** is also a standing plan, not a pi

----

- **(Tell)** Send the donor-set waiting-period page. Note and project screens show a pending spend's amount and deadline as money that can still be cancelled, and the note screen has the delay, unsuspicious delay, strict mode, and flaggers. `shouldPageDonor` still only decides once per `(noteId, nonce)`. There is no opt-in store and no email or push sender. Public remarks from people who are not flaggers stay a later UI feature and are not stored on-chain. Rules: [waiting-period.md](specs/tech/subsystems/delegation/waiting-period.md).

- **(Ask)** Expose recurring pledge fine-list and unsuspicious-delay editing in the SDK and UI. The contract supports `setPledgeFineListed` and `setPledgeUnsuspiciousDelay`, but the product controls currently edit individual notes only. Make clear that pledge edits affect future notes and do not update already-minted notes. Rules: [spend-classification.md](specs/tech/subsystems/delegation/spend-classification.md).
- **(Tell)** Send the donor-set waiting-period page. Note and project screens show a pending spend's amount and deadline as money that can still be cancelled. `shouldPageDonor` still only decides once per `(noteId, nonce)`. There is no opt-in store and no email or push sender. Public remarks from people who are not flaggers stay a later UI feature and are not stored on-chain. Rules: [waiting-period.md](specs/tech/subsystems/delegation/waiting-period.md).

- One voice for delegation copy. The donor is authorizing an address to spend a stated amount on projects in Commonality. The delegate promises nothing. A stated intent is public and does not bind the spend. Unspent funds stay revocable by the donor. Commonality does not hold the funds, choose the delegate, or supervise the spending. Remove the steward voice: entrusting money to a scout, program-officer framing, "money under management," and any Commonality ranking whose job is to send people to a delegate. A public history of what an address already funded can stay. "Scout" as the early contributor who may later be reimbursed at cost can stay; do not let that word mean a manager of other people's money. Start with `specs/product/legal/retroactive-funding-redesign.md` (Design 2) and `docs/end-user/lazyGiving/` (`retroactive-funding.md`, `index.md`, `fund-something.md`, `get-your-project-funded.md`). No delegate marketplace.

- Reliable revocation of delegated authority over returned funds. Revocation covers the unspent balance, pending spends, and outstanding receipt claims, so a later refund cannot revive authority the donor removed. Failed-project refunds stay inside the same authorization ("keep trying until I revoke") and remain subject to its current rules and revocation state. Successful-project reimbursement recycling is still an open choice; do not settle it in this item. Write the proposal against `specs/tech/subsystems/delegation/` and [delegation-narrowing.md](specs/product/legal/delegation-narrowing.md) before changing contracts.

----

- **(Tell)** Testnet Commonality SPA does not hydrate. `https://testnet.commonality.works/` and deep links now return the HTML shell (SPA fallback after public-gateway 429 is deployed), but browser loads fail on chunks such as `/assets/address-TZjglcQ5.js` (HTTP 429, public IPFS sunset body). Dedicated Pinata origin times out; Worker then falls through to `ipfs.io` / `w3s.link`. Reproduce, fix the Worker/gateway path so real assets are served from Pinata (or another working origin) instead of caching/returning 429, redeploy `cloudflare-ui-gateway`, and verify `/`, `/founders`, and a hydrated heading in a real browser. Pinata dashboard Host Origins remains Adam’s step in [`inbox.md`](inbox.md). Continuity: [`continuity/2026-09-15-commonality-live-gateway-followup.md`](continuity/2026-09-15-commonality-live-gateway-followup.md).

Expand Down
42 changes: 42 additions & 0 deletions hardhat/contracts/delegation/DelegatableNotes.sol
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,20 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder {
}
}

function _copySpendPolicy(uint256 fromNoteId, uint256 toNoteId, address copiedDelegate) private {
SpendPolicy storage policy = spendPolicies[fromNoteId];
address[] memory flaggers = spendFlaggerList[fromNoteId];
_writePolicy(
toNoteId,
policy.delay,
policy.unsuspiciousDelay,
policy.strictMode,
flaggers,
copiedDelegate
);
_copyFineList(fromNoteId, toNoteId);
}

function _copyFineList(uint256 fromNoteId, uint256 toNoteId) private {
bytes32[] storage ids = fineListIds[fromNoteId];
for (uint256 i = 0; i < ids.length; i++) {
Expand Down Expand Up @@ -1159,6 +1173,25 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder {
* @param owners The delegation chain (leaf first, root last)
*/
function revoke(uint256 noteId, address[] calldata owners) external nonReentrant {
_revoke(noteId, owners);
}

/**
* @notice Revoke each note. A note that is already gone is skipped.
* @dev A note that still exists with a wrong chain, or a caller who is not in it, reverts the call.
*/
function revokeMany(
uint256[] calldata noteIds,
address[][] calldata owners
) external nonReentrant {
if (noteIds.length != owners.length) revert ArrayLengthMismatch();
for (uint256 i = 0; i < noteIds.length; i++) {
if (notes[noteIds[i]].chainHash == bytes32(0)) continue;
_revoke(noteIds[i], owners[i]);
}
}

function _revoke(uint256 noteId, address[] calldata owners) private {
address caller = _msgSender();

Note storage note = notes[noteId];
Expand Down Expand Up @@ -1293,6 +1326,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder {
primaryMarket,
erc1155Contract,
tokenId,
inputNoteIds,
paymentChains,
outputShares,
requiredPayment,
Expand Down Expand Up @@ -1405,6 +1439,8 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder {
// NoteCreated alone carries only the leaf; RefundedIntoNote lets the fold copy the full
// chain from the consumed input note (the same pattern ERC1155Purchased uses for outputs).
emit NoteCreated(refundNoteId, chain[0], refundValue, paymentToken, TokenType.ERC20, 0);
_copySpendPolicy(noteId, refundNoteId, chain.length > 1 ? chain[0] : address(0));
_deleteSpendPolicy(noteId);
emit RefundedIntoNote(
_msgSender(),
primaryMarket,
Expand Down Expand Up @@ -1557,6 +1593,7 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder {
address primaryMarket,
address erc1155Contract,
uint256 tokenId,
uint256[] memory inputNoteIds,
address[][] memory chains,
uint256[] memory outputShares,
uint256 totalPayment,
Expand All @@ -1575,6 +1612,11 @@ contract DelegatableNotes is Context, Ownable, ReentrancyGuard, ERC1155Holder {
tokenType: TokenType.ERC1155,
tokenId: tokenId
});
_copySpendPolicy(
inputNoteIds[i],
newNoteId,
chains[i].length > 1 ? chains[i][0] : address(0)
);
reimbursementClaims[newNoteId] = ReimbursementClaim({
primaryMarket: primaryMarket,
contribution: totalPayment * outputShares[i] / totalShares,
Expand Down
31 changes: 24 additions & 7 deletions hardhat/contracts/delegation/RecurringPledges.sol
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ contract RecurringPledges is ReentrancyGuard {
uint256 public nextPledgeId = 1;
mapping(uint256 => Pledge) public pledges;
mapping(uint256 => address[]) private pledgeFlaggerList;
mapping(uint256 => bytes32[]) private pledgeFineList;
mapping(uint256 => bytes32[]) private pledgeFineIds;
mapping(uint256 => mapping(bytes32 => bool)) public pledgeFineListed;

constructor(address delegatableNotesAddress) {
Expand All @@ -98,8 +98,10 @@ contract RecurringPledges is ReentrancyGuard {
uint256 period,
string calldata causeRef,
uint256 spendDelay,
uint256 unsuspiciousDelay,
bool strictMode,
address[] calldata flaggers
address[] calldata flaggers,
bytes32[] calldata fineIds
) external nonReentrant returns (uint256 pledgeId, uint256 firstNoteId) {
address rootOwner = msg.sender;
if (rootOwner == address(0) || delegateTo == address(0) || token == address(0)) revert ZeroAddress();
Expand All @@ -122,6 +124,10 @@ contract RecurringPledges is ReentrancyGuard {
unsuspiciousDelay: 0,
strictMode: strictMode
});
_setUnsuspiciousDelay(pledgeId, pledges[pledgeId], unsuspiciousDelay);
for (uint256 i = 0; i < fineIds.length; i++) {
_setFineListed(pledgeId, fineIds[i], true);
}
for (uint256 i = 0; i < flaggers.length; i++) {
if (flaggers[i] != address(0) && flaggers[i] != delegateTo) {
pledgeFlaggerList[pledgeId].push(flaggers[i]);
Expand Down Expand Up @@ -174,8 +180,7 @@ contract RecurringPledges is ReentrancyGuard {
}
emit PledgeSpendPolicyUpdated(pledgeId, spendDelay, strictMode);
if (pledge.unsuspiciousDelay > spendDelay) {
pledge.unsuspiciousDelay = spendDelay;
emit PledgeUnsuspiciousDelaySet(pledgeId, spendDelay);
_setUnsuspiciousDelay(pledgeId, pledge, spendDelay);
}
}

Expand All @@ -184,6 +189,10 @@ contract RecurringPledges is ReentrancyGuard {
if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist();
if (pledge.rootOwner != msg.sender) revert NotPledgeOwner();
if (!pledge.active) revert PledgeInactive();
_setUnsuspiciousDelay(pledgeId, pledge, unsuspiciousDelay);
}

function _setUnsuspiciousDelay(uint256 pledgeId, Pledge storage pledge, uint256 unsuspiciousDelay) private {
if (unsuspiciousDelay > pledge.spendDelay) revert UnsuspiciousDelayExceedsStanding();
pledge.unsuspiciousDelay = unsuspiciousDelay;
emit PledgeUnsuspiciousDelaySet(pledgeId, unsuspiciousDelay);
Expand All @@ -194,17 +203,21 @@ contract RecurringPledges is ReentrancyGuard {
if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist();
if (pledge.rootOwner != msg.sender) revert NotPledgeOwner();
if (!pledge.active) revert PledgeInactive();
_setFineListed(pledgeId, beneficiaryId, allowed);
}

function _setFineListed(uint256 pledgeId, bytes32 beneficiaryId, bool allowed) private {
if (beneficiaryId == bytes32(0)) revert ZeroAddress();
if (allowed == pledgeFineListed[pledgeId][beneficiaryId]) {
emit PledgeFineListSet(pledgeId, beneficiaryId, allowed);
return;
}
if (allowed) {
pledgeFineListed[pledgeId][beneficiaryId] = true;
pledgeFineList[pledgeId].push(beneficiaryId);
pledgeFineIds[pledgeId].push(beneficiaryId);
} else {
pledgeFineListed[pledgeId][beneficiaryId] = false;
bytes32[] storage ids = pledgeFineList[pledgeId];
bytes32[] storage ids = pledgeFineIds[pledgeId];
for (uint256 i = 0; i < ids.length; i++) {
if (ids[i] == beneficiaryId) {
ids[i] = ids[ids.length - 1];
Expand All @@ -222,6 +235,10 @@ contract RecurringPledges is ReentrancyGuard {
return pledgeFlaggerList[pledgeId];
}

function pledgeFineList(uint256 pledgeId) external view returns (bytes32[] memory) {
return pledgeFineIds[pledgeId];
}

function executeDue(uint256 pledgeId) external nonReentrant returns (uint256 noteId) {
Pledge storage pledge = pledges[pledgeId];
if (pledge.rootOwner == address(0)) revert PledgeDoesNotExist();
Expand Down Expand Up @@ -259,7 +276,7 @@ contract RecurringPledges is ReentrancyGuard {
pledge.unsuspiciousDelay,
pledge.strictMode,
pledgeFlaggerList[pledgeId],
pledgeFineList[pledgeId]
pledgeFineIds[pledgeId]
);
emit StandingPledgeExecuted(pledgeId, noteId, executedAt);
}
Expand Down
52 changes: 52 additions & 0 deletions hardhat/test/DelegatableNotes.refund.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,56 @@ describe("DelegatableNotes - Refund Into Note", function () {
)
).to.be.revertedWithCustomError(notes, "UnauthorizedMarket");
});

it("copies the payment note's rules onto the receipt and the receipt's current rules onto the refund", async function () {
const paymentNoteId = await depositPaymentNote(alice, COST);
await notes.connect(alice).delegate(paymentNoteId, [alice.address], bob.address, COST);
const beneficiaryId = ethers.id("beneficiary");
await notes.connect(alice).setFineListed(paymentNoteId, [bob.address, alice.address], beneficiaryId, true);

const tx = await notes.connect(bob).purchaseFromPrimaryMarket(
[{ noteId: paymentNoteId, chain: [bob.address, alice.address], shares: COUNT }],
await assuranceContract.getAddress(),
await erc1155Token.getAddress(),
TOKEN_ID,
COUNT
);
const purchased = await tx.wait();
const receiptNoteId = purchased.logs.find(l => l.fragment && l.fragment.name === "ERC1155Purchased").args.outputNoteIds[0];
expect(await notes.fineListed(receiptNoteId, beneficiaryId)).to.equal(true);

await notes.connect(alice).setSpendDelay(receiptNoteId, [bob.address, alice.address], 250);
await failTheContract();
const refundTx = await notes.connect(bob).refundIntoNote(
receiptNoteId,
[bob.address, alice.address],
await assuranceContract.getAddress()
);
const refunded = await refundTx.wait();
const refundNoteId = refunded.logs.find(l => l.fragment && l.fragment.name === "RefundedIntoNote").args.outputNoteId;
expect((await notes.spendPolicies(refundNoteId)).delay).to.equal(250);
expect(await notes.fineListed(refundNoteId, beneficiaryId)).to.equal(true);
});

it("revokeMany skips a missing note and revokes the receipt that is still there", async function () {
const receiptNoteId = await setUpDelegatedReceiptNote();
const chain = [bob.address, alice.address];
await notes.connect(alice).revokeMany([999n, receiptNoteId], [chain, chain]);
const revoked = await notes.notes(receiptNoteId);
const rootHash = ethers.keccak256(ethers.solidityPacked(["address", "bytes32"], [alice.address, ethers.ZeroHash]));
expect(revoked.chainHash).to.equal(rootHash);

await failTheContract();
await expect(
notes.connect(bob).refundIntoNote(receiptNoteId, chain, await assuranceContract.getAddress())
).to.be.revertedWithCustomError(notes, "InvalidChain");
const refundTx = await notes.connect(alice).refundIntoNote(
receiptNoteId,
[alice.address],
await assuranceContract.getAddress()
);
const refunded = await refundTx.wait();
const refundNoteId = refunded.logs.find(l => l.fragment && l.fragment.name === "RefundedIntoNote").args.outputNoteId;
expect((await notes.notes(refundNoteId)).chainHash).to.equal(rootHash);
});
});
24 changes: 23 additions & 1 deletion hardhat/test/RecurringPledges.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,28 @@ async function deployFixture() {
}

describe("RecurringPledges", function () {
it("copies the list and shorter delay onto the first note", async function () {
const { alice, bob, notes, recurringPledges, token } = await deployFixture();
const id = ethers.id("dns:example.org");
await token.connect(alice).approve(notes.target, 10_000n);
await recurringPledges.connect(alice).createStandingPledge(
bob.address, token.target, 10_000n, 60, "bafy-cause", 100, 20, false, [], [id]
);
expect(await recurringPledges.pledgeFineList(1)).to.deep.equal([id]);
expect(await notes.fineList(1)).to.deep.equal([id]);
expect((await notes.spendPolicies(1)).unsuspiciousDelay).to.equal(20);
expect((await notes.spendPolicies(1)).delay).to.equal(100);
await expect(recurringPledges.connect(alice).createStandingPledge(
bob.address, token.target, 10_000n, 60, "bafy-cause", 10, 11, false, [], []
)).to.be.revertedWithCustomError(recurringPledges, "UnsuspiciousDelayExceedsStanding");
});

it("copies policy edits only to later notes and enforces owner and delay bounds", async function () {
const { alice, bob, carol, notes, recurringPledges, token } = await deployFixture();
const id = ethers.id("dns:example.org");
await token.connect(alice).approve(notes.target, 30_000n);
await recurringPledges.connect(alice).createStandingPledge(
bob.address, token.target, 10_000n, 60, "bafy-cause", 100, true, [carol.address]
bob.address, token.target, 10_000n, 60, "bafy-cause", 100, 0, true, [carol.address], []
);
await expect(recurringPledges.connect(bob).setPledgeFineListed(1, id, true))
.to.be.revertedWithCustomError(recurringPledges, "NotPledgeOwner");
Expand Down Expand Up @@ -79,7 +95,9 @@ describe("RecurringPledges", function () {
period,
"bafy-cause",
0,
0,
false,
[],
[]
);

Expand Down Expand Up @@ -119,7 +137,9 @@ describe("RecurringPledges", function () {
period,
"bafy-cause",
0,
0,
false,
[],
[]
);

Expand Down Expand Up @@ -150,7 +170,9 @@ describe("RecurringPledges", function () {
60,
"bafy-cause",
0,
0,
false,
[],
[]
);

Expand Down
18 changes: 18 additions & 0 deletions indexer/abis/DelegatableNotesAbi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2059,6 +2059,24 @@ export const DelegatableNotesAbi = [
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256[]",
"name": "noteIds",
"type": "uint256[]"
},
{
"internalType": "address[][]",
"name": "owners",
"type": "address[][]"
}
],
"name": "revokeMany",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
Expand Down
29 changes: 29 additions & 0 deletions indexer/abis/RecurringPledgesAbi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ export const RecurringPledgesAbi = [
"name": "spendDelay",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "unsuspiciousDelay",
"type": "uint256"
},
{
"internalType": "bool",
"name": "strictMode",
Expand All @@ -297,6 +302,11 @@ export const RecurringPledgesAbi = [
"internalType": "address[]",
"name": "flaggers",
"type": "address[]"
},
{
"internalType": "bytes32[]",
"name": "fineIds",
"type": "bytes32[]"
}
],
"name": "createStandingPledge",
Expand Down Expand Up @@ -422,6 +432,25 @@ export const RecurringPledgesAbi = [
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "pledgeId",
"type": "uint256"
}
],
"name": "pledgeFineList",
"outputs": [
{
"internalType": "bytes32[]",
"name": "",
"type": "bytes32[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
Expand Down
Loading
Loading