feat: rename dashboard to Capacity Markets with Vector sale proofs
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
* Routes (all gated by <RequireAuth>):
|
* Routes (all gated by <RequireAuth>):
|
||||||
* / → <Dashboard />
|
* / → <Dashboard />
|
||||||
* /tasks → <TaskHistory />
|
* /tasks → <TaskHistory />
|
||||||
* /dashboard → <ResaleDashboard /> unified Surplus + Vector metrics
|
* /dashboard → <ResaleDashboard /> Capacity markets and sale proofs
|
||||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||||
* /wallet → <Wallet />
|
* /wallet → <Wallet />
|
||||||
* /api-keys → <ApiKeys /> self-serve gateway API key generation
|
* /api-keys → <ApiKeys /> self-serve gateway API key generation
|
||||||
@@ -39,7 +39,7 @@ export default function App() {
|
|||||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||||
<nav className="bg-white border-b border-gray-200 px-4 py-3 sm:px-6 flex flex-wrap items-center gap-x-6 gap-y-2">
|
<nav className="bg-white border-b border-gray-200 px-4 py-3 sm:px-6 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||||
<Link to="/dashboard" className="font-medium hover:underline">Resale</Link>
|
<Link to="/dashboard" className="font-medium hover:underline">Capacity</Link>
|
||||||
{/* Book Summarizer tab hidden for now — the /book route and all PDF
|
{/* Book Summarizer tab hidden for now — the /book route and all PDF
|
||||||
summarize code remain intact; restore by re-adding this link:
|
summarize code remain intact; restore by re-adding this link:
|
||||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface VectorProof {
|
||||||
|
status: "pending" | "confirmed" | "failed";
|
||||||
|
saleHash: string | null;
|
||||||
|
batchRoot: string | null;
|
||||||
|
txHash: string | null;
|
||||||
|
leafIndex: number | null;
|
||||||
|
siblings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENDING_PROOF: VectorProof = {
|
||||||
|
status: "pending",
|
||||||
|
saleHash: null,
|
||||||
|
batchRoot: null,
|
||||||
|
txHash: null,
|
||||||
|
leafIndex: null,
|
||||||
|
siblings: [],
|
||||||
|
};
|
||||||
|
|
||||||
interface DashboardSnapshot {
|
interface DashboardSnapshot {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
controller: {
|
controller: {
|
||||||
@@ -60,20 +78,10 @@ interface DashboardSnapshot {
|
|||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
cacheReadTokens: number;
|
cacheReadTokens: number;
|
||||||
revenueUsd: string;
|
revenueUsd: string;
|
||||||
transactionHash: string | null;
|
vectorProof: VectorProof;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
vector: {
|
|
||||||
status: "retired";
|
|
||||||
model: string;
|
|
||||||
supplierWallet: string;
|
|
||||||
advertRef: string;
|
|
||||||
retirementTransaction: string;
|
|
||||||
retiredOn: string;
|
|
||||||
historicalAp3xEarned: string;
|
|
||||||
historicalSettledJobs: number;
|
|
||||||
historicalUpstreamSpendUsd: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function usd(value: string | number | null): string {
|
function usd(value: string | number | null): string {
|
||||||
@@ -110,6 +118,34 @@ function StatusPill({ label, active }: { label: string; active: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VectorProofState({ proof }: { proof: VectorProof }) {
|
||||||
|
const pillClass = proof.status === "confirmed"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: proof.status === "pending"
|
||||||
|
? "bg-amber-100 text-amber-700"
|
||||||
|
: "bg-red-100 text-red-700";
|
||||||
|
const pill = (
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${pillClass}`}>
|
||||||
|
{proof.status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (proof.status !== "confirmed" || proof.txHash === null) return pill;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center gap-2 rounded text-blue-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
|
||||||
|
href={`https://vector.apexscan.org/en/transaction/${proof.txHash}/summary/`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`Confirmed Vector proof transaction ${proof.txHash}`}
|
||||||
|
>
|
||||||
|
{pill}
|
||||||
|
<span className="font-mono text-xs">{compact(proof.txHash)}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) {
|
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||||
@@ -170,7 +206,7 @@ export default function ResaleDashboard() {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
if (loading && data === null) {
|
if (loading && data === null) {
|
||||||
return <p className="text-sm text-gray-500">Loading resale dashboard…</p>;
|
return <p className="text-sm text-gray-500">Loading capacity markets…</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data === null) {
|
if (data === null) {
|
||||||
@@ -183,18 +219,20 @@ export default function ResaleDashboard() {
|
|||||||
|
|
||||||
const offer = data.surplus.offer;
|
const offer = data.surplus.offer;
|
||||||
const earnings = data.surplus.earnings;
|
const earnings = data.surplus.earnings;
|
||||||
const vector = data.vector;
|
|
||||||
const offerLive = offer.status === "active" && offer.available && offer.healthy;
|
const offerLive = offer.status === "active" && offer.available && offer.healthy;
|
||||||
const advertHash = vector.advertRef.split("#")[0] ?? "";
|
const proofCounts = { confirmed: 0, pending: 0, failed: 0 };
|
||||||
|
for (const sale of data.surplus.recentSales) {
|
||||||
|
proofCounts[(sale.vectorProof ?? PENDING_PROOF).status] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6" data-testid="resale-dashboard">
|
<div className="mx-auto max-w-7xl space-y-6" data-testid="resale-dashboard">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-2xl font-semibold">Resale Dashboard</h1>
|
<h1 className="text-2xl font-semibold">Capacity Markets</h1>
|
||||||
<p className="max-w-3xl text-sm text-gray-600">
|
<p className="max-w-3xl text-sm text-gray-600">
|
||||||
One OpenRouter capacity pool managed by the seller agent. Surplus is the live Base USDC
|
Surplus turns available OpenRouter capacity into Base USDC sales. Vector anchors
|
||||||
market. Vector preserves the completed AP3X escrow proof.
|
Merkle-batched proofs so every sale has a verifiable transaction record.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -223,8 +261,10 @@ export default function ResaleDashboard() {
|
|||||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Capacity route</h2>
|
<h2 className="font-semibold">Capacity and proof route</h2>
|
||||||
<p className="text-sm text-gray-500">The same upstream capacity moved from Vector proof to Surplus production.</p>
|
<p className="text-sm text-gray-500">
|
||||||
|
Capacity sells through Surplus, and each sale joins a shared Vector anchor.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400">Updated {timestamp(data.generatedAt)}</span>
|
<span className="text-xs text-gray-400">Updated {timestamp(data.generatedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,86 +284,47 @@ export default function ResaleDashboard() {
|
|||||||
/>
|
/>
|
||||||
<span className="self-center text-lg text-gray-400">→</span>
|
<span className="self-center text-lg text-gray-400">→</span>
|
||||||
<FlowNode
|
<FlowNode
|
||||||
label="Live settlement"
|
label="Live market"
|
||||||
title="Surplus / Base USDC"
|
title="Surplus / Base USDC"
|
||||||
detail={`${offerLive ? "Available" : "Unavailable"} · ${offer.trusted ? "trusted" : "untrusted"}`}
|
detail={`${offerLive ? "Available" : "Unavailable"} · ${offer.trusted ? "trusted" : "untrusted"}`}
|
||||||
mono={offer.id}
|
mono={offer.id}
|
||||||
/>
|
/>
|
||||||
|
<span className="self-center text-lg text-gray-400">→</span>
|
||||||
|
<FlowNode
|
||||||
|
label="Proof layer"
|
||||||
|
title="Vector anchors"
|
||||||
|
detail="Commits each sale through a shared Merkle-batch transaction"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
<article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div>
|
||||||
<div>
|
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Current market</p>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Current market</p>
|
<h2 className="mt-1 text-lg font-semibold">Surplus Intelligence</h2>
|
||||||
<h2 className="mt-1 text-lg font-semibold">Surplus Intelligence</h2>
|
<p className="mt-1 text-sm text-gray-600">Discounted inference with USDC settlement on Base.</p>
|
||||||
<p className="mt-1 text-sm text-gray-600">Discounted inference with USDC settlement on Base.</p>
|
|
||||||
</div>
|
|
||||||
<StatusPill label={offer.status} active={offerLive} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-2 2xl:grid-cols-4">
|
<StatusPill label={offer.status} active={offerLive} />
|
||||||
<Metric label="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
|
</div>
|
||||||
<Metric label="Requests sold" value={count(earnings.requests)} detail={`${count(earnings.tokens)} tokens`} />
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Metric label="Market rank" value={offer.rank === null ? "—" : `#${offer.rank}`} detail={`${offer.trades24h} trades / 24h`} />
|
<Metric label="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
|
||||||
<Metric label="Daily seller cap" value={usd(offer.dailyCapUsd)} detail={`${usd(offer.capRemainingUsd)} remaining`} />
|
<Metric label="Requests sold" value={count(earnings.requests)} detail={`${count(earnings.tokens)} tokens`} />
|
||||||
|
<Metric label="Market rank" value={offer.rank === null ? "—" : `#${offer.rank}`} detail={`${offer.trades24h} trades / 24h`} />
|
||||||
|
<Metric label="Daily seller cap" value={usd(offer.dailyCapUsd)} detail={`${usd(offer.capRemainingUsd)} remaining`} />
|
||||||
|
</div>
|
||||||
|
<dl className="mt-4 grid gap-x-6 gap-y-2 border-t border-gray-100 pt-4 text-sm sm:grid-cols-2">
|
||||||
|
<div><dt className="text-gray-500">Marketplace model</dt><dd className="font-mono text-xs text-gray-900">{offer.model ?? "—"}</dd></div>
|
||||||
|
<div><dt className="text-gray-500">Provider model</dt><dd className="font-mono text-xs text-gray-900">{offer.providerModel ?? "—"}</dd></div>
|
||||||
|
<div><dt className="text-gray-500">Input / 1M</dt><dd>{usd(offer.inputUsdPer1m)}</dd></div>
|
||||||
|
<div><dt className="text-gray-500">Output / 1M</dt><dd>{usd(offer.outputUsdPer1m)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{earnings.payoutHoldReason && (
|
||||||
|
<div className="mt-4 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||||
|
Payout hold: {earnings.payoutHoldReason}
|
||||||
|
{earnings.payoutHoldReleasesAt && ` · releases ${timestamp(earnings.payoutHoldReleasesAt)}`}
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-4 grid gap-x-6 gap-y-2 border-t border-gray-100 pt-4 text-sm sm:grid-cols-2">
|
)}
|
||||||
<div><dt className="text-gray-500">Marketplace model</dt><dd className="font-mono text-xs text-gray-900">{offer.model ?? "—"}</dd></div>
|
|
||||||
<div><dt className="text-gray-500">Provider model</dt><dd className="font-mono text-xs text-gray-900">{offer.providerModel ?? "—"}</dd></div>
|
|
||||||
<div><dt className="text-gray-500">Input / 1M</dt><dd>{usd(offer.inputUsdPer1m)}</dd></div>
|
|
||||||
<div><dt className="text-gray-500">Output / 1M</dt><dd>{usd(offer.outputUsdPer1m)}</dd></div>
|
|
||||||
</dl>
|
|
||||||
{earnings.payoutHoldReason && (
|
|
||||||
<div className="mt-4 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
|
||||||
Payout hold: {earnings.payoutHoldReason}
|
|
||||||
{earnings.payoutHoldReleasesAt && ` · releases ${timestamp(earnings.payoutHoldReleasesAt)}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Migration record</p>
|
|
||||||
<h2 className="mt-1 text-lg font-semibold">Vector marketplace</h2>
|
|
||||||
<p className="mt-1 text-sm text-gray-600">AP3X escrow route retired after the cross-market proof.</p>
|
|
||||||
</div>
|
|
||||||
<StatusPill label="retired" active={false} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-2 2xl:grid-cols-4">
|
|
||||||
<Metric label="AP3X earned" value={`${vector.historicalAp3xEarned} AP3X`} detail="historical" />
|
|
||||||
<Metric label="Settled jobs" value={count(vector.historicalSettledJobs)} detail="Vector escrows" />
|
|
||||||
<Metric label="Upstream spend" value={usd(vector.historicalUpstreamSpendUsd)} detail="proof route" />
|
|
||||||
<Metric label="Retired on" value={vector.retiredOn} detail="advert closed" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-3 border-t border-gray-100 pt-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500">Retired advert</p>
|
|
||||||
<p className="break-all font-mono text-xs text-gray-900">{vector.advertRef}</p>
|
|
||||||
<a
|
|
||||||
className="text-xs text-blue-600 hover:underline"
|
|
||||||
href={`https://vector.apexscan.org/en/transaction/${advertHash}/summary/`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Open advert transaction ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500">Retirement proof</p>
|
|
||||||
<p className="break-all font-mono text-xs text-gray-900">{vector.retirementTransaction}</p>
|
|
||||||
<a
|
|
||||||
className="text-xs text-blue-600 hover:underline"
|
|
||||||
href={`https://vector.apexscan.org/en/transaction/${vector.retirementTransaction}/summary/`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Open confirmed transaction ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
@@ -350,8 +351,24 @@ export default function ResaleDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">{count(earnings.requests)} lifetime requests · {count(earnings.tokens)} tokens</p>
|
<p className="text-sm text-gray-500">{count(earnings.requests)} lifetime requests · {count(earnings.tokens)} tokens</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-2"
|
||||||
|
role="group"
|
||||||
|
aria-label="Vector proof summary"
|
||||||
|
>
|
||||||
|
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
|
||||||
|
Confirmed {count(proofCounts.confirmed)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
|
||||||
|
Pending {count(proofCounts.pending)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
|
||||||
|
Failed {count(proofCounts.failed)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<caption className="sr-only">Recent Surplus sales with Vector proof status</caption>
|
||||||
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
|
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">Time</th>
|
<th className="px-4 py-3">Time</th>
|
||||||
@@ -361,7 +378,7 @@ export default function ResaleDashboard() {
|
|||||||
<th className="px-4 py-3 text-right">Output</th>
|
<th className="px-4 py-3 text-right">Output</th>
|
||||||
<th className="px-4 py-3 text-right">Cache read</th>
|
<th className="px-4 py-3 text-right">Cache read</th>
|
||||||
<th className="px-4 py-3 text-right">Revenue</th>
|
<th className="px-4 py-3 text-right">Revenue</th>
|
||||||
<th className="px-4 py-3">Base settlement</th>
|
<th className="px-4 py-3">Vector proof</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
@@ -384,17 +401,8 @@ export default function ResaleDashboard() {
|
|||||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.outputTokens)}</td>
|
<td className="px-4 py-3 text-right tabular-nums">{count(sale.outputTokens)}</td>
|
||||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.cacheReadTokens)}</td>
|
<td className="px-4 py-3 text-right tabular-nums">{count(sale.cacheReadTokens)}</td>
|
||||||
<td className="px-4 py-3 text-right tabular-nums">{usd(sale.revenueUsd)}</td>
|
<td className="px-4 py-3 text-right tabular-nums">{usd(sale.revenueUsd)}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs">
|
<td className="whitespace-nowrap px-4 py-3">
|
||||||
{sale.transactionHash ? (
|
<VectorProofState proof={sale.vectorProof ?? PENDING_PROOF} />
|
||||||
<a
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
href={`https://basescan.org/tx/${sale.transactionHash}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{compact(sale.transactionHash)}
|
|
||||||
</a>
|
|
||||||
) : "Accruing"}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -45,3 +45,16 @@ export type {
|
|||||||
WithdrawWalletBuildResult,
|
WithdrawWalletBuildResult,
|
||||||
} from "./wallet/withdrawWallet.js";
|
} from "./wallet/withdrawWallet.js";
|
||||||
export { buildWithdrawTx } from "./wallet/withdrawWallet.js";
|
export { buildWithdrawTx } from "./wallet/withdrawWallet.js";
|
||||||
|
|
||||||
|
// ── Surplus Merkle-batch anchor (live-only) ────────────────────────────
|
||||||
|
export type {
|
||||||
|
AnchorMetadataParams,
|
||||||
|
SurplusSaleProofAnchorMetadata,
|
||||||
|
} from "./wallet/anchorMetadata.js";
|
||||||
|
export {
|
||||||
|
MAX_TRANSACTION_METADATA_TEXT_BYTES,
|
||||||
|
SURPLUS_SALE_PROOF_METADATA_LABEL,
|
||||||
|
SURPLUS_SALE_PROOF_PROTOCOL,
|
||||||
|
buildAnchorMetadataTx,
|
||||||
|
validateSurplusSaleProofAnchorMetadata,
|
||||||
|
} from "./wallet/anchorMetadata.js";
|
||||||
|
|||||||
166
packages/shared/src/tx/wallet/anchorMetadata.ts
Normal file
166
packages/shared/src/tx/wallet/anchorMetadata.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Builds and submits the live wallet transaction that anchors one Surplus sale
|
||||||
|
* Merkle batch. The caller remains responsible for awaiting confirmation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
|
import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js";
|
||||||
|
import { createLucidContext } from "../internal/lucidContext.js";
|
||||||
|
import type { BuildResult, WalletKey } from "../types.js";
|
||||||
|
import { TxConstructionError } from "../types.js";
|
||||||
|
|
||||||
|
export const SURPLUS_SALE_PROOF_METADATA_LABEL = 674;
|
||||||
|
export const SURPLUS_SALE_PROOF_PROTOCOL = "surplus-sale-proof-v1";
|
||||||
|
export const MAX_TRANSACTION_METADATA_TEXT_BYTES = 64;
|
||||||
|
|
||||||
|
export interface SurplusSaleProofAnchorMetadata {
|
||||||
|
p: typeof SURPLUS_SALE_PROOF_PROTOCOL;
|
||||||
|
root: string;
|
||||||
|
count: number;
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnchorMetadataParams {
|
||||||
|
chain: LiveOgmiosProvider;
|
||||||
|
walletKey: WalletKey;
|
||||||
|
metadata: SurplusSaleProofAnchorMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOT_HEX_RE = /^[0-9a-f]{64}$/;
|
||||||
|
const PRINTABLE_ASCII_RE = /^[\x20-\x7e]+$/;
|
||||||
|
const METADATA_KEYS = ["p", "root", "count", "first", "last"] as const;
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
|
||||||
|
function metadataTextFits(value: string): boolean {
|
||||||
|
return textEncoder.encode(value).byteLength <= MAX_TRANSACTION_METADATA_TEXT_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSaleId(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
PRINTABLE_ASCII_RE.test(value) &&
|
||||||
|
metadataTextFits(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate the exact label-674 payload before Lucid or Ogmios is initialized. */
|
||||||
|
export function validateSurplusSaleProofAnchorMetadata(
|
||||||
|
value: unknown,
|
||||||
|
): asserts value is SurplusSaleProofAnchorMetadata {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new TxConstructionError(
|
||||||
|
"anchor_metadata_invalid",
|
||||||
|
"Surplus sale proof metadata must be an object",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = value as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(metadata);
|
||||||
|
if (
|
||||||
|
keys.length !== METADATA_KEYS.length ||
|
||||||
|
METADATA_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(metadata, key))
|
||||||
|
) {
|
||||||
|
throw new TxConstructionError(
|
||||||
|
"anchor_metadata_invalid",
|
||||||
|
"Surplus sale proof metadata must contain only p, root, count, first, and last",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.p !== SURPLUS_SALE_PROOF_PROTOCOL) {
|
||||||
|
throw new TxConstructionError(
|
||||||
|
"anchor_metadata_invalid",
|
||||||
|
`metadata protocol must be ${SURPLUS_SALE_PROOF_PROTOCOL}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!metadataTextFits(SURPLUS_SALE_PROOF_PROTOCOL)) {
|
||||||
|
throw new TxConstructionError(
|
||||||
|
"anchor_metadata_oversized",
|
||||||
|
"metadata protocol exceeds the 64-byte transaction metadata text limit",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof metadata.root !== "string" || !ROOT_HEX_RE.test(metadata.root)) {
|
||||||
|
throw new TxConstructionError(
|
||||||
|
"anchor_metadata_invalid",
|
||||||
|
"metadata root must be 64 lowercase hexadecimal characters",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!metadataTextFits(metadata.root)) {
|
||||||
|
throw new TxConstructionError(
|
||||||
|
"anchor_metadata_oversized",
|
||||||
|
"metadata root exceeds the 64-byte transaction metadata text limit",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof metadata.count !== "number" ||
|
||||||
|
!Number.isSafeInteger(metadata.count) ||
|
||||||
|
metadata.count <= 0
|
||||||
|
) {
|
||||||
|
throw new TxConstructionError(
|
||||||
|
"anchor_metadata_invalid",
|
||||||
|
"metadata count must be a positive safe integer",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ["first", "last"] as const) {
|
||||||
|
const id = metadata[field];
|
||||||
|
if (!validSaleId(id)) {
|
||||||
|
const oversized =
|
||||||
|
typeof id === "string" &&
|
||||||
|
textEncoder.encode(id).byteLength > MAX_TRANSACTION_METADATA_TEXT_BYTES;
|
||||||
|
throw new TxConstructionError(
|
||||||
|
oversized ? "anchor_metadata_oversized" : "anchor_metadata_invalid",
|
||||||
|
`metadata ${field} must be a non-empty printable ASCII sale ID of at most 64 bytes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildAnchorMetadataTx(
|
||||||
|
params: AnchorMetadataParams,
|
||||||
|
): Promise<BuildResult> {
|
||||||
|
const { chain, walletKey, metadata } = params;
|
||||||
|
validateSurplusSaleProofAnchorMetadata(metadata);
|
||||||
|
|
||||||
|
const provider = new OgmiosLucidProvider({
|
||||||
|
ogmiosUrl: chain.url,
|
||||||
|
fetch: chain.fetchImpl,
|
||||||
|
});
|
||||||
|
const { lucid } = await createLucidContext(
|
||||||
|
provider,
|
||||||
|
walletKey,
|
||||||
|
{ networkId: 1, systemStartUnix: 0, slotLengthMs: 1000 },
|
||||||
|
{ usePresetProtocolParameters: true },
|
||||||
|
);
|
||||||
|
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||||
|
const anchorMetadata = {
|
||||||
|
p: metadata.p,
|
||||||
|
root: metadata.root,
|
||||||
|
count: metadata.count,
|
||||||
|
first: metadata.first,
|
||||||
|
last: metadata.last,
|
||||||
|
};
|
||||||
|
|
||||||
|
let signed;
|
||||||
|
try {
|
||||||
|
const txBuilder = lucid
|
||||||
|
.newTx()
|
||||||
|
.attachMetadata(SURPLUS_SALE_PROOF_METADATA_LABEL, anchorMetadata)
|
||||||
|
.addSignerKey(walletKey.pubKeyHash)
|
||||||
|
.setMinFee(500_000n);
|
||||||
|
const completed = await txBuilder.complete({
|
||||||
|
presetWalletInputs: realWalletUtxos,
|
||||||
|
localUPLCEval: false,
|
||||||
|
});
|
||||||
|
signed = await completed.sign.withWallet().complete();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new TxConstructionError("anchor_metadata_build_failed", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const txCborHex = signed.toCBOR();
|
||||||
|
const expectedTxHash = signed.toHash();
|
||||||
|
await chain.submitTx(txCborHex);
|
||||||
|
|
||||||
|
return { txCborHex, expectedTxHash };
|
||||||
|
}
|
||||||
@@ -69,9 +69,10 @@ export interface SurplusOfferPatch {
|
|||||||
export interface SurplusSale {
|
export interface SurplusSale {
|
||||||
id: string;
|
id: string;
|
||||||
model: string;
|
model: string;
|
||||||
offerId: string | null;
|
offerId: string;
|
||||||
settlementStatus: string;
|
settlementStatus: string;
|
||||||
createdAt: string | null;
|
createdAt: string;
|
||||||
|
createdAtMs: number;
|
||||||
sellerCostMicroUsd: number;
|
sellerCostMicroUsd: number;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
@@ -137,6 +138,15 @@ export class SurplusHttpError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SurplusHistoricalCompletenessError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"Surplus does not provide historical sale pagination; only the observed recent-sales window is available",
|
||||||
|
);
|
||||||
|
this.name = "SurplusHistoricalCompletenessError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SurplusClient {
|
export class SurplusClient {
|
||||||
private readonly apiBaseUrl: string;
|
private readonly apiBaseUrl: string;
|
||||||
private readonly sellerApiKey: string;
|
private readonly sellerApiKey: string;
|
||||||
@@ -337,6 +347,27 @@ export class SurplusClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listObservedSales(): Promise<SurplusSale[]> {
|
||||||
|
const salesById = new Map<string, SurplusSale>();
|
||||||
|
for (const sale of (await this.getEarnings()).recentSales) {
|
||||||
|
const existing = salesById.get(sale.id);
|
||||||
|
if (existing === undefined) {
|
||||||
|
salesById.set(sale.id, sale);
|
||||||
|
} else if (!sameImmutableSale(existing, sale)) {
|
||||||
|
throw new Error(
|
||||||
|
`Surplus recent-sales window contains conflicting immutable fields for sale ${sale.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...salesById.values()].sort((left, right) =>
|
||||||
|
left.id.localeCompare(right.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAllSales(): Promise<SurplusSale[]> {
|
||||||
|
throw new SurplusHistoricalCompletenessError();
|
||||||
|
}
|
||||||
|
|
||||||
private async requestJson(
|
private async requestJson(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
@@ -536,16 +567,21 @@ function parseOrderBookOffer(value: unknown, index: number): SurplusOrderBookOff
|
|||||||
|
|
||||||
function parseSale(value: unknown, index: number): SurplusSale {
|
function parseSale(value: unknown, index: number): SurplusSale {
|
||||||
const item = record(value, `Surplus sale ${index}`);
|
const item = record(value, `Surplus sale ${index}`);
|
||||||
|
const createdAtMs = timestampMs(
|
||||||
|
item.created_at,
|
||||||
|
`Surplus sale ${index}.created_at`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: string(item.id, `Surplus sale ${index}.id`),
|
id: string(item.id, `Surplus sale ${index}.id`),
|
||||||
model: string(item.model, `Surplus sale ${index}.model`),
|
model: string(item.model, `Surplus sale ${index}.model`),
|
||||||
offerId: optionalString(item.offer_id),
|
offerId: string(item.offer_id, `Surplus sale ${index}.offer_id`),
|
||||||
settlementStatus: string(item.settlement_status, `Surplus sale ${index}.settlement_status`),
|
settlementStatus: string(item.settlement_status, `Surplus sale ${index}.settlement_status`),
|
||||||
createdAt: optionalTimestamp(item.created_at, `Surplus sale ${index}.created_at`),
|
createdAt: new Date(createdAtMs).toISOString(),
|
||||||
|
createdAtMs,
|
||||||
sellerCostMicroUsd: microUsd(item.seller_cost_usdc, `Surplus sale ${index}.seller_cost_usdc`),
|
sellerCostMicroUsd: microUsd(item.seller_cost_usdc, `Surplus sale ${index}.seller_cost_usdc`),
|
||||||
inputTokens: optionalSafeInteger(item.input_tokens, `Surplus sale ${index}.input_tokens`),
|
inputTokens: nonNegativeSafeInteger(item.input_tokens, `Surplus sale ${index}.input_tokens`),
|
||||||
outputTokens: optionalSafeInteger(item.output_tokens, `Surplus sale ${index}.output_tokens`),
|
outputTokens: nonNegativeSafeInteger(item.output_tokens, `Surplus sale ${index}.output_tokens`),
|
||||||
cacheReadTokens: optionalSafeInteger(
|
cacheReadTokens: nonNegativeSafeInteger(
|
||||||
item.cache_read_tokens,
|
item.cache_read_tokens,
|
||||||
`Surplus sale ${index}.cache_read_tokens`,
|
`Surplus sale ${index}.cache_read_tokens`,
|
||||||
),
|
),
|
||||||
@@ -561,6 +597,19 @@ function parseSale(value: unknown, index: number): SurplusSale {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameImmutableSale(left: SurplusSale, right: SurplusSale): boolean {
|
||||||
|
return left.id === right.id &&
|
||||||
|
left.offerId === right.offerId &&
|
||||||
|
left.model === right.model &&
|
||||||
|
left.createdAtMs === right.createdAtMs &&
|
||||||
|
left.inputTokens === right.inputTokens &&
|
||||||
|
left.outputTokens === right.outputTokens &&
|
||||||
|
left.cacheReadTokens === right.cacheReadTokens &&
|
||||||
|
left.sellerCostMicroUsd === right.sellerCostMicroUsd &&
|
||||||
|
left.effectiveInputUsdPer1m === right.effectiveInputUsdPer1m &&
|
||||||
|
left.effectiveOutputUsdPer1m === right.effectiveOutputUsdPer1m;
|
||||||
|
}
|
||||||
|
|
||||||
function parseDailyEarnings(
|
function parseDailyEarnings(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -648,15 +697,19 @@ function optionalString(value: unknown): string | null {
|
|||||||
|
|
||||||
function optionalTimestamp(value: unknown, field: string): string | null {
|
function optionalTimestamp(value: unknown, field: string): string | null {
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
|
return new Date(timestampMs(value, field)).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampMs(value: unknown, field: string): number {
|
||||||
const timestamp = typeof value === "number"
|
const timestamp = typeof value === "number"
|
||||||
? value
|
? value
|
||||||
: typeof value === "string"
|
: typeof value === "string"
|
||||||
? Date.parse(value)
|
? Date.parse(value)
|
||||||
: Number.NaN;
|
: Number.NaN;
|
||||||
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
||||||
throw new Error(`${field} must be a timestamp`);
|
throw new Error(`${field} must be a timestamp with integer milliseconds`);
|
||||||
}
|
}
|
||||||
return new Date(timestamp).toISOString();
|
return timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boolean(value: unknown, field: string): boolean {
|
function boolean(value: unknown, field: string): boolean {
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import type {
|
|||||||
SurplusEarnings,
|
SurplusEarnings,
|
||||||
SurplusOffer,
|
SurplusOffer,
|
||||||
SurplusOrderBook,
|
SurplusOrderBook,
|
||||||
|
SurplusSale,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
import {
|
||||||
|
hashSurplusSale,
|
||||||
|
type SurplusDashboardVectorProof,
|
||||||
|
} from "./vectorProof.js";
|
||||||
import type {
|
import type {
|
||||||
SurplusControllerStatus,
|
SurplusControllerStatus,
|
||||||
SurplusSellerController,
|
SurplusSellerController,
|
||||||
@@ -25,6 +30,19 @@ export interface VectorMigrationSummary {
|
|||||||
historicalSettledJobs: number;
|
historicalSettledJobs: number;
|
||||||
historicalUpstreamSpendUsd: string;
|
historicalUpstreamSpendUsd: string;
|
||||||
}
|
}
|
||||||
|
export interface DashboardSaleProof {
|
||||||
|
status: "pending" | "confirmed" | "failed";
|
||||||
|
saleHash: string | null;
|
||||||
|
batchRoot: string | null;
|
||||||
|
txHash: string | null;
|
||||||
|
leafIndex: number | null;
|
||||||
|
siblings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardProofSource {
|
||||||
|
dashboardProof(saleId: string): SurplusDashboardVectorProof | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface SurplusDashboardSnapshot {
|
export interface SurplusDashboardSnapshot {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
@@ -80,6 +98,7 @@ export interface SurplusDashboardSnapshot {
|
|||||||
cacheReadTokens: number;
|
cacheReadTokens: number;
|
||||||
revenueUsd: string;
|
revenueUsd: string;
|
||||||
transactionHash: string | null;
|
transactionHash: string | null;
|
||||||
|
vectorProof: DashboardSaleProof;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
vector: VectorMigrationSummary;
|
vector: VectorMigrationSummary;
|
||||||
@@ -91,6 +110,7 @@ interface SurplusDashboardOptions {
|
|||||||
allowance: Pick<OpenRouterKeyClient, "readAllowance">;
|
allowance: Pick<OpenRouterKeyClient, "readAllowance">;
|
||||||
config: SurplusManagerConfig;
|
config: SurplusManagerConfig;
|
||||||
vector?: VectorMigrationSummary;
|
vector?: VectorMigrationSummary;
|
||||||
|
proofs?: DashboardProofSource;
|
||||||
cacheMs?: number;
|
cacheMs?: number;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
}
|
}
|
||||||
@@ -117,6 +137,7 @@ export class SurplusDashboardService {
|
|||||||
>;
|
>;
|
||||||
private readonly allowance: Pick<OpenRouterKeyClient, "readAllowance">;
|
private readonly allowance: Pick<OpenRouterKeyClient, "readAllowance">;
|
||||||
private readonly config: SurplusManagerConfig;
|
private readonly config: SurplusManagerConfig;
|
||||||
|
private readonly proofs: DashboardProofSource | null;
|
||||||
private readonly vector: VectorMigrationSummary;
|
private readonly vector: VectorMigrationSummary;
|
||||||
private readonly cacheMs: number;
|
private readonly cacheMs: number;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
@@ -128,6 +149,7 @@ export class SurplusDashboardService {
|
|||||||
this.client = options.client;
|
this.client = options.client;
|
||||||
this.allowance = options.allowance;
|
this.allowance = options.allowance;
|
||||||
this.config = options.config;
|
this.config = options.config;
|
||||||
|
this.proofs = options.proofs ?? null;
|
||||||
this.vector = options.vector ?? DEFAULT_VECTOR_MIGRATION;
|
this.vector = options.vector ?? DEFAULT_VECTOR_MIGRATION;
|
||||||
this.cacheMs = options.cacheMs ?? 15_000;
|
this.cacheMs = options.cacheMs ?? 15_000;
|
||||||
this.now = options.now ?? Date.now;
|
this.now = options.now ?? Date.now;
|
||||||
@@ -218,11 +240,31 @@ export class SurplusDashboardService {
|
|||||||
cacheReadTokens: sale.cacheReadTokens,
|
cacheReadTokens: sale.cacheReadTokens,
|
||||||
revenueUsd: formatMicroUsd(sale.sellerCostMicroUsd),
|
revenueUsd: formatMicroUsd(sale.sellerCostMicroUsd),
|
||||||
transactionHash: sale.transactionHash,
|
transactionHash: sale.transactionHash,
|
||||||
|
vectorProof: this.proofFor(sale),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
vector: this.vector,
|
vector: this.vector,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private proofFor(sale: SurplusSale): DashboardSaleProof {
|
||||||
|
const ledger = this.proofs?.dashboardProof(sale.id) ?? null;
|
||||||
|
if (ledger !== null) return ledger;
|
||||||
|
let saleHash: string | null = null;
|
||||||
|
try {
|
||||||
|
saleHash = hashSurplusSale(sale);
|
||||||
|
} catch {
|
||||||
|
saleHash = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "pending",
|
||||||
|
saleHash,
|
||||||
|
batchRoot: null,
|
||||||
|
txHash: null,
|
||||||
|
leafIndex: null,
|
||||||
|
siblings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findOffer(
|
function findOffer(
|
||||||
|
|||||||
1085
supplier/src/surplus/vectorProof.ts
Normal file
1085
supplier/src/surplus/vectorProof.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import ResaleDashboard from "../../buyer/src/ui/pages/ResaleDashboard.js";
|
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 = {
|
const DASHBOARD = {
|
||||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||||
@@ -47,28 +49,62 @@ const DASHBOARD = {
|
|||||||
payoutHoldReason: "new_seller",
|
payoutHoldReason: "new_seller",
|
||||||
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
|
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
|
||||||
},
|
},
|
||||||
recentSales: [{
|
recentSales: [
|
||||||
id: "sale-1",
|
{
|
||||||
model: "deepseek-v4-flash",
|
id: "sale-1",
|
||||||
createdAt: "2026-08-28T11:00:00.000Z",
|
model: "deepseek-v4-flash",
|
||||||
settlementStatus: "confirmed",
|
createdAt: "2026-08-28T11:00:00.000Z",
|
||||||
inputTokens: 10,
|
settlementStatus: "confirmed",
|
||||||
outputTokens: 2,
|
inputTokens: 10,
|
||||||
cacheReadTokens: 3,
|
outputTokens: 2,
|
||||||
revenueUsd: "0.000083",
|
cacheReadTokens: 3,
|
||||||
transactionHash: "0x" + "c".repeat(64),
|
revenueUsd: "0.000083",
|
||||||
}],
|
vectorProof: {
|
||||||
},
|
status: "confirmed",
|
||||||
vector: {
|
saleHash: "1".repeat(64),
|
||||||
status: "retired",
|
batchRoot: "2".repeat(64),
|
||||||
model: "deepseek/deepseek-v4-flash",
|
txHash: "d".repeat(64),
|
||||||
supplierWallet: "addr1test",
|
leafIndex: 0,
|
||||||
advertRef: `${"a".repeat(64)}#0`,
|
siblings: ["3".repeat(64)],
|
||||||
retirementTransaction: "b".repeat(64),
|
},
|
||||||
retiredOn: "2026-08-27",
|
},
|
||||||
historicalAp3xEarned: "1.600000",
|
{
|
||||||
historicalSettledJobs: 8,
|
id: "sale-2",
|
||||||
historicalUpstreamSpendUsd: "0.000093",
|
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();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ResaleDashboard", () => {
|
describe("Capacity markets dashboard", () => {
|
||||||
it("matches the marketplace card system and renders both settlement markets", async () => {
|
it("renders the authenticated route with a Vector proof state for every sale", async () => {
|
||||||
const fetchMock = vi.fn(async () => Response.json(DASHBOARD));
|
const fetchMock = vi.fn(async () => Response.json(DASHBOARD));
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
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: "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("$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(screen.getByTestId("resale-dashboard")).toHaveClass("max-w-7xl");
|
||||||
expect(document.querySelectorAll(".bg-white").length).toBeGreaterThan(3);
|
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",
|
"href",
|
||||||
`https://vector.apexscan.org/en/transaction/${"b".repeat(64)}/summary/`,
|
`https://vector.apexscan.org/en/transaction/${"d".repeat(64)}/summary/`,
|
||||||
);
|
|
||||||
expect(screen.getByRole("link", { name: /0xcccc/ })).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
`https://basescan.org/tx/0x${"c".repeat(64)}`,
|
|
||||||
);
|
);
|
||||||
|
expect(within(proofLink).getByText("dddddddddddd…ddddddddd")).toBeInTheDocument();
|
||||||
|
|
||||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||||
"/v1/resale-dashboard",
|
"/v1/resale-dashboard",
|
||||||
{ cache: "no-store" },
|
{ 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", () => {
|
describe("SurplusClient", () => {
|
||||||
it("authenticates and parses fragmented NDJSON discovery", async () => {
|
it("authenticates and parses fragmented NDJSON discovery", async () => {
|
||||||
const first = discoveryRow("alpha", "vendor/alpha");
|
const first = discoveryRow("alpha", "vendor/alpha");
|
||||||
@@ -296,6 +332,90 @@ describe("SurplusClient", () => {
|
|||||||
expect(earnings.recentSales[0].createdAt).toBe(
|
expect(earnings.recentSales[0].createdAt).toBe(
|
||||||
new Date(1787831146968).toISOString(),
|
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 () => {
|
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