wallet: tx:consolidate-wallet CLI + collateral pre-check across script spends
Adds a one-shot operational tx that re-shapes any operator wallet to
{5 AP3X collateral candidate, working remainder} so subsequent Plutus
script-spends (Claim/Submit/Accept/Reclaim/RetireAdvert) find a UTxO
that satisfies lucid's setCollateral=5_000_000n default coin selector.
Symptom this addresses: open-webui mainnet supplier wallet held 8 UTxOs
max 2.40 AP3X, every Accept tx failed at lucid .complete() with the
"wallet must hold a pure-ADA UTxO ≥ 5 ADA" error path. Same fragmentation
risk on the other 4 mainnet operator wallets (one big UTxO surrounded by
dust — first script-spend consumes the big one and leaves nothing usable
as collateral).
Phase 1 — new tx:consolidate-wallet CLI (supplier + buyer):
- planConsolidate.ts — pure planner; supports consolidate (N>1 → 2),
split (1 → 2), and already-healthy skip.
- consolidateWallet.ts — live builder; presetWalletInputs pins all
UTxOs, two pay.ToAddress outputs (collateral + working).
- runConsolidateWallet.ts — orchestration (build + submit + awaitTx).
- supplier/src/cli/consolidate-wallet.ts + buyer mirror.
- monitor-wallets.ts — add supplier-tts@vector-marketplace (was
misclassified as 3rd-party).
Phase 2 — defensive pre-check in the 5 script-spend builders:
- assertCollateralCandidate(utxos) fires at the top of
buildSpendOpenOrClaimedTx (Claim/Submit), buildLiveTxForAccept,
buildLiveTxForReclaim, buildLiveTxForRetireAdvert. Throws a
TxConstructionError("collateral_required") pointing at
tx:consolidate-wallet instead of waiting for lucid's deep failure.
Tests: 12 new passing (planConsolidate edge cases + assert helper).
Pre-existing lucid 0.4.30 WASM "unreachable" failures in tx-*-live.test.ts
fixtures are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,8 @@
|
||||
"tx:accept": "tsx src/cli/accept.ts",
|
||||
"cycle:once": "tsx src/cli/run-cycle.ts",
|
||||
"reclaim:orphans": "tsx src/cli/reclaim-orphans.ts",
|
||||
"monitor:wallets": "tsx scripts/monitor-wallets.ts"
|
||||
"monitor:wallets": "tsx scripts/monitor-wallets.ts",
|
||||
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/shared": "workspace:*",
|
||||
|
||||
@@ -62,6 +62,13 @@ const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-tts@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.tts",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@open-webui",
|
||||
host: "open-webui",
|
||||
|
||||
274
buyer/src/cli/consolidate-wallet.ts
Normal file
274
buyer/src/cli/consolidate-wallet.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* consolidate-wallet.ts — CLI entry point for
|
||||
* `pnpm --filter @marketplace/buyer tx:consolidate-wallet`.
|
||||
*
|
||||
* Re-shapes the buyer wallet to {collateral UTxO (default 5 AP3X),
|
||||
* working UTxO (remainder)} so subsequent script-spend txs (Reclaim,
|
||||
* Accept-on-buyer-side) find a pure-AP3X collateral candidate.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @marketplace/buyer tx:consolidate-wallet [--dry-run] \
|
||||
* [--collateral-lovelace 5000000] [--await-timeout-ms 120000]
|
||||
*
|
||||
* Required env: BUYER_PRIV_KEY_HEX, NETWORK_ID, OGMIOS_URL (when NETWORK_ID=1).
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import * as ed from "@noble/ed25519";
|
||||
import { blake2b } from "@noble/hashes/blake2b";
|
||||
import { bech32 } from "bech32";
|
||||
import { LiveOgmiosProvider, type ChainProvider } from "@marketplace/shared/chain";
|
||||
import type { WalletKey } from "@marketplace/shared/tx";
|
||||
import { runConsolidateWallet } from "@marketplace/shared/tx/server";
|
||||
|
||||
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
|
||||
const h = createHash("sha512");
|
||||
for (const m of messages) h.update(m);
|
||||
return new Uint8Array(h.digest());
|
||||
};
|
||||
|
||||
export type NetworkId = 0 | 1;
|
||||
|
||||
export interface CliConfig {
|
||||
ogmiosUrl: string;
|
||||
privKeyHex: string;
|
||||
networkId: NetworkId;
|
||||
collateralLovelace: bigint;
|
||||
awaitTimeoutMs: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
const HEX64_RE = /^[0-9a-f]{64}$/;
|
||||
const TESTNET_OGMIOS_DEFAULT = "https://ogmios.vector.testnet.apexfusion.org";
|
||||
const DEFAULT_AWAIT_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_COLLATERAL_LOVELACE = 5_000_000;
|
||||
|
||||
const VALUE_FLAGS = new Set<string>([
|
||||
"--ogmios-url",
|
||||
"--priv-key",
|
||||
"--collateral-lovelace",
|
||||
"--await-timeout-ms",
|
||||
]);
|
||||
const BOOL_FLAGS = new Set<string>(["--dry-run"]);
|
||||
|
||||
function requireEnv(env: Record<string, string | undefined>, name: string): string {
|
||||
const v = env[name];
|
||||
if (v === undefined || v === null || v === "") {
|
||||
throw new Error(`missing required env var: ${name}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function parseIntStrict(name: string, raw: string): number {
|
||||
if (!/^-?\d+$/.test(raw)) {
|
||||
throw new Error(`${name} must be an integer (got: ${raw})`);
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
||||
throw new Error(`${name} must be an integer (got: ${raw})`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
interface RawFlags {
|
||||
ogmiosUrl?: string;
|
||||
privKey?: string;
|
||||
collateralLovelace?: string;
|
||||
awaitTimeoutMs?: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseRawArgv(argv: string[]): RawFlags {
|
||||
const out: RawFlags = { dryRun: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
if (BOOL_FLAGS.has(tok)) {
|
||||
if (tok === "--dry-run") out.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (VALUE_FLAGS.has(tok)) {
|
||||
const val = argv[i + 1];
|
||||
if (val === undefined) throw new Error(`flag ${tok} requires a value`);
|
||||
switch (tok) {
|
||||
case "--ogmios-url":
|
||||
out.ogmiosUrl = val;
|
||||
break;
|
||||
case "--priv-key":
|
||||
out.privKey = val;
|
||||
break;
|
||||
case "--collateral-lovelace":
|
||||
out.collateralLovelace = val;
|
||||
break;
|
||||
case "--await-timeout-ms":
|
||||
out.awaitTimeoutMs = val;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown flag: ${tok}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseCliArgs(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): CliConfig {
|
||||
const flags = parseRawArgv(argv);
|
||||
|
||||
const networkIdRaw = requireEnv(env, "NETWORK_ID");
|
||||
if (networkIdRaw !== "0" && networkIdRaw !== "1") {
|
||||
throw new Error(`NETWORK_ID must be "0" or "1", got: ${networkIdRaw}`);
|
||||
}
|
||||
const networkId: NetworkId = networkIdRaw === "1" ? 1 : 0;
|
||||
|
||||
const privKeyHex = flags.privKey ?? requireEnv(env, "BUYER_PRIV_KEY_HEX");
|
||||
if (!HEX64_RE.test(privKeyHex)) {
|
||||
throw new Error(
|
||||
"BUYER_PRIV_KEY_HEX (or --priv-key) must be 64 lowercase hex chars",
|
||||
);
|
||||
}
|
||||
|
||||
let ogmiosUrl: string;
|
||||
if (flags.ogmiosUrl !== undefined) {
|
||||
ogmiosUrl = flags.ogmiosUrl;
|
||||
} else if (env.OGMIOS_URL !== undefined && env.OGMIOS_URL !== "") {
|
||||
ogmiosUrl = env.OGMIOS_URL;
|
||||
} else if (networkId === 0) {
|
||||
ogmiosUrl = TESTNET_OGMIOS_DEFAULT;
|
||||
} else {
|
||||
throw new Error("OGMIOS_URL is required when NETWORK_ID=1");
|
||||
}
|
||||
|
||||
const collateralLovelace = BigInt(
|
||||
flags.collateralLovelace === undefined
|
||||
? DEFAULT_COLLATERAL_LOVELACE
|
||||
: parseIntStrict("--collateral-lovelace", flags.collateralLovelace),
|
||||
);
|
||||
|
||||
const awaitTimeoutMs =
|
||||
flags.awaitTimeoutMs === undefined
|
||||
? DEFAULT_AWAIT_TIMEOUT_MS
|
||||
: parseIntStrict("--await-timeout-ms", flags.awaitTimeoutMs);
|
||||
|
||||
return {
|
||||
ogmiosUrl,
|
||||
privKeyHex,
|
||||
networkId,
|
||||
collateralLovelace,
|
||||
awaitTimeoutMs,
|
||||
dryRun: flags.dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
function deriveWalletKey(privHex: string, networkId: NetworkId): WalletKey {
|
||||
const priv = hexToBytes(privHex);
|
||||
const pub = ed.getPublicKey(priv);
|
||||
const pubHex = bytesToHex(pub);
|
||||
const pkh = blake2b(pub, { dkLen: 28 });
|
||||
const pkhHex = bytesToHex(pkh);
|
||||
const header = networkId === 0 ? 0x60 : 0x61;
|
||||
const payload = new Uint8Array(29);
|
||||
payload[0] = header;
|
||||
payload.set(pkh, 1);
|
||||
const words = bech32.toWords(payload);
|
||||
const hrp = networkId === 0 ? "addr_test" : "addr";
|
||||
const addr = bech32.encode(hrp, words, 1023);
|
||||
return {
|
||||
pubKeyHash: pkhHex,
|
||||
pubKeyHex: pubHex,
|
||||
privateKeyHex: privHex,
|
||||
address: addr,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<number> {
|
||||
let cfg: CliConfig;
|
||||
try {
|
||||
cfg = parseCliArgs(argv, env);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let walletKey: WalletKey;
|
||||
try {
|
||||
walletKey = deriveWalletKey(cfg.privKeyHex, cfg.networkId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: wallet derivation failed: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const chain: ChainProvider = new LiveOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
|
||||
|
||||
if (cfg.dryRun) {
|
||||
try {
|
||||
const utxos = await chain.queryUtxosByAddress(walletKey.address);
|
||||
const total = utxos.reduce((acc, u) => acc + u.lovelace, 0n);
|
||||
process.stdout.write(
|
||||
`dry-run: wallet ${walletKey.address}\n` +
|
||||
` ${utxos.length} UTxO(s), total ${total} lovelace\n` +
|
||||
` target collateral: ${cfg.collateralLovelace}\n` +
|
||||
` planned working: ~${total - cfg.collateralLovelace - 2_000_000n} (after 2M fee reserve)\n`,
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: dry-run query failed: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runConsolidateWallet({
|
||||
chain,
|
||||
walletKey,
|
||||
collateralLovelace: cfg.collateralLovelace,
|
||||
awaitTimeoutMs: cfg.awaitTimeoutMs,
|
||||
log: (line) => process.stderr.write(`${line}\n`),
|
||||
});
|
||||
process.stdout.write(`REASON=${result.reason}\n`);
|
||||
if (result.txHash) {
|
||||
process.stdout.write(`TX_HASH=${result.txHash}\n`);
|
||||
process.stdout.write(`COLLATERAL_UTXO=${result.collateralRef}\n`);
|
||||
process.stdout.write(`WORKING_UTXO=${result.workingRef}\n`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1]?.endsWith("consolidate-wallet.ts") ||
|
||||
process.argv[1]?.endsWith("consolidate-wallet.js")
|
||||
) {
|
||||
main(process.argv.slice(2), process.env)
|
||||
.then((code) => process.exit(code))
|
||||
.catch((err) => {
|
||||
process.stderr.write(`consolidate-wallet: fatal: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -292,6 +292,34 @@ function chainUtxoToLucidScriptInput(
|
||||
};
|
||||
}
|
||||
|
||||
/** Lucid 0.4.30's default `setCollateral` is 5_000_000n; its collateral
|
||||
* selector needs a pure-AP3X UTxO ≥ this threshold. Mirror that floor
|
||||
* here so we can fail fast with an actionable hint instead of waiting
|
||||
* for lucid's deep "wallet must hold a pure-ADA UTxO ≥ 5 ADA" error. */
|
||||
const COLLATERAL_MIN_LOVELACE = 5_000_000n;
|
||||
|
||||
/** Pre-check: assert the wallet has a UTxO that can serve as collateral.
|
||||
* Called at the top of each script-spend builder so a fragmented wallet
|
||||
* surfaces a clear "run tx:consolidate-wallet" message instead of the
|
||||
* deeper lucid error caught downstream by rethrowAsTxError. */
|
||||
export function assertCollateralCandidate(utxos: LucidUTxO[]): void {
|
||||
for (const u of utxos) {
|
||||
if (u.assets.lovelace < COLLATERAL_MIN_LOVELACE) continue;
|
||||
const onlyLovelace = Object.keys(u.assets).every((k) => k === "lovelace");
|
||||
if (onlyLovelace) return;
|
||||
}
|
||||
const maxLovelace = utxos.reduce(
|
||||
(m, u) => (u.assets.lovelace > m ? u.assets.lovelace : m),
|
||||
0n,
|
||||
);
|
||||
throw new TxConstructionError(
|
||||
"collateral_required",
|
||||
`wallet has no UTxO ≥ ${COLLATERAL_MIN_LOVELACE} lovelace for collateral ` +
|
||||
`(have ${utxos.length} UTxO(s), max ${maxLovelace} lovelace). ` +
|
||||
`run \`pnpm --filter @marketplace/{supplier,buyer} tx:consolidate-wallet\` to re-shape the wallet.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** TxConstructionError wrapping arbitrary lucid failures, surfacing
|
||||
* collateral mismatches with a stable substring so tests can assert.
|
||||
*
|
||||
@@ -446,6 +474,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
|
||||
// that held only 5 AP3X total; in production the real wallet has many UTxOs
|
||||
// and the ledger rejects unknown references. ARCHITECTURE.md §9 #14 cleanup.
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
assertCollateralCandidate(realWalletUtxos);
|
||||
|
||||
let signed;
|
||||
try {
|
||||
@@ -595,6 +624,7 @@ export async function buildLiveTxForAccept(params: LiveAcceptParams): Promise<Bu
|
||||
// Real wallet UTxOs only; the test-side synthetic padding input is removed
|
||||
// (it caused unknownOutputReferences errors against the real ledger).
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
assertCollateralCandidate(realWalletUtxos);
|
||||
const presetWalletInputs = realWalletUtxos;
|
||||
|
||||
let signed;
|
||||
@@ -660,6 +690,7 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
|
||||
const buyerAddress = pkhToEnterpriseAddress(params.datum.buyer_pkh, MAINNET_ID);
|
||||
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
assertCollateralCandidate(realWalletUtxos);
|
||||
const presetWalletInputs = realWalletUtxos;
|
||||
|
||||
let signed;
|
||||
@@ -785,6 +816,7 @@ export async function buildLiveTxForRetireAdvert(
|
||||
advertUtxo.lovelace < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : advertUtxo.lovelace;
|
||||
|
||||
const presetWalletInputs = await lucid.wallet().getUtxos();
|
||||
assertCollateralCandidate(presetWalletInputs);
|
||||
|
||||
let signed;
|
||||
try {
|
||||
|
||||
@@ -16,3 +16,25 @@ export type {
|
||||
PublishRefScriptsBuildResult,
|
||||
} from "./internal/publishReferenceScripts.js";
|
||||
export { buildLiveTxForPublishReferenceScripts } from "./internal/publishReferenceScripts.js";
|
||||
|
||||
// ── Wallet consolidate (one-shot operational tx; live-only) ─────────────
|
||||
export type {
|
||||
ConsolidateWalletParams,
|
||||
ConsolidateWalletBuildResult,
|
||||
} from "./wallet/consolidateWallet.js";
|
||||
export { buildConsolidateWalletTx } from "./wallet/consolidateWallet.js";
|
||||
export type {
|
||||
ConsolidateReason,
|
||||
PlannedConsolidate,
|
||||
UtxoLike,
|
||||
} from "./wallet/planConsolidate.js";
|
||||
export {
|
||||
DEFAULT_COLLATERAL_LOVELACE,
|
||||
DEFAULT_FEE_RESERVE,
|
||||
planConsolidate,
|
||||
} from "./wallet/planConsolidate.js";
|
||||
export type {
|
||||
ConsolidateWalletFlowParams,
|
||||
ConsolidateWalletFlowResult,
|
||||
} from "./wallet/runConsolidateWallet.js";
|
||||
export { runConsolidateWallet } from "./wallet/runConsolidateWallet.js";
|
||||
|
||||
123
packages/shared/src/tx/wallet/consolidateWallet.ts
Normal file
123
packages/shared/src/tx/wallet/consolidateWallet.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* consolidateWallet.ts — wallet re-shaping tx builder.
|
||||
*
|
||||
* Re-shapes any wallet to {collateral UTxO (default 5 AP3X), working UTxO
|
||||
* (remainder)} so lucid-evolution's coin selection for Plutus-script spends
|
||||
* (Claim/Submit/Accept/Reclaim/RetireAdvert) reliably finds a pure-AP3X
|
||||
* UTxO ≥ setCollateral (default 5_000_000n) as collateral candidate,
|
||||
* distinct from the fee/change input.
|
||||
*
|
||||
* Handles two modes via the same tx shape (driven by planConsolidate):
|
||||
* - consolidate: N > 1 fragmented UTxOs → 2 outputs.
|
||||
* - split: 1 large UTxO → 2 outputs.
|
||||
*
|
||||
* No script witness, no datum, no native assets. One output to wallet vkh
|
||||
* with exactly `collateralLovelace`, one output to wallet vkh with the
|
||||
* remainder (lucid balances residual fee from the working output via
|
||||
* change-balancing on top of the explicit `lovelace: workingOutput` value).
|
||||
*
|
||||
* Operational use only. Invoke via supplier/src/cli/consolidate-wallet.ts
|
||||
* or buyer/src/cli/consolidate-wallet.ts.
|
||||
*/
|
||||
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
import type { BuildResult, WalletKey } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js";
|
||||
import { createLucidContext } from "../internal/lucidContext.js";
|
||||
import {
|
||||
DEFAULT_COLLATERAL_LOVELACE,
|
||||
planConsolidate,
|
||||
type ConsolidateReason,
|
||||
} from "./planConsolidate.js";
|
||||
|
||||
export interface ConsolidateWalletParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
/** Lovelace value for output #0 (the collateral candidate).
|
||||
* Default 5_000_000n (matches lucid's default `setCollateral`). */
|
||||
collateralLovelace?: bigint;
|
||||
}
|
||||
|
||||
export interface ConsolidateWalletBuildResult extends BuildResult {
|
||||
inputCount: number;
|
||||
totalLovelaceIn: bigint;
|
||||
collateralOutputLovelace: bigint;
|
||||
workingOutputLovelace: bigint;
|
||||
reason: ConsolidateReason;
|
||||
/** True when the wallet was already at {≥collateral, working} — no tx submitted. */
|
||||
alreadyHealthy: boolean;
|
||||
}
|
||||
|
||||
export async function buildConsolidateWalletTx(
|
||||
params: ConsolidateWalletParams,
|
||||
): Promise<ConsolidateWalletBuildResult> {
|
||||
const { chain, walletKey } = params;
|
||||
const collateralLovelace =
|
||||
params.collateralLovelace ?? DEFAULT_COLLATERAL_LOVELACE;
|
||||
|
||||
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 plan = planConsolidate(realWalletUtxos, collateralLovelace);
|
||||
|
||||
if (plan.alreadyHealthy) {
|
||||
return {
|
||||
txCborHex: "",
|
||||
expectedTxHash: "",
|
||||
inputCount: plan.inputCount,
|
||||
totalLovelaceIn: plan.totalLovelaceIn,
|
||||
collateralOutputLovelace: plan.collateralOutput,
|
||||
workingOutputLovelace: plan.workingOutput,
|
||||
reason: plan.reason,
|
||||
alreadyHealthy: true,
|
||||
};
|
||||
}
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = lucid
|
||||
.newTx()
|
||||
.pay.ToAddress(walletKey.address, { lovelace: collateralLovelace })
|
||||
.pay.ToAddress(walletKey.address, { lovelace: plan.workingOutput })
|
||||
.addSignerKey(walletKey.pubKeyHash)
|
||||
// 500K floor matches commit 4f1ec9a's setMinFee bump across live builders.
|
||||
// The workingOutput already deducts a 2M fee reserve in planConsolidate;
|
||||
// lucid balances the actual fee out of that reserve via change.
|
||||
.setMinFee(500_000n);
|
||||
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs: realWalletUtxos,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new TxConstructionError("consolidate_wallet_build_failed", msg);
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
inputCount: plan.inputCount,
|
||||
totalLovelaceIn: plan.totalLovelaceIn,
|
||||
collateralOutputLovelace: collateralLovelace,
|
||||
workingOutputLovelace: plan.workingOutput,
|
||||
reason: plan.reason,
|
||||
alreadyHealthy: false,
|
||||
};
|
||||
}
|
||||
81
packages/shared/src/tx/wallet/planConsolidate.ts
Normal file
81
packages/shared/src/tx/wallet/planConsolidate.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
|
||||
*
|
||||
* Given a wallet's current UTxO set + a target collateral lovelace, decide
|
||||
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
|
||||
* {≥collateral, working}). The same downstream tx-builder handles
|
||||
* consolidate and split — only the "reason" tag differs for logging.
|
||||
*
|
||||
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
||||
*/
|
||||
|
||||
export interface UtxoLike {
|
||||
assets: { lovelace?: bigint } & Record<string, bigint | undefined>;
|
||||
}
|
||||
|
||||
export type ConsolidateReason = "consolidate" | "split" | "already-healthy";
|
||||
|
||||
export interface PlannedConsolidate {
|
||||
inputCount: number;
|
||||
totalLovelaceIn: bigint;
|
||||
collateralOutput: bigint;
|
||||
workingOutput: bigint;
|
||||
alreadyHealthy: boolean;
|
||||
reason: ConsolidateReason;
|
||||
}
|
||||
|
||||
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
||||
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
||||
const MIN_WORKING_LOVELACE = 1_000_000n;
|
||||
|
||||
export function planConsolidate(
|
||||
utxos: UtxoLike[],
|
||||
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
||||
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
||||
): PlannedConsolidate {
|
||||
if (utxos.length === 0) {
|
||||
throw new Error("wallet has no UTxOs — fund the address first");
|
||||
}
|
||||
|
||||
const totalLovelaceIn = utxos.reduce(
|
||||
(acc, u) => acc + (u.assets.lovelace ?? 0n),
|
||||
0n,
|
||||
);
|
||||
|
||||
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
||||
if (totalLovelaceIn < required) {
|
||||
throw new Error(
|
||||
`balance too low to consolidate: have ${totalLovelaceIn} lovelace, ` +
|
||||
`need at least ${required} (= ${collateralLovelace} collateral + ` +
|
||||
`${feeReserve} fee reserve + ${MIN_WORKING_LOVELACE} min working)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (utxos.length === 2) {
|
||||
const lov0 = utxos[0].assets.lovelace ?? 0n;
|
||||
const lov1 = utxos[1].assets.lovelace ?? 0n;
|
||||
const smaller = lov0 < lov1 ? lov0 : lov1;
|
||||
const larger = lov0 < lov1 ? lov1 : lov0;
|
||||
if (smaller >= collateralLovelace) {
|
||||
return {
|
||||
inputCount: 2,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: smaller,
|
||||
workingOutput: larger,
|
||||
alreadyHealthy: true,
|
||||
reason: "already-healthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
|
||||
|
||||
return {
|
||||
inputCount: utxos.length,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: collateralLovelace,
|
||||
workingOutput,
|
||||
alreadyHealthy: false,
|
||||
reason: utxos.length === 1 ? "split" : "consolidate",
|
||||
};
|
||||
}
|
||||
112
packages/shared/src/tx/wallet/runConsolidateWallet.ts
Normal file
112
packages/shared/src/tx/wallet/runConsolidateWallet.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* runConsolidateWallet.ts — orchestration layer for the consolidate-wallet
|
||||
* CLIs (supplier + buyer). Builds + submits the consolidate tx, awaits
|
||||
* on-chain confirmation, returns the resulting refs.
|
||||
*
|
||||
* Live-only (the consolidate builder is live-only).
|
||||
*/
|
||||
|
||||
import type { ChainProvider } from "../../chain/ChainProvider.js";
|
||||
import { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
import { TxConstructionError, type WalletKey } from "../types.js";
|
||||
import {
|
||||
buildConsolidateWalletTx,
|
||||
type ConsolidateWalletBuildResult,
|
||||
} from "./consolidateWallet.js";
|
||||
import type { ConsolidateReason } from "./planConsolidate.js";
|
||||
|
||||
export interface ConsolidateWalletFlowParams {
|
||||
chain: ChainProvider;
|
||||
walletKey: WalletKey;
|
||||
collateralLovelace: bigint;
|
||||
awaitTimeoutMs?: number;
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
export interface ConsolidateWalletFlowResult {
|
||||
/** Null when `reason === "already-healthy"` (no tx submitted). */
|
||||
txHash: string | null;
|
||||
collateralRef: string | null;
|
||||
workingRef: string | null;
|
||||
reason: ConsolidateReason;
|
||||
inputCount: number;
|
||||
totalLovelaceIn: bigint;
|
||||
collateralOutputLovelace: bigint;
|
||||
workingOutputLovelace: bigint;
|
||||
}
|
||||
|
||||
export async function runConsolidateWallet(
|
||||
params: ConsolidateWalletFlowParams,
|
||||
): Promise<ConsolidateWalletFlowResult> {
|
||||
const { chain, walletKey, collateralLovelace } = params;
|
||||
const writeLog =
|
||||
params.log ??
|
||||
((line: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(line);
|
||||
});
|
||||
const timeoutMs = params.awaitTimeoutMs ?? 120_000;
|
||||
|
||||
if (!(chain instanceof LiveOgmiosProvider)) {
|
||||
throw new TxConstructionError(
|
||||
"consolidate_wallet_requires_live_chain",
|
||||
"consolidate-wallet does not support mock mode — needs a real Ogmios endpoint",
|
||||
);
|
||||
}
|
||||
|
||||
writeLog(`consolidating wallet ${walletKey.address}`);
|
||||
writeLog(`target collateral output: ${collateralLovelace} lovelace`);
|
||||
|
||||
const built: ConsolidateWalletBuildResult = await buildConsolidateWalletTx({
|
||||
chain,
|
||||
walletKey,
|
||||
collateralLovelace,
|
||||
});
|
||||
|
||||
writeLog(
|
||||
`plan: ${built.reason} — ${built.inputCount} input(s), ` +
|
||||
`total ${built.totalLovelaceIn} lovelace → ` +
|
||||
`collateral ${built.collateralOutputLovelace} + working ${built.workingOutputLovelace}`,
|
||||
);
|
||||
|
||||
if (built.alreadyHealthy) {
|
||||
writeLog("wallet already healthy — no tx submitted");
|
||||
return {
|
||||
txHash: null,
|
||||
collateralRef: null,
|
||||
workingRef: null,
|
||||
reason: built.reason,
|
||||
inputCount: built.inputCount,
|
||||
totalLovelaceIn: built.totalLovelaceIn,
|
||||
collateralOutputLovelace: built.collateralOutputLovelace,
|
||||
workingOutputLovelace: built.workingOutputLovelace,
|
||||
};
|
||||
}
|
||||
|
||||
writeLog(`submitted tx ${built.expectedTxHash}`);
|
||||
writeLog("awaiting confirmation");
|
||||
|
||||
try {
|
||||
await chain.awaitTx(built.expectedTxHash, timeoutMs);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
reason.includes(built.expectedTxHash)
|
||||
? reason
|
||||
: `awaitTx failed for txHash ${built.expectedTxHash}: ${reason}`,
|
||||
);
|
||||
}
|
||||
|
||||
writeLog("confirmed");
|
||||
|
||||
return {
|
||||
txHash: built.expectedTxHash,
|
||||
collateralRef: `${built.expectedTxHash}#0`,
|
||||
workingRef: `${built.expectedTxHash}#1`,
|
||||
reason: built.reason,
|
||||
inputCount: built.inputCount,
|
||||
totalLovelaceIn: built.totalLovelaceIn,
|
||||
collateralOutputLovelace: built.collateralOutputLovelace,
|
||||
workingOutputLovelace: built.workingOutputLovelace,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
"tx:gen-keypair": "tsx src/cli/gen-keypair.ts",
|
||||
"tx:post-advert": "tsx src/cli/post-advert.ts",
|
||||
"tx:retire-advert": "tsx src/cli/retire-advert.ts",
|
||||
"tx:publish-reference-scripts": "tsx src/cli/publish-reference-scripts.ts"
|
||||
"tx:publish-reference-scripts": "tsx src/cli/publish-reference-scripts.ts",
|
||||
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/shared": "workspace:*",
|
||||
|
||||
276
supplier/src/cli/consolidate-wallet.ts
Normal file
276
supplier/src/cli/consolidate-wallet.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* consolidate-wallet.ts — CLI entry point for
|
||||
* `pnpm --filter @marketplace/supplier tx:consolidate-wallet`.
|
||||
*
|
||||
* Re-shapes the supplier wallet to {collateral UTxO (default 5 AP3X),
|
||||
* working UTxO (remainder)} so subsequent script-spend txs (Claim/Submit/
|
||||
* Accept/RetireAdvert) find a pure-AP3X collateral candidate.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @marketplace/supplier tx:consolidate-wallet [--dry-run] \
|
||||
* [--collateral-lovelace 5000000] [--await-timeout-ms 120000]
|
||||
*
|
||||
* Required env: SUPPLIER_PRIV_KEY_HEX, NETWORK_ID, OGMIOS_URL (when NETWORK_ID=1).
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import * as ed from "@noble/ed25519";
|
||||
import { blake2b } from "@noble/hashes/blake2b";
|
||||
import { bech32 } from "bech32";
|
||||
import { LiveOgmiosProvider, type ChainProvider } from "@marketplace/shared/chain";
|
||||
import type { WalletKey } from "@marketplace/shared/tx";
|
||||
import { runConsolidateWallet } from "@marketplace/shared/tx/server";
|
||||
|
||||
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
|
||||
const h = createHash("sha512");
|
||||
for (const m of messages) h.update(m);
|
||||
return new Uint8Array(h.digest());
|
||||
};
|
||||
|
||||
export type NetworkId = 0 | 1;
|
||||
|
||||
export interface CliConfig {
|
||||
ogmiosUrl: string;
|
||||
privKeyHex: string;
|
||||
networkId: NetworkId;
|
||||
collateralLovelace: bigint;
|
||||
awaitTimeoutMs: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
const HEX64_RE = /^[0-9a-f]{64}$/;
|
||||
const TESTNET_OGMIOS_DEFAULT = "https://ogmios.vector.testnet.apexfusion.org";
|
||||
const DEFAULT_AWAIT_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_COLLATERAL_LOVELACE = 5_000_000;
|
||||
|
||||
const VALUE_FLAGS = new Set<string>([
|
||||
"--ogmios-url",
|
||||
"--priv-key",
|
||||
"--collateral-lovelace",
|
||||
"--await-timeout-ms",
|
||||
]);
|
||||
const BOOL_FLAGS = new Set<string>(["--dry-run"]);
|
||||
|
||||
function requireEnv(env: Record<string, string | undefined>, name: string): string {
|
||||
const v = env[name];
|
||||
if (v === undefined || v === null || v === "") {
|
||||
throw new Error(`missing required env var: ${name}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function parseIntStrict(name: string, raw: string): number {
|
||||
if (!/^-?\d+$/.test(raw)) {
|
||||
throw new Error(`${name} must be an integer (got: ${raw})`);
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
||||
throw new Error(`${name} must be an integer (got: ${raw})`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
interface RawFlags {
|
||||
ogmiosUrl?: string;
|
||||
privKey?: string;
|
||||
collateralLovelace?: string;
|
||||
awaitTimeoutMs?: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseRawArgv(argv: string[]): RawFlags {
|
||||
const out: RawFlags = { dryRun: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
if (BOOL_FLAGS.has(tok)) {
|
||||
if (tok === "--dry-run") out.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (VALUE_FLAGS.has(tok)) {
|
||||
const val = argv[i + 1];
|
||||
if (val === undefined) throw new Error(`flag ${tok} requires a value`);
|
||||
switch (tok) {
|
||||
case "--ogmios-url":
|
||||
out.ogmiosUrl = val;
|
||||
break;
|
||||
case "--priv-key":
|
||||
out.privKey = val;
|
||||
break;
|
||||
case "--collateral-lovelace":
|
||||
out.collateralLovelace = val;
|
||||
break;
|
||||
case "--await-timeout-ms":
|
||||
out.awaitTimeoutMs = val;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown flag: ${tok}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseCliArgs(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): CliConfig {
|
||||
const flags = parseRawArgv(argv);
|
||||
|
||||
const networkIdRaw = requireEnv(env, "NETWORK_ID");
|
||||
if (networkIdRaw !== "0" && networkIdRaw !== "1") {
|
||||
throw new Error(`NETWORK_ID must be "0" or "1", got: ${networkIdRaw}`);
|
||||
}
|
||||
const networkId: NetworkId = networkIdRaw === "1" ? 1 : 0;
|
||||
|
||||
const privKeyHex = flags.privKey ?? requireEnv(env, "SUPPLIER_PRIV_KEY_HEX");
|
||||
if (!HEX64_RE.test(privKeyHex)) {
|
||||
throw new Error(
|
||||
"SUPPLIER_PRIV_KEY_HEX (or --priv-key) must be 64 lowercase hex chars",
|
||||
);
|
||||
}
|
||||
|
||||
let ogmiosUrl: string;
|
||||
if (flags.ogmiosUrl !== undefined) {
|
||||
ogmiosUrl = flags.ogmiosUrl;
|
||||
} else if (env.OGMIOS_URL !== undefined && env.OGMIOS_URL !== "") {
|
||||
ogmiosUrl = env.OGMIOS_URL;
|
||||
} else if (networkId === 0) {
|
||||
ogmiosUrl = TESTNET_OGMIOS_DEFAULT;
|
||||
} else {
|
||||
throw new Error("OGMIOS_URL is required when NETWORK_ID=1");
|
||||
}
|
||||
|
||||
const collateralLovelace = BigInt(
|
||||
flags.collateralLovelace === undefined
|
||||
? DEFAULT_COLLATERAL_LOVELACE
|
||||
: parseIntStrict("--collateral-lovelace", flags.collateralLovelace),
|
||||
);
|
||||
|
||||
const awaitTimeoutMs =
|
||||
flags.awaitTimeoutMs === undefined
|
||||
? DEFAULT_AWAIT_TIMEOUT_MS
|
||||
: parseIntStrict("--await-timeout-ms", flags.awaitTimeoutMs);
|
||||
|
||||
return {
|
||||
ogmiosUrl,
|
||||
privKeyHex,
|
||||
networkId,
|
||||
collateralLovelace,
|
||||
awaitTimeoutMs,
|
||||
dryRun: flags.dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
function deriveWalletKey(privHex: string, networkId: NetworkId): WalletKey {
|
||||
const priv = hexToBytes(privHex);
|
||||
const pub = ed.getPublicKey(priv);
|
||||
const pubHex = bytesToHex(pub);
|
||||
const pkh = blake2b(pub, { dkLen: 28 });
|
||||
const pkhHex = bytesToHex(pkh);
|
||||
const header = networkId === 0 ? 0x60 : 0x61;
|
||||
const payload = new Uint8Array(29);
|
||||
payload[0] = header;
|
||||
payload.set(pkh, 1);
|
||||
const words = bech32.toWords(payload);
|
||||
const hrp = networkId === 0 ? "addr_test" : "addr";
|
||||
const addr = bech32.encode(hrp, words, 1023);
|
||||
return {
|
||||
pubKeyHash: pkhHex,
|
||||
pubKeyHex: pubHex,
|
||||
privateKeyHex: privHex,
|
||||
address: addr,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<number> {
|
||||
let cfg: CliConfig;
|
||||
try {
|
||||
cfg = parseCliArgs(argv, env);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let walletKey: WalletKey;
|
||||
try {
|
||||
walletKey = deriveWalletKey(cfg.privKeyHex, cfg.networkId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: wallet derivation failed: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const chain: ChainProvider = new LiveOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
|
||||
|
||||
if (cfg.dryRun) {
|
||||
// Read-only pre-flight: query UTxOs and print the planned shape without
|
||||
// building or submitting a tx.
|
||||
try {
|
||||
const utxos = await chain.queryUtxosByAddress(walletKey.address);
|
||||
const total = utxos.reduce((acc, u) => acc + u.lovelace, 0n);
|
||||
process.stdout.write(
|
||||
`dry-run: wallet ${walletKey.address}\n` +
|
||||
` ${utxos.length} UTxO(s), total ${total} lovelace\n` +
|
||||
` target collateral: ${cfg.collateralLovelace}\n` +
|
||||
` planned working: ~${total - cfg.collateralLovelace - 2_000_000n} (after 2M fee reserve)\n`,
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: dry-run query failed: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runConsolidateWallet({
|
||||
chain,
|
||||
walletKey,
|
||||
collateralLovelace: cfg.collateralLovelace,
|
||||
awaitTimeoutMs: cfg.awaitTimeoutMs,
|
||||
log: (line) => process.stderr.write(`${line}\n`),
|
||||
});
|
||||
process.stdout.write(`REASON=${result.reason}\n`);
|
||||
if (result.txHash) {
|
||||
process.stdout.write(`TX_HASH=${result.txHash}\n`);
|
||||
process.stdout.write(`COLLATERAL_UTXO=${result.collateralRef}\n`);
|
||||
process.stdout.write(`WORKING_UTXO=${result.workingRef}\n`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1]?.endsWith("consolidate-wallet.ts") ||
|
||||
process.argv[1]?.endsWith("consolidate-wallet.js")
|
||||
) {
|
||||
main(process.argv.slice(2), process.env)
|
||||
.then((code) => process.exit(code))
|
||||
.catch((err) => {
|
||||
process.stderr.write(`consolidate-wallet: fatal: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
73
tests/unit/tx-assert-collateral.test.ts
Normal file
73
tests/unit/tx-assert-collateral.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* tx-assert-collateral.test.ts — unit tests for the assertCollateralCandidate
|
||||
* pre-check that fires from each script-spend builder.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { assertCollateralCandidate } from "../../packages/shared/src/tx/internal/liveCbor.js";
|
||||
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
|
||||
|
||||
type FakeLucidUtxo = { assets: Record<string, bigint> };
|
||||
|
||||
function lov(n: bigint): FakeLucidUtxo {
|
||||
return { assets: { lovelace: n } };
|
||||
}
|
||||
|
||||
describe("assertCollateralCandidate", () => {
|
||||
it("passes when one UTxO is ≥ 5 AP3X pure-AP3X", () => {
|
||||
const utxos = [lov(1_000_000n), lov(5_000_000n), lov(2_400_000n)];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(() => assertCollateralCandidate(utxos as any)).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes when one UTxO is much larger than 5 AP3X", () => {
|
||||
const utxos = [lov(1_000_000_000n)];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(() => assertCollateralCandidate(utxos as any)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when the wallet has no UTxO ≥ 5 AP3X", () => {
|
||||
const utxos = [
|
||||
lov(2_400_750n),
|
||||
lov(1_679_301n),
|
||||
lov(1_679_301n),
|
||||
lov(1_679_301n),
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let caught: unknown;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assertCollateralCandidate(utxos as any);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TxConstructionError);
|
||||
expect((caught as TxConstructionError).reason).toBe("collateral_required");
|
||||
expect((caught as TxConstructionError).message).toMatch(
|
||||
/tx:consolidate-wallet/,
|
||||
);
|
||||
expect((caught as TxConstructionError).message).toMatch(/4 UTxO/);
|
||||
expect((caught as TxConstructionError).message).toMatch(/2400750/);
|
||||
});
|
||||
|
||||
it("throws when the wallet is empty", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(() => assertCollateralCandidate([] as any)).toThrow(
|
||||
TxConstructionError,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips UTxOs that carry native assets even when ≥ 5 AP3X", () => {
|
||||
// On Vector L2 the operator wallets are pure-AP3X, but the safety net
|
||||
// catches a hypothetical UTxO with native tokens — lucid's collateral
|
||||
// selector rejects those.
|
||||
const utxos = [
|
||||
{ assets: { lovelace: 10_000_000n, ["d3.tok"]: 1n } },
|
||||
lov(1_000_000n),
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(() => assertCollateralCandidate(utxos as any)).toThrow(
|
||||
/collateral/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
95
tests/unit/tx-consolidate-wallet.test.ts
Normal file
95
tests/unit/tx-consolidate-wallet.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* tx-consolidate-wallet.test.ts — pure unit tests for the consolidate
|
||||
* planner. Drives planConsolidate directly without lucid / WASM.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
planConsolidate,
|
||||
DEFAULT_COLLATERAL_LOVELACE,
|
||||
DEFAULT_FEE_RESERVE,
|
||||
type UtxoLike,
|
||||
} from "../../packages/shared/src/tx/wallet/planConsolidate.js";
|
||||
|
||||
function u(lovelace: bigint): UtxoLike {
|
||||
return { assets: { lovelace } };
|
||||
}
|
||||
|
||||
describe("planConsolidate", () => {
|
||||
it("consolidates a fragmented wallet (8 UTxOs, max 2.4 AP3X)", () => {
|
||||
const utxos: UtxoLike[] = [
|
||||
u(2_400_750n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
];
|
||||
const total = utxos.reduce((s, x) => s + x.assets.lovelace, 0n);
|
||||
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(8);
|
||||
expect(plan.totalLovelaceIn).toBe(total);
|
||||
expect(plan.collateralOutput).toBe(DEFAULT_COLLATERAL_LOVELACE);
|
||||
expect(plan.workingOutput).toBe(
|
||||
total - DEFAULT_COLLATERAL_LOVELACE - DEFAULT_FEE_RESERVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("splits a single large UTxO into 2 outputs", () => {
|
||||
const utxos = [u(17_500_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("split");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(1);
|
||||
expect(plan.collateralOutput).toBe(DEFAULT_COLLATERAL_LOVELACE);
|
||||
expect(plan.workingOutput).toBe(
|
||||
17_500_000n - DEFAULT_COLLATERAL_LOVELACE - DEFAULT_FEE_RESERVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a wallet already in {collateral, working} shape", () => {
|
||||
const utxos = [u(5_000_000n), u(8_650_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("already-healthy");
|
||||
expect(plan.alreadyHealthy).toBe(true);
|
||||
expect(plan.collateralOutput).toBe(5_000_000n);
|
||||
expect(plan.workingOutput).toBe(8_650_000n);
|
||||
});
|
||||
|
||||
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => {
|
||||
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape.
|
||||
const utxos = [u(3_000_000n), u(15_000_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(2);
|
||||
});
|
||||
|
||||
it("throws on 0 UTxOs", () => {
|
||||
expect(() => planConsolidate([])).toThrow(/no UTxOs/i);
|
||||
});
|
||||
|
||||
it("throws on balance below threshold", () => {
|
||||
// 4 AP3X total — under 5 + 2 + 1 = 8 AP3X required minimum.
|
||||
const utxos = [u(2_000_000n), u(2_000_000n)];
|
||||
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
|
||||
});
|
||||
|
||||
it("respects custom collateralLovelace", () => {
|
||||
const utxos = [u(20_000_000n)];
|
||||
const plan = planConsolidate(utxos, 10_000_000n);
|
||||
|
||||
expect(plan.reason).toBe("split");
|
||||
expect(plan.collateralOutput).toBe(10_000_000n);
|
||||
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user