diff --git a/src/society.ts b/src/society.ts index 51b4b87a1..43c96fbb2 100644 --- a/src/society.ts +++ b/src/society.ts @@ -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 @@ -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(); - 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 @@ -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(); + const take = (r: Record) => { + 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 @@ -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. diff --git a/test/listings.test.ts b/test/listings.test.ts index 2de4dad59..f0a70eddc 100644 --- a/test/listings.test.ts +++ b/test/listings.test.ts @@ -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; @@ -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); +});