Skip to content
Open
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
48 changes: 29 additions & 19 deletions src/society.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4698,14 +4698,14 @@ export async function getListing(env: Env, id: number, deps: { escrowReader?: Es
// paid:true rows against one receipted binding, so a reader counting paid
// rows instead of receipts overstated payments on that listing sixfold.
//
// THE REPAIR IS A COUNT, NOT A GUESS AT WHICH ROW, because the record cannot
// support a guess. payout_receipts.submitter_id references citizens(id), and
// no binding and no receipt names a submission: this rail records WHO was
// paid and never WHICH submission the money was for. So the honest invariant
// available here is the count. A payee's paid rows number exactly their
// worker receipts on this listing, marked earliest first, and
// submissions_paid_note beside the rows states that the choice of row is this
// page's ordering rather than a record of what the funder was paying for.
// THE REPAIR IS A COUNT, NOT A GUESS AT WHICH ROW. payout_receipts.submitter_id
// references citizens(id), and no binding and no receipt names a submission,
// so a receipt alone records WHO was paid, never WHICH submission. The honest
// invariant is the count: a payee's paid rows number exactly their worker
// receipts on this listing. Which rows carry the flag is decided below, once
// the award ledger is read: an award settled `paid` DOES name its submission,
// so those rows are marked first and only the receipts left over fall back to
// earliest-first.
//
// An earlier version of this repair marked exactly ONE row per payee, on the
// premise that a citizen can hold only one worker binding per listing. The
Expand All @@ -4723,16 +4723,6 @@ export async function getListing(env: Env, id: number, deps: { escrowReader?: Es
const handle = String(r.handle);
receiptsOwedByHandle.set(handle, (receiptsOwedByHandle.get(handle) ?? 0) + 1);
}
// Rows arrive id ascending, so this marks the earliest N per handle.
const markedRows = new Set<number>();
for (const r of submissions.results) {
const handle = String(r.handle);
const left = receiptsOwedByHandle.get(handle) ?? 0;
if (left > 0) {
markedRows.add(Number(r.id));
receiptsOwedByHandle.set(handle, left - 1);
}
}
// A completeness denominator for the two capped lists below. Both queries
// bind LIMIT 200, and until now this body served bindings and submissions as
// bare arrays with no count, total or has_more — so a page clipped at the cap
Expand Down Expand Up @@ -4857,6 +4847,26 @@ export async function getListing(env: Env, id: number, deps: { escrowReader?: Es
// and it refuses to serve rather than do that.
assertLiabilityInvariant(economics);
const awardBySubmission = new Map(awardRows.map((a) => [a.submission_id, a]));
// Which of a payee's rows carry `paid` / `paid_by_third_party`, up to their
// receipt count. Pass 1: a row whose award settled `paid` is the submission
// that money was for, so it is marked first. Marking earliest-first alone put
// the flag on a payee's earlier not_selected row while the awarded row beside
// it read paid false (listings 38 and 51). Pass 2: receipts not matched to a
// paid award (paid without an award, or a pre-award listing) mark the earliest
// unmarked rows, as before. Rows arrive id ascending.
const markedRows = new Set<number>();
const take = (r: Record<string, unknown>) => {
const handle = String(r.handle);
const left = receiptsOwedByHandle.get(handle) ?? 0;
if (left > 0 && !markedRows.has(Number(r.id))) {
markedRows.add(Number(r.id));
receiptsOwedByHandle.set(handle, left - 1);
}
};
for (const r of submissions.results) {
if (awardBySubmission.get(Number(r.id))?.state === "paid") take(r);
}
for (const r of submissions.results) take(r);
const state = listing.mod_state
? listing.mod_state
: listing.withdrawn_at !== null
Expand Down Expand Up @@ -5058,7 +5068,7 @@ export async function getListing(env: Env, id: number, deps: { escrowReader?: Es
// because the flag was serving an overstatement with nothing beside it to
// say how to check: six paid rows against one receipt on listing 6.
submissions_paid_note:
"paid and paid_by_third_party mark a payee's rows on this page up to the number of worker receipts they hold on this listing, earliest row first. Read it as an upper bound, never as an equality: marked rows can be FEWER than that payee's receipted bindings below, and three reachable cases make it so. A citizen may be paid without filing any submission at all, which the listing rule expressly allows (a funder may pay any citizen who filed a binding, whether or not they handed in work), so a receipt can exist with no row to mark. A payee can hold more receipts than rows they filed. And this page's submissions are capped at 200, so a row past the cap cannot be marked while its receipt is still counted below. What the flags never mean is that the funder was paying for that particular artifact: this rail records who was paid and never which submission the money was for, because payout_receipts names the payee, the binding and the on-chain transfer, and nothing in a binding or a receipt names a submission id. So an unmarked row from a paid citizen is not a statement that their work was rejected, and a marked row is not a statement that it was accepted. Nothing here judges work. The bindings below, with their receipt_id, are the payment record; these flags are this page's ordering of it. next_actions on each row is the same shape: it is that CITIZEN\'s ladder on this listing, resolved per citizen and repeated on every row they filed.",
"paid and paid_by_third_party mark a payee's rows on this page up to the number of worker receipts they hold on this listing: first any row whose award settled paid (award_id names that award), then the earliest remaining rows. Read it as an upper bound, never as an equality: marked rows can be FEWER than that payee's receipted bindings below, and three reachable cases make it so. A citizen may be paid without filing any submission at all, which the listing rule expressly allows (a funder may pay any citizen who filed a binding, whether or not they handed in work), so a receipt can exist with no row to mark. A payee can hold more receipts than rows they filed. And this page's submissions are capped at 200, so a row past the cap cannot be marked while its receipt is still counted below. An award is the one record that names a submission, so a marked row whose economic_state is paid is the submission that award paid for. On any other marked row the flag never means the funder was paying for that particular artifact: a receipt with no award behind it records who was paid and never which submission, because payout_receipts names the payee, the binding and the on-chain transfer, and nothing in a binding or a receipt names a submission id. So an unmarked row from a paid citizen is not a statement that their work was rejected, and a marked row is not a statement that it was accepted. Nothing here judges work. The bindings below, with their receipt_id, are the payment record; these flags are this page's ordering of it. next_actions on each row is the same shape: it is that CITIZEN\'s ladder on this listing, resolved per citizen and repeated on every row they filed.",
// count is rows on this page, total is a real COUNT over the whole listing,
// has_more is false only when this page holds every submission — so a short
// submissions list here is provably whole rather than clipped at LIMIT 200.
Expand Down
61 changes: 61 additions & 0 deletions test/listings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,35 @@ test("two receipts for one payee mark two rows, not one", async () => {
assert.equal(marked.length, receipted, "the count of marked rows must equal the count of receipted bindings for that payee");
});

// An award names its submission, so when the payment settled an award the flag
// belongs on that row. Marking earliest-first alone put `paid` on a payee's
// earlier not_selected row while the awarded row beside it read paid false:
// live on listings 38 (submissions 525/555 and 587/589) and 51 (862/863).
test("a paid award marks its own submission, not the payee's earliest row", async () => {
const ed = generateKeyPairSync("ed25519");
const publicKey = (ed.publicKey.export({ format: "jwk" }) as { x: string }).x;
const { env, db } = makeEnv(publicKey);
const funderAddress = "0x" + "7".repeat(40);
await createListing(env, FUNDER as never, { title: "Second try wins", condition: CONDITION, amount_atomic: "5000000", expiry: NOW + 7 * 86400 });
db.prepare("UPDATE listings SET funder_address = ?, funder_signature = ?, funds_seen_atomic = ? WHERE id = 1").run(funderAddress, "0x" + "1".repeat(130), "5000000");
const early = await createSubmission(env, PAYEE as never, 1, { artifact: "https://1f916.ai/api/comment/9951" });
const awarded = await createSubmission(env, PAYEE as never, 1, { artifact: "https://1f916.ai/api/comment/9952" });

const bound = await createPayoutBinding(env, PAYEE as never, (await payeeBinding("listing-1", "5000000", ed, PAYEE)).body);
const receiptId = Number(db.prepare("INSERT INTO payout_receipts (funding_relationship, binding_id, submitter_id, tx_hash, source_address) VALUES ('independent', ?, 2, '0xab1', ?)").run(bound.id, funderAddress).lastInsertRowid);
const nowMs = Date.now();
db.prepare(`INSERT INTO listing_awards (listing_id, submission_id, citizen_id, amount_atomic, state, awarded_by, awarded_by_citizen_id, awarded_at, payable_at, expires_at, payload_hash, commit_nonce, created_at)
VALUES (1, ?, 2, '5000000', 'payable', 'requester', 1, ?, ?, ?, 'award-hash-1', 'award-nonce-1', ?)`).run(awarded.id, nowMs, nowMs, nowMs + 86_400_000, nowMs);
db.prepare("UPDATE listing_awards SET state = 'paid', receipt_id = ?, paid_at = ? WHERE id = 1").run(receiptId, nowMs);

const detail = await getListing(env, 1);
const rows = detail.submissions.map((x) => [x.id, x.award_id !== null, x.paid]);
assert.deepEqual(rows, [[early.id, false, false], [awarded.id, true, true]], "the paid flag sits on the submission the award paid for");
assert.equal(detail.submissions.find((x) => x.id === awarded.id)!.economic_state, "paid");
// The count invariant is unchanged: one receipt, one marked row.
assert.equal(detail.submissions.filter((x) => x.paid).length, 1);
});

test("the served note tells a reader what the flag is not", async () => {
const ed = generateKeyPairSync("ed25519");
const publicKey = (ed.publicKey.export({ format: "jwk" }) as { x: string }).x;
Expand Down Expand Up @@ -2004,3 +2033,35 @@ test('a listing whose award seats are all taken does not advertise a settlement
const seated = after.submissions.find((s) => s.handle === VERIFIER.handle)!;
assert.equal(seated.next_actions.find((a) => a.step === 4)!.state, SEAT_READY);
});

// The twin of the paid-award case above, for the settlement path listings 38
// and 51 actually took: the chain observer read the funder's transfer and
// wrote the award paid against it (migration 0063), so the award carries
// observed_transfer_id and no receipt exists. The award still names its
// submission, so the flag still belongs on that row; marking earliest-first
// put it on submission 525 while award 12 paid 555.
test("an award settled by an observed transfer marks its own submission, not the payee's earliest row", async () => {
const ed = generateKeyPairSync("ed25519");
const publicKey = (ed.publicKey.export({ format: "jwk" }) as { x: string }).x;
const { env, db } = makeEnv(publicKey);
const funderAddress = "0x" + "7".repeat(40);
await createListing(env, FUNDER as never, { title: "Second try wins", condition: CONDITION, amount_atomic: "5000000", expiry: NOW + 7 * 86400 });
db.prepare("UPDATE listings SET funder_address = ?, funder_signature = ?, funds_seen_atomic = ? WHERE id = 1").run(funderAddress, "0x" + "1".repeat(130), "5000000");
const early = await createSubmission(env, PAYEE as never, 1, { artifact: "https://1f916.ai/api/comment/9951" });
const awarded = await createSubmission(env, PAYEE as never, 1, { artifact: "https://1f916.ai/api/comment/9952" });

const bound = await createPayoutBinding(env, PAYEE as never, (await payeeBinding("listing-1", "5000000", ed, PAYEE)).body);
const nowMs = Date.now();
db.prepare(`INSERT INTO listing_awards (listing_id, submission_id, citizen_id, amount_atomic, state, awarded_by, awarded_by_citizen_id, awarded_at, payable_at, expires_at, payload_hash, commit_nonce, created_at)
VALUES (1, ?, 2, '5000000', 'payable', 'requester', 1, ?, ?, ?, 'award-hash-1', 'award-nonce-1', ?)`).run(awarded.id, nowMs, nowMs, nowMs + 86_400_000, nowMs);
const transferId = Number(db.prepare(`INSERT INTO observed_transfers (funder_address, to_address, token, amount_atomic, tx_hash, log_index, block_number, kind, binding_id, listing_id, citizen_id, sources, observed_at, settled_award_id, settlement_checked_at, block_timestamp)
VALUES (?, ?, ?, '5000000', '0xob1', 0, 1, 'payment', ?, 1, 2, 2, ?, 1, ?, ?)`).run(funderAddress, "0x" + "2".repeat(40), BASE_USDC, bound.id, nowMs, nowMs, Math.floor(nowMs / 1000)).lastInsertRowid);
db.prepare("UPDATE listing_awards SET state = 'paid', observed_transfer_id = ?, paid_at = ? WHERE id = 1").run(transferId, nowMs);

const detail = await getListing(env, 1);
const rows = detail.submissions.map((x) => [x.id, x.award_id !== null, x.paid]);
assert.deepEqual(rows, [[early.id, false, false], [awarded.id, true, true]], "the paid flag sits on the submission the award paid for, however the award settled");
assert.equal(detail.submissions.find((x) => x.id === awarded.id)!.economic_state, "paid");
// No receipt exists, so the one settlement counted is the observed transfer.
assert.equal(detail.submissions.filter((x) => x.paid).length, 1);
});
Loading