feat: rename dashboard to Capacity Markets with Vector sale proofs
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* Routes (all gated by <RequireAuth>):
|
||||
* / → <Dashboard />
|
||||
* /tasks → <TaskHistory />
|
||||
* /dashboard → <ResaleDashboard /> unified Surplus + Vector metrics
|
||||
* /dashboard → <ResaleDashboard /> Capacity markets and sale proofs
|
||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||
* /wallet → <Wallet />
|
||||
* /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">
|
||||
<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="/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
|
||||
summarize code remain intact; restore by re-adding this link:
|
||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
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 {
|
||||
generatedAt: string;
|
||||
controller: {
|
||||
@@ -60,20 +78,10 @@ interface DashboardSnapshot {
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
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 {
|
||||
@@ -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 }) {
|
||||
return (
|
||||
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||
@@ -170,7 +206,7 @@ export default function ResaleDashboard() {
|
||||
}, [refresh]);
|
||||
|
||||
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) {
|
||||
@@ -183,18 +219,20 @@ export default function ResaleDashboard() {
|
||||
|
||||
const offer = data.surplus.offer;
|
||||
const earnings = data.surplus.earnings;
|
||||
const vector = data.vector;
|
||||
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 (
|
||||
<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="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">
|
||||
One OpenRouter capacity pool managed by the seller agent. Surplus is the live Base USDC
|
||||
market. Vector preserves the completed AP3X escrow proof.
|
||||
Surplus turns available OpenRouter capacity into Base USDC sales. Vector anchors
|
||||
Merkle-batched proofs so every sale has a verifiable transaction record.
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Capacity route</h2>
|
||||
<p className="text-sm text-gray-500">The same upstream capacity moved from Vector proof to Surplus production.</p>
|
||||
<h2 className="font-semibold">Capacity and proof route</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Capacity sells through Surplus, and each sale joins a shared Vector anchor.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">Updated {timestamp(data.generatedAt)}</span>
|
||||
</div>
|
||||
@@ -244,86 +284,47 @@ export default function ResaleDashboard() {
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Live settlement"
|
||||
label="Live market"
|
||||
title="Surplus / Base USDC"
|
||||
detail={`${offerLive ? "Available" : "Unavailable"} · ${offer.trusted ? "trusted" : "untrusted"}`}
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<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">Current market</p>
|
||||
<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>
|
||||
</div>
|
||||
<StatusPill label={offer.status} active={offerLive} />
|
||||
<section 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">Current market</p>
|
||||
<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>
|
||||
</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="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
|
||||
<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`} />
|
||||
<StatusPill label={offer.status} active={offerLive} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
|
||||
<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>
|
||||
<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 className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
@@ -350,8 +351,24 @@ export default function ResaleDashboard() {
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{count(earnings.requests)} lifetime requests · {count(earnings.tokens)} tokens</p>
|
||||
</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">
|
||||
<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">
|
||||
<tr>
|
||||
<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">Cache read</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>
|
||||
</thead>
|
||||
<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.cacheReadTokens)}</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">
|
||||
{sale.transactionHash ? (
|
||||
<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 className="whitespace-nowrap px-4 py-3">
|
||||
<VectorProofState proof={sale.vectorProof ?? PENDING_PROOF} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -45,3 +45,16 @@ export type {
|
||||
WithdrawWalletBuildResult,
|
||||
} 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 {
|
||||
id: string;
|
||||
model: string;
|
||||
offerId: string | null;
|
||||
offerId: string;
|
||||
settlementStatus: string;
|
||||
createdAt: string | null;
|
||||
createdAt: string;
|
||||
createdAtMs: number;
|
||||
sellerCostMicroUsd: number;
|
||||
inputTokens: 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 {
|
||||
private readonly apiBaseUrl: 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(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
@@ -536,16 +567,21 @@ function parseOrderBookOffer(value: unknown, index: number): SurplusOrderBookOff
|
||||
|
||||
function parseSale(value: unknown, index: number): SurplusSale {
|
||||
const item = record(value, `Surplus sale ${index}`);
|
||||
const createdAtMs = timestampMs(
|
||||
item.created_at,
|
||||
`Surplus sale ${index}.created_at`,
|
||||
);
|
||||
return {
|
||||
id: string(item.id, `Surplus sale ${index}.id`),
|
||||
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`),
|
||||
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`),
|
||||
inputTokens: optionalSafeInteger(item.input_tokens, `Surplus sale ${index}.input_tokens`),
|
||||
outputTokens: optionalSafeInteger(item.output_tokens, `Surplus sale ${index}.output_tokens`),
|
||||
cacheReadTokens: optionalSafeInteger(
|
||||
inputTokens: nonNegativeSafeInteger(item.input_tokens, `Surplus sale ${index}.input_tokens`),
|
||||
outputTokens: nonNegativeSafeInteger(item.output_tokens, `Surplus sale ${index}.output_tokens`),
|
||||
cacheReadTokens: nonNegativeSafeInteger(
|
||||
item.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(
|
||||
value: unknown,
|
||||
index: number,
|
||||
@@ -648,15 +697,19 @@ function optionalString(value: unknown): string | null {
|
||||
|
||||
function optionalTimestamp(value: unknown, field: string): string | 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"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Date.parse(value)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
||||
throw new Error(`${field} must be a timestamp`);
|
||||
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
||||
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 {
|
||||
|
||||
@@ -5,7 +5,12 @@ import type {
|
||||
SurplusEarnings,
|
||||
SurplusOffer,
|
||||
SurplusOrderBook,
|
||||
SurplusSale,
|
||||
} from "./client.js";
|
||||
import {
|
||||
hashSurplusSale,
|
||||
type SurplusDashboardVectorProof,
|
||||
} from "./vectorProof.js";
|
||||
import type {
|
||||
SurplusControllerStatus,
|
||||
SurplusSellerController,
|
||||
@@ -25,6 +30,19 @@ export interface VectorMigrationSummary {
|
||||
historicalSettledJobs: number;
|
||||
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 {
|
||||
generatedAt: string;
|
||||
@@ -80,6 +98,7 @@ export interface SurplusDashboardSnapshot {
|
||||
cacheReadTokens: number;
|
||||
revenueUsd: string;
|
||||
transactionHash: string | null;
|
||||
vectorProof: DashboardSaleProof;
|
||||
}>;
|
||||
};
|
||||
vector: VectorMigrationSummary;
|
||||
@@ -91,6 +110,7 @@ interface SurplusDashboardOptions {
|
||||
allowance: Pick<OpenRouterKeyClient, "readAllowance">;
|
||||
config: SurplusManagerConfig;
|
||||
vector?: VectorMigrationSummary;
|
||||
proofs?: DashboardProofSource;
|
||||
cacheMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
@@ -117,6 +137,7 @@ export class SurplusDashboardService {
|
||||
>;
|
||||
private readonly allowance: Pick<OpenRouterKeyClient, "readAllowance">;
|
||||
private readonly config: SurplusManagerConfig;
|
||||
private readonly proofs: DashboardProofSource | null;
|
||||
private readonly vector: VectorMigrationSummary;
|
||||
private readonly cacheMs: number;
|
||||
private readonly now: () => number;
|
||||
@@ -128,6 +149,7 @@ export class SurplusDashboardService {
|
||||
this.client = options.client;
|
||||
this.allowance = options.allowance;
|
||||
this.config = options.config;
|
||||
this.proofs = options.proofs ?? null;
|
||||
this.vector = options.vector ?? DEFAULT_VECTOR_MIGRATION;
|
||||
this.cacheMs = options.cacheMs ?? 15_000;
|
||||
this.now = options.now ?? Date.now;
|
||||
@@ -218,11 +240,31 @@ export class SurplusDashboardService {
|
||||
cacheReadTokens: sale.cacheReadTokens,
|
||||
revenueUsd: formatMicroUsd(sale.sellerCostMicroUsd),
|
||||
transactionHash: sale.transactionHash,
|
||||
vectorProof: this.proofFor(sale),
|
||||
})),
|
||||
},
|
||||
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(
|
||||
|
||||
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
|
||||
|
||||
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