Skip to content

Commit 6db9221

Browse files
Address review: fix receipt typecheck and export redacted serialize API.
Build validated RoundReceipt objects without unsafe casts, and add serializeCanonicalJson for redacted artifacts that fail schema validation. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d10977c commit 6db9221

4 files changed

Lines changed: 69 additions & 20 deletions

File tree

packages/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export {
8686

8787
export {
8888
serializeReceipt,
89+
serializeCanonicalJson,
8990
parseReceipt,
9091
validateReceipt,
9192
networkFingerprint,

packages/sdk/src/public-api-snapshot.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const EXPECTED_EXPORTS = [
4545
"parseReceipt",
4646
"redactReceipt",
4747
"runMainnetReadiness",
48+
"serializeCanonicalJson",
4849
"serializeReceipt",
4950
"tryDecodeBase64",
5051
"tryDecodeHex",

packages/sdk/src/receipt.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,19 @@ function validateBidEntry(value: unknown, field: string): BidReceiptEntry {
170170
assertNullableHexString(evidence.ciphertext, `${field}.evidence.ciphertext`);
171171
assertNullableHexString(evidence.auditorBlob, `${field}.evidence.auditorBlob`);
172172

173-
return entry as BidReceiptEntry;
173+
return {
174+
commitment: entry.commitment as string,
175+
escrow: entry.escrow as string,
176+
revealedValue: entry.revealedValue as string | null,
177+
nonce: entry.nonce as string | null,
178+
hashValid: entry.hashValid as boolean | null,
179+
valid: entry.valid as boolean,
180+
settled: entry.settled as boolean,
181+
evidence: {
182+
ciphertext: evidence.ciphertext as string | null,
183+
auditorBlob: evidence.auditorBlob as string | null,
184+
},
185+
};
174186
}
175187

176188
/** Validate a parsed or in-memory receipt before export/serialize. */
@@ -224,11 +236,12 @@ export function validateReceipt(value: unknown): RoundReceipt {
224236
}
225237

226238
const bids = receipt.bids as Record<string, unknown>;
239+
const validatedBids: Record<string, BidReceiptEntry> = {};
227240
for (const bidder of receipt.bidders as string[]) {
228241
if (!(bidder in bids)) {
229242
validationError(`missing bid entry for bidder ${bidder}`, `bids.${bidder}`);
230243
}
231-
validateBidEntry(bids[bidder], `bids.${bidder}`);
244+
validatedBids[bidder] = validateBidEntry(bids[bidder], `bids.${bidder}`);
232245
}
233246

234247
if (receipt.winner !== null) {
@@ -240,14 +253,44 @@ export function validateReceipt(value: unknown): RoundReceipt {
240253
assertNonEmptyString(receipt.artifactChecksum, "artifactChecksum");
241254
}
242255

243-
return receipt as RoundReceipt;
256+
const validated: RoundReceipt = {
257+
version: RECEIPT_VERSION,
258+
network: receipt.network as string,
259+
networkFingerprint: receipt.networkFingerprint as string,
260+
contractId: receipt.contractId as string,
261+
exportedAt: receipt.exportedAt as string,
262+
roundId: receipt.roundId as string,
263+
itemRef: receipt.itemRef as string,
264+
revealRound: receipt.revealRound as number,
265+
clearingRule: receipt.clearingRule as string,
266+
commitDeadline: receipt.commitDeadline as string,
267+
revealDeadline: receipt.revealDeadline as string,
268+
operator: receipt.operator as string,
269+
auditorPubkey: receipt.auditorPubkey as string,
270+
bidders: [...(receipt.bidders as string[])],
271+
bids: validatedBids,
272+
winner: receipt.winner as string | null,
273+
winningValue: receipt.winningValue as string | null,
274+
status: receipt.status as string,
275+
};
276+
if (receipt.artifactChecksum !== undefined) {
277+
validated.artifactChecksum = receipt.artifactChecksum as string;
278+
}
279+
return validated;
280+
}
281+
282+
/** Canonical JSON (deep-sorted keys, trailing newline) without schema checks.
283+
* Use for redacted / partial artifacts that are not valid RoundReceipt values.
284+
* Verifiable receipts must go through {@link serializeReceipt}. */
285+
export function serializeCanonicalJson(value: unknown): string {
286+
return JSON.stringify(value, sortKeys) + "\n";
244287
}
245288

246289
/** Serialise a receipt to canonical JSON (deep-sorted keys, no whitespace).
247290
* This is the format the CLI writes and the verifier reads. */
248291
export function serializeReceipt(receipt: RoundReceipt): string {
249292
validateReceipt(receipt);
250-
return JSON.stringify(receipt, sortKeys) + "\n";
293+
return serializeCanonicalJson(receipt);
251294
}
252295

253296
/** Parse a receipt from its canonical JSON form. */

packages/sdk/src/redact.test.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
33

4-
import { serializeReceipt, parseReceipt, type RoundReceipt } from "./receipt.js";
4+
import {
5+
serializeReceipt,
6+
serializeCanonicalJson,
7+
type RoundReceipt,
8+
} from "./receipt.js";
9+
import { SubRosaReceiptValidationError } from "./errors.js";
510
import { redactReceipt, type BidReceiptEntry } from "./redact.js";
611
import { verifyReceipt } from "./verify.js";
712

8-
function canonicalJson(value: unknown): string {
9-
const sortKeys = (_: string, v: unknown): unknown => {
10-
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
11-
return Object.fromEntries(
12-
Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
13-
);
14-
}
15-
return v;
16-
};
17-
return JSON.stringify(value, sortKeys) + "\n";
18-
}
19-
2013
function makeReceipt(): RoundReceipt {
2114
return {
2215
version: 1,
@@ -148,11 +141,22 @@ test("preserves network and verification metadata", () => {
148141

149142
test("redaction output is deterministic", () => {
150143
const receipt = makeReceipt();
151-
const first = canonicalJson(redactReceipt(receipt));
152-
const second = canonicalJson(redactReceipt(receipt));
144+
const first = serializeCanonicalJson(redactReceipt(receipt));
145+
const second = serializeCanonicalJson(redactReceipt(receipt));
153146
assert.equal(first, second);
154147
});
155148

149+
test("serializeReceipt rejects redacted receipts; serializeCanonicalJson accepts them", () => {
150+
const redacted = redactReceipt(makeReceipt());
151+
assert.throws(
152+
() => serializeReceipt(redacted),
153+
(err: unknown) => err instanceof SubRosaReceiptValidationError,
154+
);
155+
const canonical = serializeCanonicalJson(redacted);
156+
assert.match(canonical, /"<redacted>"/);
157+
assert.equal(canonical, serializeCanonicalJson(JSON.parse(canonical)));
158+
});
159+
156160
test("keep-list preserves specified top-level field", () => {
157161
const receipt = makeReceipt();
158162
const redacted = redactReceipt(receipt, { keep: ["contractId"] });
@@ -289,5 +293,5 @@ test("idempotent on nested structures", () => {
289293
};
290294
const once = redactReceipt(receipt);
291295
const twice = redactReceipt(once);
292-
assert.equal(canonicalJson(once), canonicalJson(twice));
296+
assert.equal(serializeCanonicalJson(once), serializeCanonicalJson(twice));
293297
});

0 commit comments

Comments
 (0)