feat: rename dashboard to Capacity Markets with Vector sale proofs
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import ResaleDashboard from "../../buyer/src/ui/pages/ResaleDashboard.js";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import App from "../../buyer/src/ui/App.js";
|
||||
import { AuthProvider } from "../../buyer/src/ui/state/AuthContext.js";
|
||||
|
||||
const DASHBOARD = {
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
@@ -47,28 +49,62 @@ const DASHBOARD = {
|
||||
payoutHoldReason: "new_seller",
|
||||
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
|
||||
},
|
||||
recentSales: [{
|
||||
id: "sale-1",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:00:00.000Z",
|
||||
settlementStatus: "confirmed",
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 3,
|
||||
revenueUsd: "0.000083",
|
||||
transactionHash: "0x" + "c".repeat(64),
|
||||
}],
|
||||
},
|
||||
vector: {
|
||||
status: "retired",
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
supplierWallet: "addr1test",
|
||||
advertRef: `${"a".repeat(64)}#0`,
|
||||
retirementTransaction: "b".repeat(64),
|
||||
retiredOn: "2026-08-27",
|
||||
historicalAp3xEarned: "1.600000",
|
||||
historicalSettledJobs: 8,
|
||||
historicalUpstreamSpendUsd: "0.000093",
|
||||
recentSales: [
|
||||
{
|
||||
id: "sale-1",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:00:00.000Z",
|
||||
settlementStatus: "confirmed",
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 3,
|
||||
revenueUsd: "0.000083",
|
||||
vectorProof: {
|
||||
status: "confirmed",
|
||||
saleHash: "1".repeat(64),
|
||||
batchRoot: "2".repeat(64),
|
||||
txHash: "d".repeat(64),
|
||||
leafIndex: 0,
|
||||
siblings: ["3".repeat(64)],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sale-2",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:01:00.000Z",
|
||||
settlementStatus: "confirmed",
|
||||
inputTokens: 20,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 6,
|
||||
revenueUsd: "0.000166",
|
||||
vectorProof: {
|
||||
status: "pending",
|
||||
saleHash: "4".repeat(64),
|
||||
batchRoot: null,
|
||||
txHash: null,
|
||||
leafIndex: null,
|
||||
siblings: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sale-3",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:02:00.000Z",
|
||||
settlementStatus: "confirmed",
|
||||
inputTokens: 30,
|
||||
outputTokens: 6,
|
||||
cacheReadTokens: 9,
|
||||
revenueUsd: "0.000249",
|
||||
vectorProof: {
|
||||
status: "failed",
|
||||
saleHash: "5".repeat(64),
|
||||
batchRoot: null,
|
||||
txHash: null,
|
||||
leafIndex: null,
|
||||
siblings: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -76,29 +112,41 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ResaleDashboard", () => {
|
||||
it("matches the marketplace card system and renders both settlement markets", async () => {
|
||||
describe("Capacity markets dashboard", () => {
|
||||
it("renders the authenticated route with a Vector proof state for every sale", async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json(DASHBOARD));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ResaleDashboard />);
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<AuthProvider initialStatus="authenticated">
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Resale Dashboard" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Capacity Markets" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Capacity" })).toHaveAttribute("href", "/dashboard");
|
||||
expect(screen.getByRole("heading", { name: "Surplus Intelligence" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Vector marketplace" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Vector proof" })).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.2399")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.600000 AP3X")).toBeInTheDocument();
|
||||
expect(screen.getByText("confirmed")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resale-dashboard")).toHaveClass("max-w-7xl");
|
||||
expect(document.querySelectorAll(".bg-white").length).toBeGreaterThan(3);
|
||||
expect(screen.getByRole("link", { name: /Open confirmed transaction/ })).toHaveAttribute(
|
||||
|
||||
const proofSummary = screen.getByRole("group", { name: "Vector proof summary" });
|
||||
expect(within(proofSummary).getByText("Confirmed 1")).toHaveClass("bg-green-100");
|
||||
expect(within(proofSummary).getByText("Pending 1")).toHaveClass("bg-amber-100");
|
||||
expect(within(proofSummary).getByText("Failed 1")).toHaveClass("bg-red-100");
|
||||
expect(screen.getByText("pending")).toHaveClass("bg-amber-100");
|
||||
expect(screen.getByText("failed")).toHaveClass("bg-red-100");
|
||||
|
||||
const proofLink = screen.getByRole("link", { name: /Confirmed Vector proof transaction/ });
|
||||
expect(proofLink).toHaveAttribute(
|
||||
"href",
|
||||
`https://vector.apexscan.org/en/transaction/${"b".repeat(64)}/summary/`,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /0xcccc/ })).toHaveAttribute(
|
||||
"href",
|
||||
`https://basescan.org/tx/0x${"c".repeat(64)}`,
|
||||
`https://vector.apexscan.org/en/transaction/${"d".repeat(64)}/summary/`,
|
||||
);
|
||||
expect(within(proofLink).getByText("dddddddddddd…ddddddddd")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/v1/resale-dashboard",
|
||||
{ cache: "no-store" },
|
||||
|
||||
@@ -36,6 +36,42 @@ function discoveryRow(model: string, providerModelId: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function saleRow(
|
||||
id: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
model: "alpha",
|
||||
offer_id: "offer-1",
|
||||
settlement_status: "confirmed",
|
||||
created_at: 1_787_831_146_968,
|
||||
seller_cost_usdc: "83",
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
cache_read_tokens: 4,
|
||||
effective_input_per_1m: 0.004,
|
||||
effective_output_per_1m: 0.008,
|
||||
tx_hash: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function earningsEnvelope(
|
||||
recentSales: Record<string, unknown>[],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
total_earned_usdc: "166",
|
||||
pending_usdc: "166",
|
||||
paid_usdc: "0",
|
||||
daily: [],
|
||||
by_model: [],
|
||||
share: { requests: recentSales.length, tokens: 0, top_model: "alpha" },
|
||||
recent_sales: recentSales,
|
||||
payout_hold: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SurplusClient", () => {
|
||||
it("authenticates and parses fragmented NDJSON discovery", async () => {
|
||||
const first = discoveryRow("alpha", "vendor/alpha");
|
||||
@@ -296,6 +332,90 @@ describe("SurplusClient", () => {
|
||||
expect(earnings.recentSales[0].createdAt).toBe(
|
||||
new Date(1787831146968).toISOString(),
|
||||
);
|
||||
expect(earnings.recentSales[0].createdAtMs).toBe(1787831146968);
|
||||
});
|
||||
|
||||
it("returns the deduplicated observed sale window in deterministic sale-id order", async () => {
|
||||
const duplicate = saleRow("sale-a", {
|
||||
buyer_address: "must-not-be-retained",
|
||||
});
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () =>
|
||||
Response.json(earningsEnvelope([
|
||||
saleRow("sale-b"),
|
||||
duplicate,
|
||||
{ ...duplicate },
|
||||
])),
|
||||
});
|
||||
|
||||
const sales = await client.listObservedSales();
|
||||
|
||||
expect(sales.map((sale) => sale.id)).toEqual(["sale-a", "sale-b"]);
|
||||
expect(sales[0]).not.toHaveProperty("buyerAddress");
|
||||
expect(sales[0]).not.toHaveProperty("buyer_address");
|
||||
});
|
||||
|
||||
it("fails closed on conflicting immutable fields in the observed sale window", async () => {
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () =>
|
||||
Response.json(earningsEnvelope([
|
||||
saleRow("sale-a"),
|
||||
saleRow("sale-a", { input_tokens: 11 }),
|
||||
])),
|
||||
});
|
||||
|
||||
await expect(client.listObservedSales()).rejects.toThrow(
|
||||
"conflicting immutable fields for sale sale-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires every immutable proof field in observed sales", async () => {
|
||||
const requiredFields = [
|
||||
"id",
|
||||
"model",
|
||||
"offer_id",
|
||||
"created_at",
|
||||
"seller_cost_usdc",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"effective_input_per_1m",
|
||||
"effective_output_per_1m",
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
const row = saleRow("sale-a");
|
||||
delete row[field];
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn: async () => Response.json(earningsEnvelope([row])),
|
||||
});
|
||||
|
||||
await expect(client.listObservedSales()).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails clearly instead of claiming unavailable historical completeness", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: "https://api.surplusintelligence.ai",
|
||||
sellerApiKey: SELLER_KEY,
|
||||
timeoutMs: 1_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
await expect(client.listAllSales()).rejects.toThrow(
|
||||
"does not provide historical sale pagination",
|
||||
);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends one absolute cost-multiplier PATCH with the cap", async () => {
|
||||
|
||||
340
tests/unit/surplus-vector-proof.test.ts
Normal file
340
tests/unit/surplus-vector-proof.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSurplusMerkleBatch,
|
||||
canonicalizeSurplusSale,
|
||||
canonicalSurplusSaleJson,
|
||||
FileSurplusVectorProofLedger,
|
||||
hashSurplusSale,
|
||||
SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
verifySurplusMerkleProof,
|
||||
type SurplusMerkleProof,
|
||||
type SurplusMerkleBatch,
|
||||
type SurplusSaleProofInput,
|
||||
} from "../../supplier/src/surplus/vectorProof.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "surplus-vector-proof-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((path) =>
|
||||
rm(path, { recursive: true, force: true })
|
||||
));
|
||||
});
|
||||
|
||||
function sale(
|
||||
id: string,
|
||||
overrides: Partial<SurplusSaleProofInput> = {},
|
||||
): SurplusSaleProofInput {
|
||||
return {
|
||||
id,
|
||||
offerId: "offer-1",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:00:00.123Z",
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 3,
|
||||
sellerCostMicroUsd: 83,
|
||||
effectiveInputUsdPer1m: 0.003972,
|
||||
effectiveOutputUsdPer1m: 0.007944,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function proofFor(
|
||||
batch: SurplusMerkleBatch,
|
||||
saleId: string,
|
||||
): SurplusMerkleProof {
|
||||
const leaf = batch.leaves.find((candidate) => candidate.sale.saleId === saleId);
|
||||
if (leaf === undefined) throw new Error(`Missing test leaf ${saleId}`);
|
||||
return {
|
||||
saleHash: leaf.saleHash,
|
||||
batchRoot: leaf.batchRoot,
|
||||
leafIndex: leaf.leafIndex,
|
||||
leafCount: leaf.leafCount,
|
||||
siblings: [...leaf.siblings],
|
||||
};
|
||||
}
|
||||
|
||||
function changedHash(hash: string): string {
|
||||
return `${hash[0] === "0" ? "1" : "0"}${hash.slice(1)}`;
|
||||
}
|
||||
|
||||
describe("Surplus Vector sale projection", () => {
|
||||
it("encodes the versioned leaf in the required field order and hashes its UTF-8 JSON", () => {
|
||||
const input = sale("sale-1");
|
||||
const canonical = canonicalizeSurplusSale(input);
|
||||
const json = canonicalSurplusSaleJson(canonical);
|
||||
|
||||
expect(canonical).toEqual({
|
||||
protocol: SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
saleId: "sale-1",
|
||||
offerId: "offer-1",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAtEpochMs: Date.parse("2026-08-28T11:00:00.123Z"),
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 3,
|
||||
sellerCostMicroUsd: 83,
|
||||
effectiveInputUsdPer1m: "0.003972",
|
||||
effectiveOutputUsdPer1m: "0.007944",
|
||||
});
|
||||
expect(json).toBe(
|
||||
'{"protocol":"surplus-sale-proof-v1","saleId":"sale-1","offerId":"offer-1","model":"deepseek-v4-flash","createdAtEpochMs":1787914800123,"inputTokens":10,"outputTokens":2,"cacheReadTokens":3,"sellerCostMicroUsd":83,"effectiveInputUsdPer1m":"0.003972","effectiveOutputUsdPer1m":"0.007944"}',
|
||||
);
|
||||
expect(hashSurplusSale(input)).toBe(
|
||||
createHash("sha256").update(json, "utf8").digest("hex"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects sales that cannot produce an immutable canonical leaf", () => {
|
||||
expect(() => canonicalizeSurplusSale(sale("sale-1", { offerId: null })))
|
||||
.toThrow("offer id");
|
||||
expect(() => canonicalizeSurplusSale(sale("sale-1", { createdAt: null })))
|
||||
.toThrow("no creation time");
|
||||
expect(() => canonicalizeSurplusSale(sale("sale-1", { inputTokens: -1 })))
|
||||
.toThrow("non-negative safe integer");
|
||||
expect(() => canonicalizeSurplusSale(sale("bad\nid")))
|
||||
.toThrow("printable ASCII");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Surplus sale Merkle batches", () => {
|
||||
it("sorts by sale id and duplicates the final node at odd tree levels", () => {
|
||||
const batch = buildSurplusMerkleBatch([
|
||||
canonicalizeSurplusSale(sale("sale-c")),
|
||||
canonicalizeSurplusSale(sale("sale-a")),
|
||||
canonicalizeSurplusSale(sale("sale-b")),
|
||||
]);
|
||||
|
||||
expect(batch.count).toBe(3);
|
||||
expect(batch.firstSaleId).toBe("sale-a");
|
||||
expect(batch.lastSaleId).toBe("sale-c");
|
||||
expect(batch.leaves.map((leaf) => leaf.sale.saleId)).toEqual([
|
||||
"sale-a",
|
||||
"sale-b",
|
||||
"sale-c",
|
||||
]);
|
||||
const last = batch.leaves[2];
|
||||
expect(last.siblings[0]).toBe(last.saleHash);
|
||||
for (const leaf of batch.leaves) {
|
||||
expect(verifySurplusMerkleProof(leaf.sale, leaf)).toBe(true);
|
||||
}
|
||||
|
||||
const reordered = buildSurplusMerkleBatch([
|
||||
canonicalizeSurplusSale(sale("sale-b")),
|
||||
canonicalizeSurplusSale(sale("sale-c")),
|
||||
canonicalizeSurplusSale(sale("sale-a")),
|
||||
]);
|
||||
expect(reordered.root).toBe(batch.root);
|
||||
expect(reordered.leaves).toEqual(batch.leaves);
|
||||
});
|
||||
|
||||
it("rejects every material proof mutation", () => {
|
||||
const originalSale = sale("sale-a");
|
||||
const batch = buildSurplusMerkleBatch([
|
||||
canonicalizeSurplusSale(originalSale),
|
||||
canonicalizeSurplusSale(sale("sale-b")),
|
||||
canonicalizeSurplusSale(sale("sale-c")),
|
||||
]);
|
||||
const proof = proofFor(batch, "sale-a");
|
||||
expect(verifySurplusMerkleProof(originalSale, proof)).toBe(true);
|
||||
|
||||
const changedFields: SurplusSaleProofInput[] = [
|
||||
sale("sale-x"),
|
||||
sale("sale-a", { offerId: "offer-2" }),
|
||||
sale("sale-a", { model: "another-model" }),
|
||||
sale("sale-a", { createdAt: "2026-08-28T11:00:00.124Z" }),
|
||||
sale("sale-a", { inputTokens: 11 }),
|
||||
sale("sale-a", { outputTokens: 3 }),
|
||||
sale("sale-a", { cacheReadTokens: 4 }),
|
||||
sale("sale-a", { sellerCostMicroUsd: 84 }),
|
||||
sale("sale-a", { effectiveInputUsdPer1m: 0.003973 }),
|
||||
sale("sale-a", { effectiveOutputUsdPer1m: 0.007945 }),
|
||||
];
|
||||
for (const changed of changedFields) {
|
||||
expect(verifySurplusMerkleProof(changed, proof)).toBe(false);
|
||||
}
|
||||
|
||||
expect(verifySurplusMerkleProof(originalSale, {
|
||||
...proof,
|
||||
siblings: [changedHash(proof.siblings[0]), ...proof.siblings.slice(1)],
|
||||
})).toBe(false);
|
||||
expect(verifySurplusMerkleProof(originalSale, {
|
||||
...proof,
|
||||
leafIndex: 1,
|
||||
})).toBe(false);
|
||||
expect(verifySurplusMerkleProof(originalSale, {
|
||||
...proof,
|
||||
batchRoot: changedHash(proof.batchRoot),
|
||||
})).toBe(false);
|
||||
expect(verifySurplusMerkleProof(originalSale, {
|
||||
...proof,
|
||||
saleHash: changedHash(proof.saleHash),
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileSurplusVectorProofLedger", () => {
|
||||
it("persists intent before confirmation and never selects confirmed sales again", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "proofs", "ledger.json");
|
||||
let now = Date.parse("2026-08-28T12:00:00.000Z");
|
||||
const ledger = await FileSurplusVectorProofLedger.open(path, { now: () => now });
|
||||
|
||||
expect(await ledger.ingestSales([
|
||||
sale("sale-c"),
|
||||
sale("sale-a"),
|
||||
sale("sale-b"),
|
||||
sale("sale-a"),
|
||||
])).toEqual({ inserted: 3, duplicates: 1 });
|
||||
expect(ledger.selectUnprovedSales(2).map((item) => item.saleId)).toEqual([
|
||||
"sale-a",
|
||||
"sale-b",
|
||||
]);
|
||||
|
||||
const intent = await ledger.persistPendingBatch(["sale-b", "sale-a"]);
|
||||
expect(intent.leaves.map((leaf) => leaf.saleId)).toEqual(["sale-a", "sale-b"]);
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||
|
||||
const restarted = await FileSurplusVectorProofLedger.open(path, { now: () => now });
|
||||
expect(restarted.pendingBatchIntent()).toEqual(intent);
|
||||
expect(() => restarted.selectUnprovedSales()).toThrow(
|
||||
"pending Surplus proof batch must be resolved first",
|
||||
);
|
||||
await expect(restarted.createPendingBatch()).rejects.toThrow(
|
||||
"pending Surplus proof batch must be resolved first",
|
||||
);
|
||||
|
||||
now += 1_000;
|
||||
const txHash = "AB".repeat(32);
|
||||
const confirmed = await restarted.confirmPendingBatch(intent.batchRoot, txHash);
|
||||
expect(confirmed.txHash).toBe(txHash.toLowerCase());
|
||||
for (const id of ["sale-a", "sale-b"]) {
|
||||
const proof = restarted.dashboardProof(id);
|
||||
expect(proof).toMatchObject({
|
||||
status: "confirmed",
|
||||
batchRoot: intent.batchRoot,
|
||||
txHash: txHash.toLowerCase(),
|
||||
});
|
||||
expect(verifySurplusMerkleProof(sale(id), {
|
||||
saleHash: proof!.saleHash,
|
||||
batchRoot: proof!.batchRoot!,
|
||||
leafIndex: proof!.leafIndex!,
|
||||
leafCount: intent.count,
|
||||
siblings: proof!.siblings,
|
||||
})).toBe(true);
|
||||
}
|
||||
|
||||
const confirmedRestart = await FileSurplusVectorProofLedger.open(path);
|
||||
expect(confirmedRestart.selectUnprovedSales().map((item) => item.saleId))
|
||||
.toEqual(["sale-c"]);
|
||||
expect(confirmedRestart.confirmedBatchHistory()).toEqual([confirmed]);
|
||||
});
|
||||
|
||||
it("deduplicates exact sales and rejects an immutable id conflict", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "ledger.json");
|
||||
const ledger = await FileSurplusVectorProofLedger.open(path);
|
||||
|
||||
expect(await ledger.ingestSales([sale("sale-a")]))
|
||||
.toEqual({ inserted: 1, duplicates: 0 });
|
||||
expect(await ledger.ingestSales([sale("sale-a")]))
|
||||
.toEqual({ inserted: 0, duplicates: 1 });
|
||||
await expect(ledger.ingestSales([
|
||||
sale("sale-a", { sellerCostMicroUsd: 84 }),
|
||||
])).rejects.toThrow("Conflicting Surplus sale id");
|
||||
expect(ledger.selectUnprovedSales()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("records a safe failed attempt and makes its sales eligible for a later batch", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "ledger.json");
|
||||
let now = Date.parse("2026-08-28T12:00:00.000Z");
|
||||
const ledger = await FileSurplusVectorProofLedger.open(path, { now: () => now });
|
||||
await ledger.ingestSales([sale("sale-a"), sale("sale-b")]);
|
||||
const intent = await ledger.createPendingBatch();
|
||||
if (intent === null) throw new Error("Expected a pending test batch");
|
||||
|
||||
now += 1_000;
|
||||
const failure = await ledger.failPendingBatch(intent.batchRoot, "submit");
|
||||
expect(failure).toEqual({
|
||||
batchRoot: intent.batchRoot,
|
||||
count: 2,
|
||||
firstSaleId: "sale-a",
|
||||
lastSaleId: "sale-b",
|
||||
stage: "submit",
|
||||
failedAt: "2026-08-28T12:00:01.000Z",
|
||||
});
|
||||
expect(ledger.dashboardProof("sale-a")).toMatchObject({
|
||||
status: "failed",
|
||||
batchRoot: intent.batchRoot,
|
||||
txHash: null,
|
||||
leafIndex: 0,
|
||||
});
|
||||
|
||||
const restarted = await FileSurplusVectorProofLedger.open(path);
|
||||
expect(restarted.failedAttemptHistory()).toEqual([failure]);
|
||||
expect(restarted.selectUnprovedSales().map((item) => item.saleId)).toEqual([
|
||||
"sale-a",
|
||||
"sale-b",
|
||||
]);
|
||||
const retry = await restarted.createPendingBatch();
|
||||
expect(retry?.batchRoot).toBe(intent.batchRoot);
|
||||
expect(restarted.dashboardProof("sale-a")?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("stores only canonical sale fields and proof data", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const path = join(directory, "ledger.json");
|
||||
const ledger = await FileSurplusVectorProofLedger.open(path);
|
||||
const input = {
|
||||
...sale("sale-a"),
|
||||
apiKey: "must-not-be-written",
|
||||
buyerAddress: "must-not-be-written",
|
||||
};
|
||||
|
||||
await ledger.ingestSales([input]);
|
||||
|
||||
const text = await readFile(path, "utf8");
|
||||
expect(text).not.toContain("must-not-be-written");
|
||||
expect(text).not.toContain("apiKey");
|
||||
expect(text).not.toContain("buyerAddress");
|
||||
});
|
||||
|
||||
it("rejects ledger symlinks on load and before an atomic write", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const target = join(directory, "target.json");
|
||||
const loadPath = join(directory, "load-ledger.json");
|
||||
await writeFile(
|
||||
target,
|
||||
'{"version":1,"sales":[],"pendingBatch":null,"confirmedBatches":[],"failedAttempts":[]}\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await symlink(target, loadPath);
|
||||
await expect(FileSurplusVectorProofLedger.open(loadPath)).rejects.toThrow();
|
||||
|
||||
const writePath = join(directory, "write-ledger.json");
|
||||
const ledger = await FileSurplusVectorProofLedger.open(writePath);
|
||||
await symlink(target, writePath);
|
||||
await expect(ledger.ingestSales([sale("sale-a")])).rejects.toThrow(
|
||||
"wrong file type",
|
||||
);
|
||||
expect(await readFile(target, "utf8")).not.toContain("sale-a");
|
||||
});
|
||||
});
|
||||
227
tests/unit/tx-anchor-metadata.test.ts
Normal file
227
tests/unit/tx-anchor-metadata.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import type { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
|
||||
import type { WalletKey } from "../../packages/shared/src/tx/types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createLucidContext: vi.fn(),
|
||||
providerConstructor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../packages/shared/src/chain/OgmiosLucidProvider.js",
|
||||
() => ({
|
||||
OgmiosLucidProvider: class {
|
||||
constructor(options: unknown) {
|
||||
mocks.providerConstructor(options);
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../../packages/shared/src/tx/internal/lucidContext.js",
|
||||
() => ({ createLucidContext: mocks.createLucidContext }),
|
||||
);
|
||||
|
||||
import {
|
||||
buildAnchorMetadataTx,
|
||||
SURPLUS_SALE_PROOF_METADATA_LABEL,
|
||||
SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
type SurplusSaleProofAnchorMetadata,
|
||||
} from "../../packages/shared/src/tx/wallet/anchorMetadata.js";
|
||||
|
||||
const walletKey: WalletKey = {
|
||||
address: "addr1wallet",
|
||||
pubKeyHash: "1".repeat(56),
|
||||
pubKeyHex: "2".repeat(64),
|
||||
privateKeyHex: "3".repeat(64),
|
||||
};
|
||||
|
||||
function validMetadata(): SurplusSaleProofAnchorMetadata {
|
||||
return {
|
||||
p: SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
root: "a".repeat(64),
|
||||
count: 1_000,
|
||||
first: "sale-000001",
|
||||
last: "sale-001000",
|
||||
};
|
||||
}
|
||||
|
||||
interface LucidHarness {
|
||||
chain: LiveOgmiosProvider;
|
||||
submitTx: Mock;
|
||||
getUtxos: Mock;
|
||||
newTx: Mock;
|
||||
attachMetadata: Mock;
|
||||
addSignerKey: Mock;
|
||||
setMinFee: Mock;
|
||||
completeTx: Mock;
|
||||
withWallet: Mock;
|
||||
completeSignature: Mock;
|
||||
payToAddress: Mock;
|
||||
}
|
||||
|
||||
function lucidHarness(): LucidHarness {
|
||||
const submitTx = vi.fn().mockResolvedValue("submitted-hash");
|
||||
const chain = {
|
||||
url: "http://ogmios.test",
|
||||
fetchImpl: undefined,
|
||||
submitTx,
|
||||
} as unknown as LiveOgmiosProvider;
|
||||
const realWalletUtxos = [
|
||||
{ txHash: "f".repeat(64), outputIndex: 0, assets: { lovelace: 25_000_000n } },
|
||||
];
|
||||
const getUtxos = vi.fn().mockResolvedValue(realWalletUtxos);
|
||||
const completeSignature = vi.fn().mockResolvedValue({
|
||||
toCBOR: () => "signed-anchor-cbor",
|
||||
toHash: () => "b".repeat(64),
|
||||
});
|
||||
const withWallet = vi.fn(() => ({ complete: completeSignature }));
|
||||
const completeTx = vi.fn().mockResolvedValue({ sign: { withWallet } });
|
||||
const payToAddress = vi.fn();
|
||||
const txBuilder: Record<string, unknown> = {
|
||||
pay: { ToAddress: payToAddress },
|
||||
};
|
||||
const attachMetadata = vi.fn(() => txBuilder);
|
||||
const addSignerKey = vi.fn(() => txBuilder);
|
||||
const setMinFee = vi.fn(() => txBuilder);
|
||||
Object.assign(txBuilder, {
|
||||
attachMetadata,
|
||||
addSignerKey,
|
||||
setMinFee,
|
||||
complete: completeTx,
|
||||
});
|
||||
const newTx = vi.fn(() => txBuilder);
|
||||
const lucid = {
|
||||
wallet: vi.fn(() => ({ getUtxos })),
|
||||
newTx,
|
||||
};
|
||||
mocks.createLucidContext.mockResolvedValue({ lucid });
|
||||
|
||||
return {
|
||||
chain,
|
||||
submitTx,
|
||||
getUtxos,
|
||||
newTx,
|
||||
attachMetadata,
|
||||
addSignerKey,
|
||||
setMinFee,
|
||||
completeTx,
|
||||
withWallet,
|
||||
completeSignature,
|
||||
payToAddress,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("buildAnchorMetadataTx", () => {
|
||||
it("signs label-674 metadata with real wallet inputs and submits once", async () => {
|
||||
const harness = lucidHarness();
|
||||
const metadata = validMetadata();
|
||||
|
||||
const result = await buildAnchorMetadataTx({
|
||||
chain: harness.chain,
|
||||
walletKey,
|
||||
metadata,
|
||||
});
|
||||
|
||||
expect(mocks.providerConstructor).toHaveBeenCalledWith({
|
||||
ogmiosUrl: "http://ogmios.test",
|
||||
fetch: undefined,
|
||||
});
|
||||
expect(mocks.createLucidContext).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
walletKey,
|
||||
{ networkId: 1, systemStartUnix: 0, slotLengthMs: 1000 },
|
||||
{ usePresetProtocolParameters: true },
|
||||
);
|
||||
expect(harness.getUtxos).toHaveBeenCalledOnce();
|
||||
expect(harness.newTx).toHaveBeenCalledOnce();
|
||||
expect(harness.attachMetadata).toHaveBeenCalledWith(
|
||||
SURPLUS_SALE_PROOF_METADATA_LABEL,
|
||||
metadata,
|
||||
);
|
||||
expect(harness.addSignerKey).toHaveBeenCalledWith(walletKey.pubKeyHash);
|
||||
expect(harness.setMinFee).toHaveBeenCalledWith(500_000n);
|
||||
expect(harness.completeTx).toHaveBeenCalledWith({
|
||||
presetWalletInputs: expect.arrayContaining([
|
||||
expect.objectContaining({ assets: { lovelace: 25_000_000n } }),
|
||||
]),
|
||||
localUPLCEval: false,
|
||||
});
|
||||
expect(harness.withWallet).toHaveBeenCalledOnce();
|
||||
expect(harness.completeSignature).toHaveBeenCalledOnce();
|
||||
expect(harness.payToAddress).not.toHaveBeenCalled();
|
||||
expect(harness.submitTx).toHaveBeenCalledOnce();
|
||||
expect(harness.submitTx).toHaveBeenCalledWith("signed-anchor-cbor");
|
||||
expect(result).toEqual({
|
||||
txCborHex: "signed-anchor-cbor",
|
||||
expectedTxHash: "b".repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wrong protocol", { ...validMetadata(), p: "surplus-sale-proof-v2" }],
|
||||
["short root", { ...validMetadata(), root: "a".repeat(63) }],
|
||||
["uppercase root", { ...validMetadata(), root: "A".repeat(64) }],
|
||||
["non-hex root", { ...validMetadata(), root: "z".repeat(64) }],
|
||||
["zero count", { ...validMetadata(), count: 0 }],
|
||||
["fractional count", { ...validMetadata(), count: 1.5 }],
|
||||
["unsafe count", { ...validMetadata(), count: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
["empty first ID", { ...validMetadata(), first: "" }],
|
||||
["control character in last ID", { ...validMetadata(), last: "sale\n2" }],
|
||||
["non-ASCII first ID", { ...validMetadata(), first: "salé-1" }],
|
||||
["extra field", { ...validMetadata(), extra: true }],
|
||||
])("rejects %s before initializing Lucid", async (_case, metadata) => {
|
||||
const harness = lucidHarness();
|
||||
|
||||
await expect(
|
||||
buildAnchorMetadataTx({
|
||||
chain: harness.chain,
|
||||
walletKey,
|
||||
metadata: metadata as SurplusSaleProofAnchorMetadata,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "anchor_metadata_invalid" });
|
||||
|
||||
expect(mocks.providerConstructor).not.toHaveBeenCalled();
|
||||
expect(mocks.createLucidContext).not.toHaveBeenCalled();
|
||||
expect(harness.submitTx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects metadata text over 64 UTF-8 bytes before initializing Lucid", async () => {
|
||||
const harness = lucidHarness();
|
||||
|
||||
await expect(
|
||||
buildAnchorMetadataTx({
|
||||
chain: harness.chain,
|
||||
walletKey,
|
||||
metadata: { ...validMetadata(), last: "x".repeat(65) },
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "anchor_metadata_oversized" });
|
||||
|
||||
expect(mocks.providerConstructor).not.toHaveBeenCalled();
|
||||
expect(mocks.createLucidContext).not.toHaveBeenCalled();
|
||||
expect(harness.submitTx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit when transaction construction fails", async () => {
|
||||
const harness = lucidHarness();
|
||||
harness.completeTx.mockRejectedValueOnce(new Error("coin selection failed"));
|
||||
|
||||
await expect(
|
||||
buildAnchorMetadataTx({
|
||||
chain: harness.chain,
|
||||
walletKey,
|
||||
metadata: validMetadata(),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
reason: "anchor_metadata_build_failed",
|
||||
message: "coin selection failed",
|
||||
});
|
||||
|
||||
expect(harness.submitTx).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user