Compare commits
1 Commits
fix/wallet
...
fix/escrow
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c391b98af8 |
@@ -42,6 +42,7 @@ import { loadBlueprint } from "../blueprint.js";
|
|||||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||||
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
||||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||||
|
import { escrowLockFloor } from "../internal/minAdaFloor.js";
|
||||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
|
|
||||||
export interface PostChatEscrowParams {
|
export interface PostChatEscrowParams {
|
||||||
@@ -132,7 +133,7 @@ export async function buildPostChatEscrowTx(
|
|||||||
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||||
const promptHash = chatSessionPromptHash({ session_nonce });
|
const promptHash = chatSessionPromptHash({ session_nonce });
|
||||||
|
|
||||||
const totalLocked =
|
const economicTotal =
|
||||||
advertDatum.price_lovelace +
|
advertDatum.price_lovelace +
|
||||||
advertDatum.buyer_bond_lovelace +
|
advertDatum.buyer_bond_lovelace +
|
||||||
advertDatum.supplier_bond_lovelace;
|
advertDatum.supplier_bond_lovelace;
|
||||||
@@ -154,6 +155,11 @@ export async function buildPostChatEscrowTx(
|
|||||||
state: "Open",
|
state: "Open",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Lock enough that the escrow output satisfies min-ada in its LARGEST
|
||||||
|
// future state (Submitted) - value_equal on Claim/Submit forbids any
|
||||||
|
// later bump (min-ada floor).
|
||||||
|
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
|
||||||
|
|
||||||
// Live path: reuse the capability-agnostic chat builder (messages = []).
|
// Live path: reuse the capability-agnostic chat builder (messages = []).
|
||||||
// The on-chain commitment is the escrowDatum (carrying our session-init
|
// The on-chain commitment is the escrowDatum (carrying our session-init
|
||||||
// prompt_hash); messages are only used for the mock cbor body, which we omit.
|
// prompt_hash); messages are only used for the mock cbor body, which we omit.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { loadBlueprint } from "../blueprint.js";
|
|||||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||||
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
||||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||||
|
import { escrowLockFloor } from "../internal/minAdaFloor.js";
|
||||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
|
|
||||||
export interface PostEscrowParams {
|
export interface PostEscrowParams {
|
||||||
@@ -129,7 +130,7 @@ export async function buildPostEscrowTx(
|
|||||||
// supplier-side validation in M1-C can verify role/content/system integrity.
|
// supplier-side validation in M1-C can verify role/content/system integrity.
|
||||||
const promptHash = sha256Utf8Hex(canonicalize(messages));
|
const promptHash = sha256Utf8Hex(canonicalize(messages));
|
||||||
|
|
||||||
const totalLocked =
|
const economicTotal =
|
||||||
advertDatum.price_lovelace +
|
advertDatum.price_lovelace +
|
||||||
advertDatum.buyer_bond_lovelace +
|
advertDatum.buyer_bond_lovelace +
|
||||||
advertDatum.supplier_bond_lovelace;
|
advertDatum.supplier_bond_lovelace;
|
||||||
@@ -151,6 +152,11 @@ export async function buildPostEscrowTx(
|
|||||||
state: "Open",
|
state: "Open",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Lock enough that the escrow output satisfies min-ada in its LARGEST
|
||||||
|
// future state (Submitted) - value_equal on Claim/Submit forbids any
|
||||||
|
// later bump (min-ada floor).
|
||||||
|
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
|
||||||
|
|
||||||
// Live path: produce real Cardano CBOR via lucid-evolution.
|
// Live path: produce real Cardano CBOR via lucid-evolution.
|
||||||
if (detectCborBackend(chain) === "live") {
|
if (detectCborBackend(chain) === "live") {
|
||||||
const liveCborPath = "../internal/liveCbor.js";
|
const liveCborPath = "../internal/liveCbor.js";
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { loadBlueprint } from "../blueprint.js";
|
|||||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||||
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
||||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||||
|
import { escrowLockFloor } from "../internal/minAdaFloor.js";
|
||||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
|
|
||||||
/** TTS request envelope. The buyer-side hash and the supplier-side hash
|
/** TTS request envelope. The buyer-side hash and the supplier-side hash
|
||||||
@@ -149,7 +150,7 @@ export async function buildPostTtsEscrowTx(
|
|||||||
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||||
const promptHash = ttsPromptHash(request);
|
const promptHash = ttsPromptHash(request);
|
||||||
|
|
||||||
const totalLocked =
|
const economicTotal =
|
||||||
advertDatum.price_lovelace +
|
advertDatum.price_lovelace +
|
||||||
advertDatum.buyer_bond_lovelace +
|
advertDatum.buyer_bond_lovelace +
|
||||||
advertDatum.supplier_bond_lovelace;
|
advertDatum.supplier_bond_lovelace;
|
||||||
@@ -171,6 +172,11 @@ export async function buildPostTtsEscrowTx(
|
|||||||
state: "Open",
|
state: "Open",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Lock enough that the escrow output satisfies min-ada in its LARGEST
|
||||||
|
// future state (Submitted) - value_equal on Claim/Submit forbids any
|
||||||
|
// later bump (min-ada floor).
|
||||||
|
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
|
||||||
|
|
||||||
// Live path: reuse the chat builder. The live cbor path is already
|
// Live path: reuse the chat builder. The live cbor path is already
|
||||||
// capability-agnostic — `messages` is `void`-marked there. We pass an
|
// capability-agnostic — `messages` is `void`-marked there. We pass an
|
||||||
// empty array so the type checker is satisfied; the actual on-chain
|
// empty array so the type checker is satisfied; the actual on-chain
|
||||||
|
|||||||
67
packages/shared/src/tx/internal/minAdaFloor.ts
Normal file
67
packages/shared/src/tx/internal/minAdaFloor.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* minAdaFloor.ts — min-ada floor for the escrow's LARGEST future datum.
|
||||||
|
*
|
||||||
|
* The escrow validator requires value_equal(continuing, in) on Claim and
|
||||||
|
* Submit, so the lovelace locked at POST time must already satisfy min-ada
|
||||||
|
* for every datum the output will ever carry. The largest is the Submitted
|
||||||
|
* state: submitted_at None -> Some(POSIX ms) and result_receipt_hash
|
||||||
|
* None -> Some(32 bytes) add ~43 bytes over Open/Claimed. If the posted
|
||||||
|
* lovelace sits below that state's min-ada, lucid bumps the Submit
|
||||||
|
* continuing output to min-ada, value_equal fails, and the validator
|
||||||
|
* errors with no traces (the 2026-08-07 mainnet incident: the 33-byte
|
||||||
|
* "ocr.page.extract.chandra-ocr-2.v1" capability_id pushed the Submitted
|
||||||
|
* output 11,030 lovelace over a 2,200,000 lock; the 20-byte chat
|
||||||
|
* capability stayed 49,310 under, which is why only OCR Submits failed).
|
||||||
|
*
|
||||||
|
* escrowLockFloor() returns the lovelace to lock: the economic total
|
||||||
|
* (payment + both bonds), raised to the Submitted-state min-ada plus a
|
||||||
|
* safety margin when the total alone is too small.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EscrowDatum } from "../../cbor/types.js";
|
||||||
|
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
|
||||||
|
|
||||||
|
/** Conway/Babbage utxoCostPerByte on Vector mainnet (protocol params). */
|
||||||
|
const UTXO_COST_PER_BYTE = 4310n;
|
||||||
|
|
||||||
|
/** Ledger constant: min-ada = (160 + serialized output bytes) * costPerByte. */
|
||||||
|
const OUTPUT_OVERHEAD_BYTES = 160n;
|
||||||
|
|
||||||
|
/** Covers CBOR framing drift between this estimate and the ledger's exact
|
||||||
|
* serialization (address, coin width, datum wrapper). Generous on purpose:
|
||||||
|
* 50k lovelace = 0.05 AP3X, dwarfed by per-leg tx fees. */
|
||||||
|
const SAFETY_MARGIN_LOVELACE = 50_000n;
|
||||||
|
|
||||||
|
/** Serialized-output byte estimate for an enterprise script address output
|
||||||
|
* carrying `lovelace` and an inline datum of `datumBytes`. Mirrors the
|
||||||
|
* arithmetic validated against mainnet on 2026-08-07 (minada-calc). */
|
||||||
|
function estimateOutputBytes(datumBytes: number, lovelace: bigint): bigint {
|
||||||
|
const addr = 1n + 29n;
|
||||||
|
const coin = 1n + (lovelace > 0xffffffffn ? 8n : 4n);
|
||||||
|
const datumField = 1n + 2n + 3n + BigInt(datumBytes);
|
||||||
|
const mapOverhead = 3n;
|
||||||
|
return addr + coin + datumField + mapOverhead;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Min-ada for the escrow output when it carries `datum`. */
|
||||||
|
export function minAdaForEscrowDatum(datum: EscrowDatum, lovelace: bigint): bigint {
|
||||||
|
const datumBytes = encodeEscrowDatum(datum).length / 2;
|
||||||
|
const size = estimateOutputBytes(datumBytes, lovelace);
|
||||||
|
return (OUTPUT_OVERHEAD_BYTES + size) * UTXO_COST_PER_BYTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lovelace a POST must lock so the escrow output satisfies min-ada in
|
||||||
|
* every reachable state, including Submitted. `economicTotal` is
|
||||||
|
* payment + buyer_bond + supplier_bond. */
|
||||||
|
export function escrowLockFloor(openDatum: EscrowDatum, economicTotal: bigint): bigint {
|
||||||
|
const submittedShape: EscrowDatum = {
|
||||||
|
...openDatum,
|
||||||
|
// Representative worst-case Submit fields: a 13-digit POSIX ms stamp
|
||||||
|
// and a 32-byte receipt hash — sizes, not values, matter here.
|
||||||
|
submitted_at: openDatum.deliver_by,
|
||||||
|
result_receipt_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||||
|
state: "Submitted",
|
||||||
|
};
|
||||||
|
const floor = minAdaForEscrowDatum(submittedShape, economicTotal) + SAFETY_MARGIN_LOVELACE;
|
||||||
|
return floor > economicTotal ? floor : economicTotal;
|
||||||
|
}
|
||||||
@@ -2,22 +2,9 @@
|
|||||||
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
|
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
|
||||||
*
|
*
|
||||||
* Given a wallet's current UTxO set + a target collateral lovelace, decide
|
* Given a wallet's current UTxO set + a target collateral lovelace, decide
|
||||||
* whether the wallet needs reshaping or is already usable for script spends.
|
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
|
||||||
*
|
* {≥collateral, working}). The same downstream tx-builder handles
|
||||||
* Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
|
* consolidate and split — only the "reason" tag differs for logging.
|
||||||
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
|
|
||||||
* candidate lucid's setCollateral needs), AND
|
|
||||||
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
|
|
||||||
* spend money that is not the candidate), AND
|
|
||||||
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
|
|
||||||
*
|
|
||||||
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
|
|
||||||
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
|
|
||||||
* remainder as change), so the shape consolidation produced could never
|
|
||||||
* satisfy its own health check — the wallet-health ticker re-consolidated
|
|
||||||
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
|
|
||||||
* The health check also runs BEFORE the low-balance guard now: a wallet that
|
|
||||||
* can already transact must never be told "balance too low to consolidate".
|
|
||||||
*
|
*
|
||||||
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
||||||
*/
|
*/
|
||||||
@@ -39,24 +26,12 @@ export interface PlannedConsolidate {
|
|||||||
|
|
||||||
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
||||||
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
||||||
/** Reshape only when fragmentation passes this; receipt/change UTxOs
|
|
||||||
* accumulating between jobs are fine as long as a candidate exists. */
|
|
||||||
export const DEFAULT_MAX_UTXOS = 6;
|
|
||||||
const MIN_WORKING_LOVELACE = 1_000_000n;
|
const MIN_WORKING_LOVELACE = 1_000_000n;
|
||||||
|
|
||||||
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
|
|
||||||
function pureLovelace(u: UtxoLike): bigint | null {
|
|
||||||
for (const [unit, amount] of Object.entries(u.assets)) {
|
|
||||||
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
|
|
||||||
}
|
|
||||||
return u.assets.lovelace ?? 0n;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function planConsolidate(
|
export function planConsolidate(
|
||||||
utxos: UtxoLike[],
|
utxos: UtxoLike[],
|
||||||
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
||||||
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
||||||
maxUtxos: number = DEFAULT_MAX_UTXOS,
|
|
||||||
): PlannedConsolidate {
|
): PlannedConsolidate {
|
||||||
if (utxos.length === 0) {
|
if (utxos.length === 0) {
|
||||||
throw new Error("wallet has no UTxOs — fund the address first");
|
throw new Error("wallet has no UTxOs — fund the address first");
|
||||||
@@ -67,32 +42,6 @@ export function planConsolidate(
|
|||||||
0n,
|
0n,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Structural health check first — reshaping a usable wallet wastes a fee.
|
|
||||||
if (utxos.length <= maxUtxos) {
|
|
||||||
// Smallest qualifying candidate: leave larger UTxOs as working funds.
|
|
||||||
let candidate: bigint | null = null;
|
|
||||||
for (const u of utxos) {
|
|
||||||
const pure = pureLovelace(u);
|
|
||||||
if (pure !== null && pure >= collateralLovelace) {
|
|
||||||
if (candidate === null || pure < candidate) candidate = pure;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (candidate !== null) {
|
|
||||||
const workingOutside = totalLovelaceIn - candidate;
|
|
||||||
if (workingOutside >= MIN_WORKING_LOVELACE) {
|
|
||||||
return {
|
|
||||||
inputCount: utxos.length,
|
|
||||||
totalLovelaceIn,
|
|
||||||
collateralOutput: candidate,
|
|
||||||
workingOutput: workingOutside,
|
|
||||||
alreadyHealthy: true,
|
|
||||||
reason: "already-healthy",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reshape needed — only an actual reshape has a funds threshold.
|
|
||||||
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
||||||
if (totalLovelaceIn < required) {
|
if (totalLovelaceIn < required) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -102,6 +51,23 @@ export function planConsolidate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,26 +7,15 @@
|
|||||||
* and the lane stalls until an operator runs `tx:consolidate-wallet`.
|
* and the lane stalls until an operator runs `tx:consolidate-wallet`.
|
||||||
*
|
*
|
||||||
* Two layers of self-healing:
|
* Two layers of self-healing:
|
||||||
* startWalletHealthTicker — periodic background tick (every N ms). Runs
|
* startWalletHealthTicker — periodic background tick (every N ms). When
|
||||||
* runConsolidateWallet; planConsolidate's structural health predicate
|
* the supplier is idle, runs runConsolidateWallet. planConsolidate
|
||||||
* short-circuits without a tx when a collateral candidate exists and
|
* returns `already-healthy` cheaply for clean 2-UTxO wallets, so this
|
||||||
* fragmentation is bounded. A tick on a healthy wallet costs one UTxO
|
* is a no-op in steady state — no tx, no fee.
|
||||||
* query and nothing else.
|
|
||||||
* triggerOnFailureConsolidate — fire-and-forget consolidate from the
|
* triggerOnFailureConsolidate — fire-and-forget consolidate from the
|
||||||
* Claim-failure path. Debounced so back-to-back failures don't queue
|
* Claim-failure path. Debounced so back-to-back failures don't queue
|
||||||
* multiple consolidates. Returns 503 to the buyer for THIS request; the
|
* multiple consolidates. Returns 503 to the buyer for THIS request; the
|
||||||
* next buyer retry (~30-60s later) finds the wallet healthy.
|
* next buyer retry (~30-60s later) finds the wallet healthy.
|
||||||
*
|
*
|
||||||
* F7 guards (2026-08-08 incident: a non-idempotent health check made every
|
|
||||||
* tick consolidate, burning 0.5 AP3X per 45s until the wallet hit the
|
|
||||||
* floor — 116 AP3X in one soak). Two independent circuit breakers protect
|
|
||||||
* against ANY future non-convergence:
|
|
||||||
* - consecutive guard: 3 consolidations in a row without ever reaching
|
|
||||||
* `already-healthy` halts wallet-health for this process.
|
|
||||||
* - budget guard: more than 6 consolidations inside 24h halts it too.
|
|
||||||
* A halt logs loudly once; consolidate manually and restart the supplier
|
|
||||||
* to re-arm.
|
|
||||||
*
|
|
||||||
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
|
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
|
||||||
* inside state.walletMutex — the same mutex that serializes Claims/Submits —
|
* inside state.walletMutex — the same mutex that serializes Claims/Submits —
|
||||||
* queueing behind any in-flight chain op instead of skipping. Session slots
|
* queueing behind any in-flight chain op instead of skipping. Session slots
|
||||||
@@ -74,29 +63,6 @@ export interface WalletHealthTicker {
|
|||||||
const inFlight = new WeakSet<SupplierState>();
|
const inFlight = new WeakSet<SupplierState>();
|
||||||
const lastOnFailureAt = new WeakMap<SupplierState, number>();
|
const lastOnFailureAt = new WeakMap<SupplierState, number>();
|
||||||
|
|
||||||
// ── F7 circuit breakers ──────────────────────────────────────────────
|
|
||||||
const MAX_CONSECUTIVE_CONSOLIDATIONS = 3;
|
|
||||||
const MAX_CONSOLIDATIONS_PER_WINDOW = 6;
|
|
||||||
const BUDGET_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
interface GuardCounters {
|
|
||||||
consecutive: number;
|
|
||||||
windowStart: number;
|
|
||||||
windowCount: number;
|
|
||||||
halted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const guardCounters = new WeakMap<SupplierState, GuardCounters>();
|
|
||||||
|
|
||||||
function guardsFor(state: SupplierState): GuardCounters {
|
|
||||||
let g = guardCounters.get(state);
|
|
||||||
if (!g) {
|
|
||||||
g = { consecutive: 0, windowStart: Date.now(), windowCount: 0, halted: false };
|
|
||||||
guardCounters.set(state, g);
|
|
||||||
}
|
|
||||||
return g;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultLog(line: string): void {
|
function defaultLog(line: string): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[wallet-health] ${line}`);
|
console.log(`[wallet-health] ${line}`);
|
||||||
@@ -107,8 +73,6 @@ async function consolidateOnce(
|
|||||||
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
|
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { state } = deps;
|
const { state } = deps;
|
||||||
const guards = guardsFor(state);
|
|
||||||
if (guards.halted) return; // the halt already logged loudly, once
|
|
||||||
if (inFlight.has(state)) {
|
if (inFlight.has(state)) {
|
||||||
opts.log("skip: another consolidate already in flight");
|
opts.log("skip: another consolidate already in flight");
|
||||||
return;
|
return;
|
||||||
@@ -128,36 +92,11 @@ async function consolidateOnce(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (result.reason === "already-healthy") {
|
if (result.reason === "already-healthy") {
|
||||||
guards.consecutive = 0;
|
|
||||||
opts.log("already-healthy");
|
opts.log("already-healthy");
|
||||||
} else {
|
} else {
|
||||||
const now = Date.now();
|
|
||||||
if (now - guards.windowStart > BUDGET_WINDOW_MS) {
|
|
||||||
guards.windowStart = now;
|
|
||||||
guards.windowCount = 0;
|
|
||||||
}
|
|
||||||
guards.consecutive += 1;
|
|
||||||
guards.windowCount += 1;
|
|
||||||
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
|
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
|
||||||
if (guards.consecutive >= MAX_CONSECUTIVE_CONSOLIDATIONS) {
|
|
||||||
guards.halted = true;
|
|
||||||
opts.log(
|
|
||||||
`HALT: ${guards.consecutive} consecutive consolidations without reaching a healthy shape — ` +
|
|
||||||
`wallet-health stopped to protect funds (F7 guard). Investigate planConsolidate vs the ` +
|
|
||||||
`builder's output shape, consolidate manually, restart the supplier to re-arm.`,
|
|
||||||
);
|
|
||||||
} else if (guards.windowCount >= MAX_CONSOLIDATIONS_PER_WINDOW) {
|
|
||||||
guards.halted = true;
|
|
||||||
opts.log(
|
|
||||||
`HALT: consolidation budget exhausted (${guards.windowCount} in 24h) — wallet-health ` +
|
|
||||||
`stopped to protect funds (F7 guard). Restart the supplier to re-arm.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Neither healthy nor consolidated (e.g. balance too low): no fee was
|
|
||||||
// spent, so the guards don't move — but nothing converged either, so
|
|
||||||
// the consecutive counter is NOT reset.
|
|
||||||
opts.log(`consolidate failed: ${(err as Error).message}`);
|
opts.log(`consolidate failed: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.delete(state);
|
inFlight.delete(state);
|
||||||
@@ -204,9 +143,8 @@ export function triggerOnFailureConsolidate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-only: reset the in-flight + debounce + guard maps between tests. */
|
/** Test-only: reset the in-flight + debounce maps between tests. */
|
||||||
export function _resetWalletHealthForTests(state: SupplierState): void {
|
export function _resetWalletHealthForTests(state: SupplierState): void {
|
||||||
inFlight.delete(state);
|
inFlight.delete(state);
|
||||||
lastOnFailureAt.delete(state);
|
lastOnFailureAt.delete(state);
|
||||||
guardCounters.delete(state);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,115 +242,3 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
|
|||||||
expect(state.snapshot().status).toBe("working");
|
expect(state.snapshot().status).toBe("working");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("walletHealth — F7 circuit breakers", () => {
|
|
||||||
let state: SupplierState;
|
|
||||||
let logLines: string[];
|
|
||||||
let log: (line: string) => void;
|
|
||||||
let consolidate: ReturnType<typeof vi.fn> & ConsolidateFn;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
state = new SupplierState();
|
|
||||||
_resetWalletHealthForTests(state);
|
|
||||||
logLines = [];
|
|
||||||
log = (line) => logLines.push(line);
|
|
||||||
consolidate = vi.fn() as never;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("halts after 3 consecutive consolidations that never reach healthy", async () => {
|
|
||||||
// The 2026-08-07 pump: every tick consolidates, none lands healthy.
|
|
||||||
consolidate.mockResolvedValue(consolidatedResult);
|
|
||||||
|
|
||||||
const ticker = startWalletHealthTicker(
|
|
||||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
||||||
{ intervalMs: 1_000, log },
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(3_000);
|
|
||||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(3));
|
|
||||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
|
||||||
|
|
||||||
// Two more ticks: the breaker holds, no further txs.
|
|
||||||
await vi.advanceTimersByTimeAsync(2_000);
|
|
||||||
expect(consolidate).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
ticker.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an already-healthy result resets the consecutive counter", async () => {
|
|
||||||
consolidate
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValueOnce(healthyResult)
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValue(healthyResult);
|
|
||||||
|
|
||||||
const ticker = startWalletHealthTicker(
|
|
||||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
||||||
{ intervalMs: 1_000, log },
|
|
||||||
);
|
|
||||||
|
|
||||||
// 6 ticks: c,c,h,c,c,h — never 3 consecutive, so never halted.
|
|
||||||
await vi.advanceTimersByTimeAsync(6_000);
|
|
||||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(6));
|
|
||||||
expect(logLines.some((l) => l.includes("HALT"))).toBe(false);
|
|
||||||
|
|
||||||
ticker.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("halts when the 24h consolidation budget is exhausted", async () => {
|
|
||||||
// Interleave healthy results so the consecutive guard never fires:
|
|
||||||
// c,c,h,c,c,h,c,c -> 6 consolidations inside the window at tick 8.
|
|
||||||
consolidate
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValueOnce(healthyResult)
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValueOnce(healthyResult)
|
|
||||||
.mockResolvedValue(consolidatedResult);
|
|
||||||
|
|
||||||
const ticker = startWalletHealthTicker(
|
|
||||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
||||||
{ intervalMs: 1_000, log },
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(8_000);
|
|
||||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(8));
|
|
||||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("budget"))).toBe(true);
|
|
||||||
|
|
||||||
// Breaker holds on subsequent ticks.
|
|
||||||
await vi.advanceTimersByTimeAsync(2_000);
|
|
||||||
expect(consolidate).toHaveBeenCalledTimes(8);
|
|
||||||
|
|
||||||
ticker.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("failed consolidations neither trip nor reset the breakers", async () => {
|
|
||||||
// Errors spend no fee: they must not count as consolidations, but they
|
|
||||||
// must not clear the consecutive count either.
|
|
||||||
consolidate
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockResolvedValueOnce(consolidatedResult)
|
|
||||||
.mockRejectedValueOnce(new Error("balance too low to consolidate"))
|
|
||||||
.mockResolvedValue(consolidatedResult);
|
|
||||||
|
|
||||||
const ticker = startWalletHealthTicker(
|
|
||||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
||||||
{ intervalMs: 1_000, log },
|
|
||||||
);
|
|
||||||
|
|
||||||
// c,c,error,c -> the 4th call is the 3rd consecutive consolidation.
|
|
||||||
await vi.advanceTimersByTimeAsync(4_000);
|
|
||||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(4));
|
|
||||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
|
||||||
|
|
||||||
ticker.stop();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -64,17 +64,14 @@ describe("planConsolidate", () => {
|
|||||||
expect(plan.workingOutput).toBe(8_650_000n);
|
expect(plan.workingOutput).toBe(8_650_000n);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a 2-UTxO wallet where the LARGER is the collateral candidate", () => {
|
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => {
|
||||||
// {3, 15}: the 15 qualifies as a pure >=5 candidate and the 3 covers
|
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape.
|
||||||
// fees — this wallet can run script txs as-is. The old exact-2-slot
|
|
||||||
// convention reshaped it for nothing (0.5 AP3X per pass).
|
|
||||||
const utxos = [u(3_000_000n), u(15_000_000n)];
|
const utxos = [u(3_000_000n), u(15_000_000n)];
|
||||||
const plan = planConsolidate(utxos);
|
const plan = planConsolidate(utxos);
|
||||||
|
|
||||||
expect(plan.reason).toBe("already-healthy");
|
expect(plan.reason).toBe("consolidate");
|
||||||
expect(plan.alreadyHealthy).toBe(true);
|
expect(plan.alreadyHealthy).toBe(false);
|
||||||
expect(plan.collateralOutput).toBe(15_000_000n);
|
expect(plan.inputCount).toBe(2);
|
||||||
expect(plan.workingOutput).toBe(3_000_000n);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on 0 UTxOs", () => {
|
it("throws on 0 UTxOs", () => {
|
||||||
@@ -96,93 +93,3 @@ describe("planConsolidate", () => {
|
|||||||
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
|
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("planConsolidate — structural health predicate (F7)", () => {
|
|
||||||
// The tx buildConsolidateWalletTx actually lands: {collateral 5.0,
|
|
||||||
// working = total - 5.0 - 2.0 reserve, change = reserve - fee}. The fee
|
|
||||||
// observed on mainnet is exactly 501,628, so the change is 1,498,372.
|
|
||||||
const MAINNET_CONSOLIDATE_FEE = 501_628n;
|
|
||||||
|
|
||||||
it("REGRESSION: the exact shape consolidation produces is healthy", () => {
|
|
||||||
// This is the loop that burned 116 AP3X in the 2026-08-07 soak: the
|
|
||||||
// planner called its own output shape unhealthy and re-consolidated
|
|
||||||
// every 45s tick. Feed it precisely what the builder lands.
|
|
||||||
const utxos = [
|
|
||||||
u(5_000_000n),
|
|
||||||
u(91_000_000n),
|
|
||||||
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE), // 1,498,372 change
|
|
||||||
];
|
|
||||||
const plan = planConsolidate(utxos);
|
|
||||||
|
|
||||||
expect(plan.reason).toBe("already-healthy");
|
|
||||||
expect(plan.alreadyHealthy).toBe(true);
|
|
||||||
expect(plan.collateralOutput).toBe(5_000_000n);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent: applying the builder's output model to any plan yields healthy", () => {
|
|
||||||
const fragmented = [u(2_400_750n), u(4_100_000n), u(1_679_301n), u(1_679_301n)];
|
|
||||||
const plan = planConsolidate(fragmented);
|
|
||||||
expect(plan.alreadyHealthy).toBe(false);
|
|
||||||
|
|
||||||
// Model of what buildConsolidateWalletTx lands for this plan.
|
|
||||||
const afterConsolidation = [
|
|
||||||
u(plan.collateralOutput),
|
|
||||||
u(plan.workingOutput),
|
|
||||||
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE),
|
|
||||||
];
|
|
||||||
const replan = planConsolidate(afterConsolidation);
|
|
||||||
|
|
||||||
expect(replan.reason).toBe("already-healthy");
|
|
||||||
expect(replan.alreadyHealthy).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still consolidates when fragmentation exceeds the max UTxO bound", () => {
|
|
||||||
// Candidate present, but 7 UTxOs > DEFAULT_MAX_UTXOS (6).
|
|
||||||
const utxos = [
|
|
||||||
u(5_000_000n), u(1_100_000n), u(1_100_000n), u(1_100_000n),
|
|
||||||
u(1_100_000n), u(1_100_000n), u(1_100_000n),
|
|
||||||
];
|
|
||||||
const plan = planConsolidate(utxos);
|
|
||||||
expect(plan.reason).toBe("consolidate");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still consolidates when no pure UTxO reaches the collateral threshold", () => {
|
|
||||||
// The 2026-08-07 batch-stall shape: fragments only, max 4,498,372.
|
|
||||||
const utxos = [u(4_498_372n), u(3_098_372n), u(1_498_372n)];
|
|
||||||
const plan = planConsolidate(utxos);
|
|
||||||
expect(plan.reason).toBe("consolidate");
|
|
||||||
expect(plan.alreadyHealthy).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a UTxO 1,628 lovelace short of collateral is not a candidate", () => {
|
|
||||||
// Seen live: 4,998,372 (= 5.0 minus a 501,628 fee twice-shuffled).
|
|
||||||
const utxos = [u(4_998_372n), u(9_000_000n - 4_998_372n)];
|
|
||||||
const plan = planConsolidate(utxos);
|
|
||||||
expect(plan.reason).toBe("consolidate");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a UTxO carrying native assets is not a collateral candidate", () => {
|
|
||||||
const withToken: UtxoLike = {
|
|
||||||
assets: { lovelace: 6_000_000n, "deadbeef.72737456": 1n },
|
|
||||||
};
|
|
||||||
const utxos = [withToken, u(4_000_000n)];
|
|
||||||
const plan = planConsolidate(utxos);
|
|
||||||
expect(plan.reason).toBe("consolidate");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("healthy shape below the reshape threshold is healthy, not an error", () => {
|
|
||||||
// {5.0, 2.5} = 7.5 total: under the 8.0 reshape minimum but perfectly
|
|
||||||
// able to run script txs. The old order threw "balance too low" here
|
|
||||||
// on every tick (1,744 log lines in the soak).
|
|
||||||
const utxos = [u(5_000_000n), u(2_500_000n)];
|
|
||||||
const plan = planConsolidate(utxos);
|
|
||||||
expect(plan.reason).toBe("already-healthy");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("candidate with only dust outside it needs a reshape it cannot afford", () => {
|
|
||||||
// {5.0, 0.4}: no working money outside the candidate, and 5.4 total is
|
|
||||||
// below the reshape minimum — loud error, funds needed.
|
|
||||||
const utxos = [u(5_000_000n), u(400_000n)];
|
|
||||||
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
84
tests/unit/tx-minada-floor.test.ts
Normal file
84
tests/unit/tx-minada-floor.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* tx-minada-floor.test.ts — the escrow min-ada floor.
|
||||||
|
*
|
||||||
|
* The escrow output locks payment + both bonds and the validator requires
|
||||||
|
* exact value preservation (value_equal) on Claim and Submit. Submit grows
|
||||||
|
* the inline datum (submitted_at None->Some, result_receipt_hash None->Some
|
||||||
|
* 32 bytes), so the lovelace locked at POST time must already cover min-ada
|
||||||
|
* for the Submitted state. A capability_id long enough to push the
|
||||||
|
* Submitted output's min-ada past the economic total made lucid bump the
|
||||||
|
* continuing output, value_equal failed, and the validator errored with no
|
||||||
|
* traces — so escrows with short capability ids settled and long ones never
|
||||||
|
* could. escrowLockFloor closes that by locking the max of the economic
|
||||||
|
* total and the Submitted-state min-ada plus a margin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
escrowLockFloor,
|
||||||
|
minAdaForEscrowDatum,
|
||||||
|
} from "../../packages/shared/src/tx/internal/minAdaFloor.js";
|
||||||
|
import type { EscrowDatum } from "../../packages/shared/src/cbor/types.js";
|
||||||
|
|
||||||
|
function openDatum(capabilityId: string): EscrowDatum {
|
||||||
|
return {
|
||||||
|
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
|
||||||
|
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
|
||||||
|
advert_ref: { txHash: "a6".repeat(32), index: 0 },
|
||||||
|
capability_id: capabilityId,
|
||||||
|
request_spec_hash: "1b".repeat(32),
|
||||||
|
prompt_hash: "23".repeat(32),
|
||||||
|
payment_lovelace: 200_000n,
|
||||||
|
buyer_bond_lovelace: 1_000_000n,
|
||||||
|
supplier_bond_lovelace: 1_000_000n,
|
||||||
|
deliver_by: 1_786_105_955_257,
|
||||||
|
posted_at: 1_786_105_625_257,
|
||||||
|
submitted_at: null,
|
||||||
|
result_receipt_hash: null,
|
||||||
|
state: "Open",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ECONOMIC_TOTAL = 2_200_000n; // 0.2 payment + 1.0 + 1.0 bonds
|
||||||
|
|
||||||
|
describe("escrowLockFloor", () => {
|
||||||
|
it("barely lifts a short-capability escrow (margin only, no capability-driven bump)", () => {
|
||||||
|
// The 20-byte chat capability's Submitted-state min-ada is ~2.15M, under
|
||||||
|
// the 2.2M economic total; only the safety margin nudges the lock, so it
|
||||||
|
// stays within a fraction of a percent of payment + bonds.
|
||||||
|
const datum = openDatum("llm.text.generate.v1");
|
||||||
|
const floor = escrowLockFloor(datum, ECONOMIC_TOTAL);
|
||||||
|
expect(floor).toBeGreaterThanOrEqual(ECONOMIC_TOTAL);
|
||||||
|
expect(floor).toBeLessThan(ECONOMIC_TOTAL + 100_000n); // << the OCR lift
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises a long-capability escrow above the economic total", () => {
|
||||||
|
// The 33-byte OCR capability that could not settle: its Submitted-state
|
||||||
|
// output needs more than 2.2M, so the floor must lift the lock.
|
||||||
|
const datum = openDatum("ocr.page.extract.chandra-ocr-2.v1");
|
||||||
|
const floor = escrowLockFloor(datum, ECONOMIC_TOTAL);
|
||||||
|
expect(floor).toBeGreaterThan(ECONOMIC_TOTAL);
|
||||||
|
// The locked value must satisfy the Submitted-state min-ada it is sized for.
|
||||||
|
const submitted: EscrowDatum = {
|
||||||
|
...datum,
|
||||||
|
submitted_at: datum.deliver_by,
|
||||||
|
result_receipt_hash: "ff".repeat(32),
|
||||||
|
state: "Submitted",
|
||||||
|
};
|
||||||
|
expect(floor).toBeGreaterThanOrEqual(minAdaForEscrowDatum(submitted, floor));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns less than the economic total", () => {
|
||||||
|
for (const cap of ["a", "x".repeat(64), "tts.speak.v1"]) {
|
||||||
|
expect(escrowLockFloor(openDatum(cap), ECONOMIC_TOTAL)).toBeGreaterThanOrEqual(
|
||||||
|
ECONOMIC_TOTAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the floor is monotonic in capability-id length", () => {
|
||||||
|
const short = escrowLockFloor(openDatum("a.b.v1"), ECONOMIC_TOTAL);
|
||||||
|
const long = escrowLockFloor(openDatum("a.very.long.capability.id.that.keeps.going.v1"), ECONOMIC_TOTAL);
|
||||||
|
expect(long).toBeGreaterThanOrEqual(short);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user