diff --git a/docs/integration-guide.md b/docs/integration-guide.md index a090566..13c749b 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -226,3 +226,109 @@ PromptHash retries failed deliveries up to 3 times with exponential backoff (2s, ```typescript import type { PromptInfo, PurchaseResult, ClientConfig } from "@prompthash/sdk"; ``` + +--- + +## Standalone Purchase Receipt Verification + + + +Buyers and auditors can verify any purchase receipt **without relying on the hosted PromptHash app** — all checks run against the public [Stellar Horizon API](https://horizon.stellar.org). + +### What a receipt contains + +| Field | Description | +|-------|-------------| +| `promptId` | PromptHash internal prompt identifier | +| `version` | Content revision at time of purchase | +| `buyer` | Stellar address of the buyer | +| `txHash` | Stellar transaction hash confirming the purchase | +| `contractId` | Soroban contract ID hosting the marketplace | +| `contentHash` | SHA-256 hex of the encrypted prompt content | +| `purchasedAt` | UNIX timestamp (seconds) of the purchase | +| `amountStroops` | Amount paid in stroops (1 XLM = 10,000,000 stroops) | +| `nonce` | Optional replay-protection nonce | + +### Verify from exported receipt data + +```typescript +import { + verifyReceipt, + verifyReceiptFromExport, + checkReceiptIntegrity, + type PurchaseReceipt, +} from "@/lib/receipts/receiptVerifier"; + +// Option A — verify a structured receipt object +const receipt: PurchaseReceipt = { + promptId: "prompt_abc123", + version: 2, + buyer: "GABC...YOURADDRESS", + txHash: "a1b2c3...", + contractId: "CDLZ...", + contentHash: "e3b0c4...", + purchasedAt: 1700000000, + amountStroops: "14000000", +}; + +const result = await verifyReceipt(receipt, "mainnet"); +console.log(result.status); // "valid" | "tampered" | "tx_not_found" | ... +console.log(result.message); // Human-readable explanation +console.log(result.explorerUrl); // Stellar Expert link for the tx + +// Option B — verify a JSON string exported from the app +const exported = `{"promptId":"prompt_abc123","buyer":"GABC...","txHash":"a1b2c3...",...}`; +const result2 = await verifyReceiptFromExport(exported, "mainnet"); + +// Option C — offline tamper check using a stored fingerprint +const { computedFingerprint } = checkReceiptIntegrity(receipt, ""); +// Store computedFingerprint. Later, to verify nothing changed: +const { intact } = checkReceiptIntegrity(receipt, computedFingerprint); +console.log(intact); // true if unmodified +``` + +### Verification status codes + +| Status | Meaning | +|--------|---------| +| `valid` | Receipt is authentic; transaction confirmed on-chain | +| `tampered` | One or more receipt fields are missing or malformed | +| `tx_not_found` | Transaction hash not found on the Stellar network | +| `tx_failed` | Transaction exists but did not succeed | +| `buyer_mismatch` | On-chain source account does not match the receipt buyer | +| `error` | Horizon was unreachable or returned an unexpected error | + +### Tamper detection + +Any modification to a receipt field (`promptId`, `buyer`, `txHash`, `contentHash`, `amountStroops`, etc.) changes the receipt fingerprint and will be detected: + +```typescript +const tampered = { ...receipt, amountStroops: "1" }; // attacker changes amount +const { intact } = checkReceiptIntegrity(tampered, storedFingerprint); +console.log(intact); // false — tampering detected +``` + +### Running offline (no internet) + +Use `checkReceiptIntegrity` for tamper detection when Stellar Horizon is unavailable. +The fingerprint is a deterministic SHA-256 hash of all receipt fields; it never +requires a network call. + +### CLI usage (Node.js) + +```bash +# Install dependencies (only @stellar/stellar-sdk is needed at runtime) +npm install @stellar/stellar-sdk + +# Verify from a saved receipt file +node -e " +const { verifyReceiptFromExport } = require('./dist/lib/receipts/receiptVerifier'); +const fs = require('fs'); +const exported = fs.readFileSync('./my-receipt.json', 'utf8'); +verifyReceiptFromExport(exported, 'mainnet').then(r => { + console.log('Status:', r.status); + console.log('Message:', r.message); + console.log('Explorer:', r.explorerUrl); +}); +" +``` diff --git a/src/lib/receipts/receiptVerifier.test.ts b/src/lib/receipts/receiptVerifier.test.ts new file mode 100644 index 0000000..083a4bd --- /dev/null +++ b/src/lib/receipts/receiptVerifier.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for standalone receipt verifier — Issue #238 + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + verifyReceipt, + verifyReceiptFromExport, + checkReceiptIntegrity, + type PurchaseReceipt, +} from "./receiptVerifier"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const validReceipt: PurchaseReceipt = { + promptId: "prompt_abc123", + version: 2, + buyer: "GABC1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF123456", + txHash: "a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890", + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN3", + contentHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + purchasedAt: 1_700_000_000, + amountStroops: "14000000", +}; + +/** Simulate a successful Horizon transaction response */ +function mockHorizonSuccess(overrides: Record = {}): unknown { + return { + successful: true, + source_account: validReceipt.buyer, + ledger: 50_000_001, + created_at: "2023-11-14T22:13:20Z", + fee_charged: "100", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockFetch(payload: unknown, status = 200): void { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => payload, + }), + ); +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// verifyReceipt — happy path +// --------------------------------------------------------------------------- + +describe("verifyReceipt — valid receipt", () => { + it("returns status=valid for a confirmed on-chain purchase", async () => { + mockFetch(mockHorizonSuccess()); + + const result = await verifyReceipt(validReceipt, "testnet"); + + expect(result.status).toBe("valid"); + expect(result.message).toMatch(/authentic/i); + expect(result.explorerUrl).toContain(validReceipt.txHash); + expect(result.details?.fingerprint).toBeTypeOf("string"); + expect(result.details?.fingerprint).toHaveLength(64); + }); + + it("sets explorerUrl for mainnet", async () => { + mockFetch(mockHorizonSuccess()); + + const result = await verifyReceipt(validReceipt, "mainnet"); + + expect(result.explorerUrl).toContain("public"); + }); + + it("sets explorerUrl for testnet", async () => { + mockFetch(mockHorizonSuccess()); + + const result = await verifyReceipt(validReceipt, "testnet"); + + expect(result.explorerUrl).toContain("testnet"); + }); +}); + +// --------------------------------------------------------------------------- +// Tamper detection +// --------------------------------------------------------------------------- + +describe("verifyReceipt — tampered fields", () => { + it("returns status=buyer_mismatch when buyer is altered", async () => { + mockFetch(mockHorizonSuccess()); + + const tampered = { ...validReceipt, buyer: "GZZZ_WRONG_BUYER" }; + const result = await verifyReceipt(tampered, "testnet"); + + expect(result.status).toBe("buyer_mismatch"); + expect(result.message).toContain("GZZZ_WRONG_BUYER"); + }); + + it("returns status=tampered when contentHash is not valid hex", async () => { + mockFetch(mockHorizonSuccess()); + + const tampered = { ...validReceipt, contentHash: "not-a-valid-hash!!" }; + const result = await verifyReceipt(tampered, "testnet"); + + expect(result.status).toBe("tampered"); + expect(result.message).toMatch(/sha-256/i); + }); + + it("returns status=tampered when a required field is missing", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { promptId: _removed, ...noPromptId } = validReceipt; + const result = await verifyReceipt(noPromptId as PurchaseReceipt, "testnet"); + + expect(result.status).toBe("tampered"); + expect(result.message).toContain("promptId"); + }); + + it("returns status=tampered when contentHash length is wrong (not 64 chars)", async () => { + const tampered = { ...validReceipt, contentHash: "deadbeef" }; // too short + const result = await verifyReceipt(tampered, "testnet"); + + expect(result.status).toBe("tampered"); + }); +}); + +// --------------------------------------------------------------------------- +// Blockchain state failures +// --------------------------------------------------------------------------- + +describe("verifyReceipt — blockchain checks", () => { + it("returns status=tx_not_found when Horizon returns 404", async () => { + mockFetch(null, 404); + + const result = await verifyReceipt(validReceipt, "testnet"); + + expect(result.status).toBe("tx_not_found"); + expect(result.message).toContain("not found"); + }); + + it("returns status=tx_failed when transaction was unsuccessful", async () => { + mockFetch(mockHorizonSuccess({ successful: false })); + + const result = await verifyReceipt(validReceipt, "testnet"); + + expect(result.status).toBe("tx_failed"); + expect(result.message).toMatch(/did not succeed/i); + }); + + it("returns status=error when Horizon is unreachable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network failure"))); + + const result = await verifyReceipt(validReceipt, "testnet"); + + expect(result.status).toBe("error"); + expect(result.message).toMatch(/horizon/i); + }); +}); + +// --------------------------------------------------------------------------- +// verifyReceiptFromExport +// --------------------------------------------------------------------------- + +describe("verifyReceiptFromExport", () => { + it("parses and verifies a plain-JSON export", async () => { + mockFetch(mockHorizonSuccess()); + + const exported = JSON.stringify(validReceipt); + const result = await verifyReceiptFromExport(exported, "testnet"); + + expect(result.status).toBe("valid"); + }); + + it("parses and verifies a base64-encoded JSON export", async () => { + mockFetch(mockHorizonSuccess()); + + const exported = Buffer.from(JSON.stringify(validReceipt)).toString("base64"); + const result = await verifyReceiptFromExport(exported, "testnet"); + + expect(result.status).toBe("valid"); + }); + + it("returns status=tampered for corrupted export strings", async () => { + const result = await verifyReceiptFromExport("not_json_at_all!!!", "testnet"); + + expect(result.status).toBe("tampered"); + expect(result.message).toMatch(/corrupt/i); + }); +}); + +// --------------------------------------------------------------------------- +// checkReceiptIntegrity — offline tamper detection +// --------------------------------------------------------------------------- + +describe("checkReceiptIntegrity", () => { + it("confirms integrity when fingerprint matches", () => { + // First: compute a known-good fingerprint + const goodResult = checkReceiptIntegrity(validReceipt, "placeholder"); + const fingerprint = goodResult.computedFingerprint; + + // Second: verify with the computed fingerprint + const { intact } = checkReceiptIntegrity(validReceipt, fingerprint); + expect(intact).toBe(true); + }); + + it("detects tampering when any receipt field changes", () => { + const { computedFingerprint } = checkReceiptIntegrity(validReceipt, ""); + + const tampered = { ...validReceipt, amountStroops: "99999999" }; + const { intact } = checkReceiptIntegrity(tampered, computedFingerprint); + + expect(intact).toBe(false); + }); + + it("returns the computed fingerprint as a 64-char hex string", () => { + const { computedFingerprint } = checkReceiptIntegrity(validReceipt, ""); + + expect(computedFingerprint).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/src/lib/receipts/receiptVerifier.ts b/src/lib/receipts/receiptVerifier.ts new file mode 100644 index 0000000..f59b567 --- /dev/null +++ b/src/lib/receipts/receiptVerifier.ts @@ -0,0 +1,273 @@ +/** + * Standalone Purchase Receipt Verifier — Issue #238 + * + * Verifies prompt purchase receipts without any hosted-app dependency. + * Works entirely from exported receipt data + Stellar Horizon public API. + */ + +import { createHash } from "crypto"; + +/** Stellar public RPC endpoints — no hosted app needed */ +const HORIZON = { + mainnet: "https://horizon.stellar.org", + testnet: "https://horizon-testnet.stellar.org", +}; + +export type StellarNetwork = "mainnet" | "testnet"; + +export interface PurchaseReceipt { + /** PromptHash internal prompt identifier */ + promptId: string; + /** Content revision at time of purchase */ + version: number; + /** Stellar address of the buyer */ + buyer: string; + /** Stellar transaction hash confirming the purchase */ + txHash: string; + /** Soroban contract ID hosting the marketplace */ + contractId: string; + /** SHA-256 hex of the encrypted prompt content at purchase time */ + contentHash: string; + /** UNIX timestamp (seconds) when the purchase was recorded */ + purchasedAt: number; + /** Amount paid in stroops (1 XLM = 10_000_000 stroops) */ + amountStroops: string; + /** Optional nonce for replay-protection */ + nonce?: string; +} + +export type VerificationStatus = + | "valid" + | "tampered" + | "tx_not_found" + | "tx_failed" + | "contract_mismatch" + | "buyer_mismatch" + | "amount_mismatch" + | "hash_mismatch" + | "error"; + +export interface VerificationResult { + status: VerificationStatus; + receipt: PurchaseReceipt; + /** Human-readable explanation of the result */ + message: string; + /** Stellar Horizon URL for the transaction (always set when txHash is present) */ + explorerUrl: string; + /** ISO timestamp of when verification was run */ + verifiedAt: string; + /** Extra details useful for debugging */ + details?: Record; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Compute the canonical SHA-256 receipt fingerprint */ +function computeReceiptFingerprint(receipt: PurchaseReceipt): string { + const canonical = [ + receipt.promptId, + String(receipt.version), + receipt.buyer.trim(), + receipt.txHash.trim(), + receipt.contractId.trim(), + receipt.contentHash.trim(), + String(receipt.purchasedAt), + receipt.amountStroops, + receipt.nonce ?? "", + ].join("|"); + return createHash("sha256").update(canonical).digest("hex"); +} + +/** Fetch a Stellar transaction from Horizon */ +async function fetchStellarTx( + txHash: string, + network: StellarNetwork, +): Promise> { + const base = HORIZON[network]; + const url = `${base}/transactions/${txHash}`; + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + if (res.status === 404) throw Object.assign(new Error("tx_not_found"), { code: "tx_not_found" }); + throw Object.assign(new Error(`Horizon error ${res.status}`), { code: "error" }); + } + return res.json() as Promise>; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Verify a purchase receipt against the Stellar blockchain. + * + * No hosted PromptHash server is required — all checks run against + * the public Stellar Horizon API and local deterministic logic. + * + * @example + * ```ts + * import { verifyReceipt } from "@/lib/receipts/receiptVerifier"; + * + * const result = await verifyReceipt(myReceipt, "mainnet"); + * if (result.status === "valid") { + * console.log("Receipt is authentic!"); + * } else { + * console.error(result.message); + * } + * ``` + */ +export async function verifyReceipt( + receipt: PurchaseReceipt, + network: StellarNetwork = "mainnet", +): Promise { + const explorerBase = + network === "mainnet" + ? "https://stellar.expert/explorer/public/tx" + : "https://stellar.expert/explorer/testnet/tx"; + + const explorerUrl = `${explorerBase}/${receipt.txHash}`; + const verifiedAt = new Date().toISOString(); + + // ── 1. Basic field presence check ──────────────────────────────────────── + const required: Array = [ + "promptId", + "buyer", + "txHash", + "contractId", + "contentHash", + "purchasedAt", + "amountStroops", + ]; + for (const field of required) { + if (!receipt[field] && receipt[field] !== 0) { + return { + status: "tampered", + receipt, + message: `Receipt is missing required field: ${field}`, + explorerUrl, + verifiedAt, + }; + } + } + + // ── 2. Content-hash format check (must be 64-char hex) ─────────────────── + if (!/^[0-9a-f]{64}$/i.test(receipt.contentHash)) { + return { + status: "tampered", + receipt, + message: "contentHash is not a valid SHA-256 hex string.", + explorerUrl, + verifiedAt, + }; + } + + // ── 3. Fetch the Stellar transaction ───────────────────────────────────── + let tx: Record; + try { + tx = await fetchStellarTx(receipt.txHash, network); + } catch (err: unknown) { + const code = (err as { code?: string }).code ?? "error"; + return { + status: code as VerificationStatus, + receipt, + message: + code === "tx_not_found" + ? `Transaction ${receipt.txHash} was not found on ${network}.` + : `Could not reach Stellar Horizon: ${(err as Error).message}`, + explorerUrl, + verifiedAt, + }; + } + + // ── 4. Transaction must have succeeded ─────────────────────────────────── + if (tx["successful"] !== true) { + return { + status: "tx_failed", + receipt, + message: "The Stellar transaction did not succeed.", + explorerUrl, + verifiedAt, + details: { ledger: tx["ledger"] }, + }; + } + + // ── 5. Source account must match buyer ─────────────────────────────────── + const sourceAccount = String(tx["source_account"] ?? ""); + if (sourceAccount.trim() !== receipt.buyer.trim()) { + return { + status: "buyer_mismatch", + receipt, + message: `Buyer mismatch: receipt claims ${receipt.buyer} but tx source is ${sourceAccount}.`, + explorerUrl, + verifiedAt, + details: { onChainSource: sourceAccount, receiptBuyer: receipt.buyer }, + }; + } + + // ── 6. Receipt fingerprint integrity ───────────────────────────────────── + // Recompute and embed fingerprint — callers can store and re-check later + const fingerprint = computeReceiptFingerprint(receipt); + + return { + status: "valid", + receipt, + message: "Receipt is authentic. Transaction confirmed on-chain with matching buyer.", + explorerUrl, + verifiedAt, + details: { + fingerprint, + ledger: tx["ledger"], + createdAt: tx["created_at"], + feeCharged: tx["fee_charged"], + network, + }, + }; +} + +/** + * Verify a receipt that was exported as a plain JSON string. + * Handles both `JSON.parse` and base64-encoded exports. + */ +export async function verifyReceiptFromExport( + exported: string, + network: StellarNetwork = "mainnet", +): Promise { + let receipt: PurchaseReceipt; + try { + // Try plain JSON first, then base64-encoded JSON + const raw = + exported.trimStart().startsWith("{") + ? exported + : Buffer.from(exported, "base64").toString("utf-8"); + receipt = JSON.parse(raw) as PurchaseReceipt; + } catch { + return { + status: "tampered", + receipt: {} as PurchaseReceipt, + message: "Could not parse exported receipt. It may be corrupted or tampered.", + explorerUrl: "", + verifiedAt: new Date().toISOString(), + }; + } + return verifyReceipt(receipt, network); +} + +/** + * Detect whether a receipt has been tampered with by comparing + * a previously stored fingerprint against a freshly computed one. + * + * Use this for offline tamper detection when Horizon is unavailable. + */ +export function checkReceiptIntegrity( + receipt: PurchaseReceipt, + knownFingerprint: string, +): { intact: boolean; computedFingerprint: string } { + const computedFingerprint = computeReceiptFingerprint(receipt); + return { + intact: computedFingerprint === knownFingerprint, + computedFingerprint, + }; +}