"use strict";
/**
 * Wallet worker thread entry.
 *
 * Lives in its OWN Node `worker_threads` worker so the Midnight wallet SDK's
 * Effect.ts Fiber scheduler — which monopolises the microtask queue while a
 * chain sync is running — only blocks THIS thread's event loop. The main
 * cds-serve thread stays responsive for OData requests and CAP DB writes.
 *
 * Communication: per-call `MessageChannel`. Main thread posts
 *   { kind: 'rpc', method, args, port: MessagePort }
 * and the worker replies on `port` with
 *   { ok: true, result } | { ok: false, error: string }
 *
 * Push events (worker → main, on `parentPort`):
 *   - { kind: 'state-save', sessionId, sdkVersion, blobs }
 *     emitted ~every 30 s while a facade is active so the main thread can
 *     persist via standard `cds.connect.to('db').run(...)`.
 *   - { kind: 'log', level, message }
 *     surfaces worker-side console.log/warn lines into the main thread's
 *     unified log stream.
 *
 * Surface: init / waitForSyncedState / serializeState / evict, plus
 * balanceUnboundTransaction / submitTransaction /
 * registerNightUtxosForDustGeneration for the deploy path.
 */
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const node_worker_threads_1 = require("node:worker_threads");
const node_path_1 = __importDefault(require("node:path"));
const node_url_1 = require("node:url");
const format_error_1 = require("../utils/format-error");
const contract_witnesses_1 = require("../submission/contract-witnesses");
const wallet_hd_1 = require("../utils/wallet-hd");
const facades = new Map();
// ---- Logging back to main thread ------------------------------------------
function log(level, message) {
    node_worker_threads_1.parentPort?.postMessage({ kind: 'log', level, message });
}
// ---- SDK loaders (dynamic ESM imports, same pattern as sdk-loader.ts) ----
let cachedLedger;
let cachedWallet;
let cachedFacadeSdk;
let cachedContractsSdk;
let cachedAddressFormat;
async function loadAddressFormat() {
    if (cachedAddressFormat)
        return cachedAddressFormat;
    cachedAddressFormat = await import('@midnight-ntwrk/wallet-sdk-address-format');
    return cachedAddressFormat;
}
async function loadSdk() {
    if (!cachedLedger) {
        cachedLedger = await import('@midnight-ntwrk/ledger-v8');
    }
    if (!cachedWallet) {
        const [shielded, unshielded, dust, abstractions] = await Promise.all([
            import('@midnight-ntwrk/wallet-sdk-shielded'),
            import('@midnight-ntwrk/wallet-sdk-unshielded-wallet'),
            import('@midnight-ntwrk/wallet-sdk-dust-wallet'),
            import('@midnight-ntwrk/wallet-sdk-abstractions')
        ]);
        cachedWallet = { shielded, unshielded, dust, abstractions };
    }
    if (!cachedFacadeSdk) {
        const [facade, networkId] = await Promise.all([
            import('@midnight-ntwrk/wallet-sdk-facade'),
            import('@midnight-ntwrk/midnight-js-network-id')
        ]);
        cachedFacadeSdk = { facade, networkId };
    }
    return {
        ledger: cachedLedger,
        shielded: cachedWallet.shielded,
        unshielded: cachedWallet.unshielded,
        dust: cachedWallet.dust,
        abstractions: cachedWallet.abstractions,
        facade: cachedFacadeSdk.facade,
        networkId: cachedFacadeSdk.networkId
    };
}
let lastNetworkId;
async function ensureNetworkId(networkId, sdk) {
    if (lastNetworkId === networkId)
        return;
    sdk.networkId.setNetworkId(networkId);
    lastNetworkId = networkId;
}
/**
 * SDK packages needed for contract deploy/call (Phase 2b). Loaded lazily on
 * the first deploy/call so the worker startup cost only covers the wallet
 * sync surface.
 */
async function loadContractsSdk() {
    if (cachedContractsSdk)
        return cachedContractsSdk;
    const [contracts, indexer, proof, zk, compactJs] = await Promise.all([
        import('@midnight-ntwrk/midnight-js-contracts'),
        import('@midnight-ntwrk/midnight-js-indexer-public-data-provider'),
        import('@midnight-ntwrk/midnight-js-http-client-proof-provider'),
        import('@midnight-ntwrk/midnight-js-node-zk-config-provider'),
        import('@midnight-ntwrk/compact-js')
    ]);
    cachedContractsSdk = { contracts, indexer, proof, zk, compactJs };
    return cachedContractsSdk;
}
const contractScaffolds = new Map();
async function getContractScaffold(name, registration) {
    const cached = contractScaffolds.get(name);
    if (cached)
        return cached;
    const importSpec = node_path_1.default.isAbsolute(registration.artifactPath)
        ? (0, node_url_1.pathToFileURL)(registration.artifactPath).href
        : registration.artifactPath;
    const mod = await import(importSpec);
    const contractClass = mod.Contract ?? mod.default ?? mod;
    const scaffold = { contractClass };
    contractScaffolds.set(name, scaffold);
    return scaffold;
}
/**
 * Builds a CompiledContract for the given registered contract. If the
 * contract declares no witnesses, supplies vacant ones (counter). Otherwise
 * looks up the witness factory and feeds it the FacadeEntry's
 * attestationSecret (AttestationVault).
 *
 * Witnesses bind to a Compact Contract instance for the lifetime of its use,
 * so we must build them fresh per call — different sessions yield different
 * attester ids.
 */
async function getOrCompileContract(name, registration, entry, witnessValues) {
    const { contractClass } = await getContractScaffold(name, registration);
    const { compactJs } = await loadContractsSdk();
    const CompiledContract = compactJs.CompiledContract ?? compactJs.effect?.CompiledContract;
    if (!CompiledContract?.make) {
        throw new Error(`CompiledContract.make not found in @midnight-ntwrk/compact-js exports; got keys: ${Object.keys(compactJs).join(',')}`);
    }
    const witnessFactory = (0, contract_witnesses_1.getContractWitnessFactory)(name);
    const witnessStep = witnessFactory
        ? CompiledContract.withWitnesses(witnessFactory({ attestationSecret: entry.attestationSecret, witnessValues }))
        : CompiledContract.withVacantWitnesses;
    return CompiledContract.make(name, contractClass).pipe(witnessStep, CompiledContract.withCompiledFileAssets(registration.zkConfigPath));
}
// ---- Worker-side provider construction (Phase 2b) -------------------------
async function buildWorkerContractProviders(args) {
    // `ws` is CJS — Node 22 worker_threads can `require` it freely.
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    const WebSocket = require('ws');
    const { indexer, proof, zk } = await loadContractsSdk();
    const zkConfigProvider = new zk.NodeZkConfigProvider(args.zkConfigPath);
    const publicDataProvider = indexer.indexerPublicDataProvider(args.indexerHttpUrl, args.indexerWsUrl, WebSocket);
    const proofProvider = proof.httpClientProofProvider(args.proofServerUrl, zkConfigProvider);
    return { publicDataProvider, zkConfigProvider, proofProvider };
}
/**
 * Adapts a worker-side facade into the SDK's WalletProvider & MidnightProvider
 * shape. balanceTx routes through balanceUnboundTransaction → finalizeRecipe
 * (matches the main-thread wallet-material-factory adapter pre-Phase-2b).
 */
// Upper bound for the pre-balance sync wait. Long enough to absorb a normal
// tip catch-up between submissions, short enough that a stalled indexer
// subscription fails the job promptly instead of hanging. Env-overridable.
const BALANCE_SYNC_TIMEOUT_MS = Number(process.env.NIGHTGATE_BALANCE_SYNC_TIMEOUT_MS || 180_000);
/**
 * `facade.waitForSyncedState()` raced against a timeout. Resolves as soon as
 * the wallet is synced (instant on the prewarmed path); rejects with a clear
 * error if the sync hasn't latched within `timeoutMs` — typically a dropped,
 * non-retried indexer graphql-ws subscription.
 */
async function waitForSyncedStateBounded(facade, timeoutMs) {
    let timer;
    const timeout = new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error(`waitForSyncedState timed out after ${timeoutMs}ms (indexer subscription may have stalled)`)), timeoutMs);
    });
    try {
        await Promise.race([facade.waitForSyncedState(), timeout]);
    }
    finally {
        if (timer)
            clearTimeout(timer);
    }
}
function buildWorkerWalletProvider(entry) {
    return {
        getCoinPublicKey() { return entry.zswapKeys.coinPublicKey; },
        getEncryptionPublicKey() { return entry.zswapKeys.encryptionPublicKey; },
        async balanceTx(tx, ttl) {
            // Robustness: block until the wallet is synced to the chain tip
            // before balancing. The prewarm job (connectWalletForSigning) also
            // waits, but a caller that submits WITHOUT prewarming — or after the
            // facade has drifted — would otherwise balance against stale
            // (restored/partial) dust state, and the node rejects the tx with
            // `1010 Invalid Transaction: Custom error: 170` (dust validity
            // window: ctime + grace < tblock). waitForSyncedState is a no-op
            // once synced, so this is cheap on the common (prewarmed) path.
            //
            // BOUNDED: the SDK's graphql-ws subscription has no auto-retry
            // (shouldRetry:()=>false), so a dropped indexer subscription would
            // make waitForSyncedState hang forever. Race it against a timeout so
            // a stalled sync surfaces as a clear, classifiable submission error
            // instead of an indefinite hang.
            await waitForSyncedStateBounded(entry.facade, BALANCE_SYNC_TIMEOUT_MS);
            const effectiveTtl = ttl ?? new Date(Date.now() + 60 * 60 * 1000);
            const recipe = await entry.facade.balanceUnboundTransaction(tx, { shieldedSecretKeys: entry.zswapKeys, dustSecretKey: entry.dustKey }, { ttl: effectiveTtl });
            return entry.facade.finalizeRecipe(recipe);
        },
        async submitTx(tx) {
            return entry.facade.submitTransaction(tx);
        }
    };
}
// ---- Private-state proxy (worker → main RPC) ------------------------------
/**
 * Each CRUD call on the proxy posts a `private-state-rpc` message back to the
 * main thread, which holds the real CapDbPrivateStateProvider (keyed by
 * proxyId, see srv/midnight/wallet-worker-client.ts). The SDK consumes the
 * returned object as a plain PrivateStateProvider; await semantics work
 * because each method returns a Promise that resolves on the reply port.
 *
 * `setContractAddress` is sync in the SDK contract; we forward it as a
 * fire-and-forget message (no reply port). worker_threads guarantees ordering
 * on parentPort, so the next async set/get from the same proxy is always
 * dispatched on main AFTER the address-set has been applied.
 */
function privateStateRpc(proxyId, method, args) {
    return new Promise((resolve, reject) => {
        const { port1, port2 } = new node_worker_threads_1.MessageChannel();
        port2.once('message', (msg) => {
            port2.close();
            if (msg?.ok) {
                resolve(msg.result);
            }
            else {
                const payload = msg?.error;
                const err = new Error(payload?.message ?? String(payload ?? 'private-state rpc failed'));
                if (payload?.name)
                    err.name = payload.name;
                reject(err);
            }
        });
        port2.once('messageerror', err => { port2.close(); reject(err); });
        node_worker_threads_1.parentPort.postMessage({ kind: 'private-state-rpc', proxyId, method, args, port: port1 }, [port1]);
    });
}
function createPrivateStateProxy(proxyId) {
    return {
        setContractAddress(addr) {
            if (!addr)
                throw new Error('Contract address must not be empty');
            // Fire-and-forget; order preserved relative to subsequent async ops.
            node_worker_threads_1.parentPort.postMessage({
                kind: 'private-state-rpc',
                proxyId,
                method: 'setContractAddress',
                args: [addr]
            });
        },
        async set(privateStateId, state) {
            return privateStateRpc(proxyId, 'set', [privateStateId, state]);
        },
        async get(privateStateId) {
            return privateStateRpc(proxyId, 'get', [privateStateId]);
        },
        async remove(privateStateId) {
            return privateStateRpc(proxyId, 'remove', [privateStateId]);
        },
        async clear() {
            return privateStateRpc(proxyId, 'clear', []);
        },
        async setSigningKey(addr, signingKey) {
            return privateStateRpc(proxyId, 'setSigningKey', [addr, signingKey]);
        },
        async getSigningKey(addr) {
            return privateStateRpc(proxyId, 'getSigningKey', [addr]);
        },
        async removeSigningKey(addr) {
            return privateStateRpc(proxyId, 'removeSigningKey', [addr]);
        },
        async clearSigningKeys() {
            return privateStateRpc(proxyId, 'clearSigningKeys', []);
        }
    };
}
// ---- SDK version pin ------------------------------------------------------
let resolvedSdkVersion;
function getSdkVersion() {
    if (resolvedSdkVersion)
        return resolvedSdkVersion;
    try {
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        const fs = require('fs');
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        const path = require('path');
        let pkgPath;
        // First try: direct subpath resolution. Newer ESM-only packages with
        // restricted `exports` reject this — that's why we had `@unknown`.
        try {
            pkgPath = require.resolve('@midnight-ntwrk/wallet-sdk-facade/package.json');
        }
        catch {
            // Fallback: walk up from the package's main module path until we
            // find a package.json. Works regardless of exports restrictions.
            const mainPath = require.resolve('@midnight-ntwrk/wallet-sdk-facade');
            let dir = path.dirname(mainPath);
            // Bound the walk to keep it cheap.
            for (let i = 0; i < 8 && dir && dir !== path.dirname(dir); i++) {
                const candidate = path.join(dir, 'package.json');
                if (fs.existsSync(candidate)) {
                    pkgPath = candidate;
                    break;
                }
                dir = path.dirname(dir);
            }
        }
        if (!pkgPath)
            throw new Error('package.json not located');
        const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
        resolvedSdkVersion = `wallet-sdk-facade@${pkg.version}`;
    }
    catch {
        resolvedSdkVersion = 'wallet-sdk-facade@unknown';
    }
    return resolvedSdkVersion;
}
async function encodeAddressString(addr, networkId) {
    if (addr == null)
        return '';
    if (typeof addr === 'string')
        return addr;
    const af = await loadAddressFormat();
    return af.MidnightBech32m.encode(networkId, addr).toString();
}
async function parseReceiverAddress(addr, networkId) {
    const af = await loadAddressFormat();
    if (addr.startsWith('mn_shield-addr_')) {
        return { kind: 'shielded', addr: af.MidnightBech32m.parse(addr).decode(af.ShieldedAddress, networkId) };
    }
    if (addr.startsWith('mn_addr_')) {
        return { kind: 'unshielded', addr: af.MidnightBech32m.parse(addr).decode(af.UnshieldedAddress, networkId) };
    }
    throw new Error(`Unsupported receiver address prefix in '${addr.slice(0, 16)}...' ` +
        `(expected 'mn_shield-addr_' for shielded or 'mn_addr_' for unshielded)`);
}
// ---- Facade construction --------------------------------------------------
async function buildFacade(args) {
    const sdk = await loadSdk();
    await ensureNetworkId(args.networkId, sdk);
    // args.seedHex is the 64-byte BIP39 seed (128 hex). Lace derives each key
    // type from a DIFFERENT HD role (Zswap/Dust/NightExternal); deriving them
    // all from one raw seed lands on the wrong account. See srv/utils/wallet-hd.ts.
    const bip39Seed = new Uint8Array(Buffer.from(args.seedHex, 'hex'));
    const roleSeeds = await (0, wallet_hd_1.deriveRoleSeeds)(bip39Seed);
    const zswapKeys = sdk.ledger.ZswapSecretKeys.fromSeed(roleSeeds.zswap);
    const dustKey = sdk.ledger.DustSecretKey.fromSeed(roleSeeds.dust);
    const txHistoryStorage = new sdk.abstractions.InMemoryTransactionHistoryStorage(sdk.facade.WalletEntrySchema, sdk.facade.mergeWalletEntries);
    const { createKeystore, PublicKey } = sdk.unshielded;
    const unshieldedKeystore = createKeystore(roleSeeds.night, args.networkId);
    const configuration = {
        networkId: args.networkId,
        provingServerUrl: new URL(args.proofServerUrl),
        relayURL: new URL(args.relayUrl),
        indexerClientConnection: {
            indexerHttpUrl: args.indexerHttpUrl,
            indexerWsUrl: args.indexerWsUrl
        },
        txHistoryStorage,
        costParameters: { additionalFeeOverhead: 0n, feeBlocksMargin: 1 }
    };
    const dustParameters = sdk.ledger.LedgerParameters.initialParameters().dust;
    const ShieldedWallet = sdk.shielded.ShieldedWallet;
    const UnshieldedWallet = sdk.unshielded.UnshieldedWallet;
    const DustWallet = sdk.dust.DustWallet;
    const restore = args.restoreBlobs;
    const facade = await sdk.facade.WalletFacade.init({
        configuration,
        shielded: () => restore?.shielded
            ? ShieldedWallet(configuration).restore(restore.shielded)
            : ShieldedWallet(configuration).startWithSecretKeys(zswapKeys),
        unshielded: () => restore?.unshielded
            ? UnshieldedWallet(configuration).restore(restore.unshielded)
            : UnshieldedWallet(configuration).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)),
        dust: () => restore?.dust
            ? DustWallet(configuration).restore(restore.dust)
            : DustWallet(configuration).startWithSecretKey(dustKey, dustParameters)
    });
    await facade.start(zswapKeys, dustKey);
    log('info', `[worker] facade started for ${args.sessionId.slice(0, 16)} (restored=${!!restore})`);
    return {
        facade,
        sdkVersion: getSdkVersion(),
        zswapKeys,
        dustKey,
        unshieldedKeystore,
        networkId: args.networkId,
        attestationSecret: (0, contract_witnesses_1.deriveAttestationSecret)(roleSeeds.zswap)
    };
}
// ---- Periodic state save (pushed to main thread) -------------------------
function startPeriodicSave(sessionId, entry) {
    if (entry.saveTimer)
        return;
    let lastBlobs = {};
    let tickCount = 0;
    log('info', `[worker] periodic-save interval armed for ${sessionId.slice(0, 16)}`);
    entry.saveTimer = setInterval(async () => {
        tickCount++;
        const tickStart = Date.now();
        // Log BEFORE the first await so we know the timer fired even if
        // serializeState() hangs on the rx Observable.
        log('info', `[worker] save-tick #${tickCount} fired, calling collectSerializedStates...`);
        try {
            const collectStart = Date.now();
            const blobs = await collectSerializedStates(entry.facade);
            const collectMs = Date.now() - collectStart;
            const shape = [
                `sh=${blobs.shielded ? blobs.shielded.length : '-'}`,
                `un=${blobs.unshielded ? blobs.unshielded.length : '-'}`,
                `du=${blobs.dust ? blobs.dust.length : '-'}`
            ].join(' ');
            log('info', `[worker] save-tick #${tickCount} collect returned in ${collectMs}ms: ${shape}`);
            if (!hasAnyBlob(blobs))
                return;
            // Skip push if nothing changed since last tick — avoids burning
            // CAP write cycles when wallet is fully synced and idle.
            if (blobs.shielded === lastBlobs.shielded &&
                blobs.unshielded === lastBlobs.unshielded &&
                blobs.dust === lastBlobs.dust) {
                log('info', `[worker] save-tick #${tickCount} unchanged, skipping push`);
                return;
            }
            lastBlobs = blobs;
            node_worker_threads_1.parentPort?.postMessage({
                kind: 'state-save',
                sessionId,
                sdkVersion: entry.sdkVersion,
                blobs
            });
            log('info', `[worker] save-tick #${tickCount} pushed (total ${Date.now() - tickStart}ms)`);
        }
        catch (err) {
            log('warn', `[worker] periodic save failed: ${(0, format_error_1.formatErr)(err)}`);
        }
    }, 30_000);
    entry.saveTimer.unref();
}
async function collectSerializedStates(facade) {
    const out = {};
    const tryOne = async (key) => {
        try {
            const sub = facade?.[key];
            if (sub && typeof sub.serializeState === 'function') {
                const blob = await sub.serializeState();
                if (typeof blob === 'string')
                    out[key] = blob;
            }
        }
        catch {
            // Best-effort: a missing blob for one sub-wallet doesn't block
            // persistence of the others.
        }
    };
    await Promise.all([tryOne('shielded'), tryOne('unshielded'), tryOne('dust')]);
    return out;
}
function hasAnyBlob(b) {
    return !!(b.shielded || b.unshielded || b.dust);
}
// ---- RPC method handlers --------------------------------------------------
const handlers = {
    async init(args) {
        if (facades.has(args.sessionId)) {
            log('info', `[worker] init: cache hit ${args.sessionId.slice(0, 16)}`);
            return { facadeReady: true, alreadyExisted: true };
        }
        const entry = await buildFacade(args);
        facades.set(args.sessionId, entry);
        startPeriodicSave(args.sessionId, entry);
        return { facadeReady: true, alreadyExisted: false, sdkVersion: entry.sdkVersion };
    },
    async waitForSyncedState({ sessionId, timeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const deadline = timeoutMs
            ? new Promise((_, rej) => setTimeout(() => rej(new Error('waitForSyncedState timeout')), timeoutMs))
            : null;
        const synced = deadline
            ? Promise.race([entry.facade.waitForSyncedState(), deadline])
            : entry.facade.waitForSyncedState();
        await synced;
        return { synced: true };
    },
    async serializeState({ sessionId }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const blobs = await collectSerializedStates(entry.facade);
        return { sdkVersion: entry.sdkVersion, blobs };
    },
    async evict({ sessionId }) {
        const entry = facades.get(sessionId);
        if (!entry)
            return { evicted: false };
        facades.delete(sessionId);
        if (entry.saveTimer)
            clearInterval(entry.saveTimer);
        // Best-effort final save push. Cleanup-path errors don't block
        // eviction but are logged — silent swallowing would hide leaks.
        try {
            const blobs = await collectSerializedStates(entry.facade);
            if (hasAnyBlob(blobs)) {
                node_worker_threads_1.parentPort?.postMessage({
                    kind: 'state-save',
                    sessionId,
                    sdkVersion: entry.sdkVersion,
                    blobs
                });
            }
        }
        catch (err) {
            log('warn', `[worker] evict final-save failed for ${sessionId.slice(0, 16)}: ${(0, format_error_1.formatErr)(err)}`);
        }
        try {
            entry.zswapKeys?.clear?.();
            await entry.facade?.stop?.();
        }
        catch (err) {
            log('warn', `[worker] evict cleanup failed for ${sessionId.slice(0, 16)}: ${(0, format_error_1.formatErr)(err)}`);
        }
        return { evicted: true };
    },
    async ping() {
        return { ok: true, ts: Date.now() };
    },
    /**
     * End-to-end NIGHT-UTXO registration for DUST generation. Wraps:
     *   waitForSyncedState → filter unregistered → register/finalize/submit.
     * Runs entirely in the worker so no SDK objects cross the thread boundary.
     */
    async registerDustGeneration({ sessionId, dustReceiverAddress, syncTimeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        // 1. Block until the wallet is synced enough to see its NIGHT UTXOs.
        log('info', `[worker] dust-register: waiting for synced state...`);
        const synced = syncTimeoutMs
            ? await Promise.race([
                entry.facade.waitForSyncedState(),
                new Promise((_, rej) => setTimeout(() => rej(new Error('dust-register: sync timeout')), syncTimeoutMs))
            ])
            : await entry.facade.waitForSyncedState();
        log('info', `[worker] dust-register: synced.`);
        const availableCoins = synced?.unshielded?.availableCoins ?? [];
        const unregistered = availableCoins.filter((c) => c?.meta?.registeredForDustGeneration !== true);
        const myDustAddr = await entry.facade.dust.getAddress();
        const receiverRaw = dustReceiverAddress || myDustAddr;
        const dustAddrStr = await encodeAddressString(receiverRaw, entry.networkId);
        if (unregistered.length === 0) {
            // Two distinct cases produce an empty unregistered list, distinguish
            // for the caller via the log so triage is faster.
            if (availableCoins.length === 0) {
                log('info', `[worker] dust-register: no unshielded NIGHT UTXOs visible to this wallet. ` +
                    `Either all your NIGHT is held shielded (unshield via Lace to enable dust-gen), ` +
                    `or your unshielded UTXOs are already committed to dust generation and no longer ` +
                    `surface in synced.unshielded.availableCoins (the SDK's "available" excludes ` +
                    `registered UTXOs). Check Lace's "Refilling NhNNmin" indicator for the latter.`);
            }
            else {
                log('info', `[worker] dust-register: ${availableCoins.length} NIGHT UTXO(s) are already registered for dust-gen.`);
            }
            return {
                txId: null,
                registeredCount: 0,
                totalNightUtxos: availableCoins.length,
                dustReceiverAddress: dustAddrStr
            };
        }
        // 2. Parse Bech32m receiver string into a DustAddress, which is what
        //    `registerNightUtxosForDustGeneration` expects on the wire.
        let receiverParsed = receiverRaw;
        if (typeof receiverRaw === 'string') {
            const af = await loadAddressFormat();
            receiverParsed = af.MidnightBech32m
                .parse(receiverRaw)
                .decode(af.DustAddress, entry.networkId);
        }
        // 3. Build registration recipe + finalize + submit. All in-process.
        const verifyingKey = entry.unshieldedKeystore.getPublicKey();
        const signFn = (payload) => entry.unshieldedKeystore.signData(payload);
        const recipe = await entry.facade.registerNightUtxosForDustGeneration(unregistered, verifyingKey, signFn, receiverParsed);
        const finalized = await entry.facade.finalizeRecipe(recipe);
        const txId = await entry.facade.submitTransaction(finalized);
        log('info', `[worker] dust-register: submitted ${unregistered.length} UTXO(s), txId=${String(txId).slice(0, 16)}...`);
        return {
            txId: String(txId),
            registeredCount: unregistered.length,
            totalNightUtxos: availableCoins.length,
            dustReceiverAddress: dustAddrStr
        };
    },
    /**
     * Symmetric pair to `registerDustGeneration`. Removes NIGHT UTXOs from
     * dust generation so they become spendable again (registered UTXOs are
     * committed to dust accrual and excluded from `availableCoins`).
     *
     * The SDK's `synced.unshielded.availableCoins` only lists *unregistered*
     * UTXOs, so we read registered ones from the full set the wallet tracks.
     * For Phase 1 of this action we deregister ALL registered UTXOs; per-UTXO
     * narrowing is a follow-up once we have a stable UTXO-id surface.
     */
    async deregisterDustGeneration({ sessionId, syncTimeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        log('info', `[worker] dust-deregister: waiting for synced state...`);
        const synced = syncTimeoutMs
            ? await Promise.race([
                entry.facade.waitForSyncedState(),
                new Promise((_, rej) => setTimeout(() => rej(new Error('dust-deregister: sync timeout')), syncTimeoutMs))
            ])
            : await entry.facade.waitForSyncedState();
        log('info', `[worker] dust-deregister: synced.`);
        // The SDK exposes `availableCoins` (unregistered-only) AND `allCoins`
        // (full set) on the synced unshielded state. The full set is where
        // registered UTXOs live — they're committed to dust gen so the SDK
        // hides them from "available" but we still need them to deregister.
        const allCoins = synced?.unshielded?.allCoins ?? synced?.unshielded?.coins ?? [];
        const registered = allCoins.filter((c) => c?.meta?.registeredForDustGeneration === true);
        if (registered.length === 0) {
            log('info', `[worker] dust-deregister: no registered NIGHT UTXOs to deregister.`);
            return {
                txId: null,
                deregisteredCount: 0,
                totalNightUtxos: allCoins.length
            };
        }
        const verifyingKey = entry.unshieldedKeystore.getPublicKey();
        const signFn = (payload) => entry.unshieldedKeystore.signData(payload);
        const recipe = await entry.facade.deregisterFromDustGeneration(registered, verifyingKey, signFn);
        const finalized = await entry.facade.finalizeRecipe(recipe);
        const txId = await entry.facade.submitTransaction(finalized);
        log('info', `[worker] dust-deregister: submitted ${registered.length} UTXO(s), txId=${String(txId).slice(0, 16)}...`);
        return {
            txId: String(txId),
            deregisteredCount: registered.length,
            totalNightUtxos: allCoins.length
        };
    },
    /**
     * Send NIGHT to any Midnight address. The receiver's Bech32m prefix
     * decides the destination ledger (`mn_shield-addr_` → shielded,
     * `mn_addr_` → unshielded). Source funds are selected by the SDK's
     * balancer from the wallet's available UTXOs on the target ledger;
     * cross-ledger funding is not attempted here (use shield/unshieldFunds).
     *
     * Build + balance + prove + submit all in-worker via `facade.transferTransaction`.
     * Returns primitives only — no SDK objects cross the thread boundary.
     */
    async transferNight({ sessionId, receiverAddress, amount, ttlIso, syncTimeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const sdk = await loadSdk();
        await ensureNetworkId(entry.networkId, sdk);
        log('info', `[worker] transfer: waiting for synced state...`);
        if (syncTimeoutMs) {
            await Promise.race([
                entry.facade.waitForSyncedState(),
                new Promise((_, rej) => setTimeout(() => rej(new Error('transfer: sync timeout')), syncTimeoutMs))
            ]);
        }
        else {
            await entry.facade.waitForSyncedState();
        }
        log('info', `[worker] transfer: synced.`);
        const receiver = await parseReceiverAddress(receiverAddress, entry.networkId);
        const amountBig = BigInt(amount);
        const nightRawType = sdk.ledger.nativeToken().raw;
        const ttl = ttlIso ? new Date(ttlIso) : new Date(Date.now() + 10 * 60 * 1000);
        const outputs = receiver.kind === 'shielded'
            ? [{ type: 'shielded', outputs: [{ type: nightRawType, receiverAddress: receiver.addr, amount: amountBig }] }]
            : [{ type: 'unshielded', outputs: [{ type: nightRawType, receiverAddress: receiver.addr, amount: amountBig }] }];
        log('info', `[worker] transfer: ${amount} NIGHT to ${receiver.kind} addr ${receiverAddress.slice(0, 24)}...`);
        const recipe = await entry.facade.transferTransaction(outputs, { shieldedSecretKeys: entry.zswapKeys, dustSecretKey: entry.dustKey }, { ttl });
        const finalized = await entry.facade.finalizeRecipe(recipe);
        const txId = await entry.facade.submitTransaction(finalized);
        log('info', `[worker] transfer: submitted, txId=${String(txId).slice(0, 16)}...`);
        return {
            txId: String(txId),
            toLedger: receiver.kind,
            amount: amount,
            receiverAddress: receiverAddress
        };
    },
    /**
     * Move NIGHT from shielded → unshielded ledger (own funds only).
     *
     * Built via `facade.initSwap` — the SDK's primitive for explicit
     * cross-ledger conversion. `desiredInputs` names the source ledger
     * + token + amount; `desiredOutputs` names the destination ledger
     * with the wallet's own unshielded address as receiver.
     *
     * For 1:1 self-swaps (no value change, just ledger shift), the same
     * NIGHT raw type and amount appear on both sides.
     */
    async unshieldNight({ sessionId, amount, ttlIso, syncTimeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const sdk = await loadSdk();
        await ensureNetworkId(entry.networkId, sdk);
        log('info', `[worker] unshield: waiting for synced state...`);
        if (syncTimeoutMs) {
            await Promise.race([
                entry.facade.waitForSyncedState(),
                new Promise((_, rej) => setTimeout(() => rej(new Error('unshield: sync timeout')), syncTimeoutMs))
            ]);
        }
        else {
            await entry.facade.waitForSyncedState();
        }
        log('info', `[worker] unshield: synced.`);
        const ownUnshieldedAddr = await entry.facade.unshielded.getAddress();
        const ownUnshieldedAddrStr = await encodeAddressString(ownUnshieldedAddr, entry.networkId);
        const amountBig = BigInt(amount);
        const nightRawType = sdk.ledger.nativeToken().raw;
        const ttl = ttlIso ? new Date(ttlIso) : new Date(Date.now() + 10 * 60 * 1000);
        const desiredInputs = {
            shielded: { [nightRawType]: amountBig }
        };
        const desiredOutputs = [
            { type: 'unshielded', outputs: [{ type: nightRawType, receiverAddress: ownUnshieldedAddr, amount: amountBig }] }
        ];
        log('info', `[worker] unshield: ${amount} NIGHT → own unshielded addr ${ownUnshieldedAddrStr.slice(0, 24)}...`);
        const recipe = await entry.facade.initSwap(desiredInputs, desiredOutputs, { shieldedSecretKeys: entry.zswapKeys, dustSecretKey: entry.dustKey }, { ttl, payFees: true });
        const finalized = await entry.facade.finalizeRecipe(recipe);
        const txId = await entry.facade.submitTransaction(finalized);
        log('info', `[worker] unshield: submitted, txId=${String(txId).slice(0, 16)}...`);
        return {
            txId: String(txId),
            amount: amount,
            unshieldedReceiverAddress: ownUnshieldedAddrStr
        };
    },
    /**
     * Move NIGHT from unshielded → shielded ledger (own funds only).
     * Symmetric counterpart to `unshieldNight`. Same `initSwap` mechanism,
     * source/destination flipped.
     */
    async shieldNight({ sessionId, amount, ttlIso, syncTimeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const sdk = await loadSdk();
        await ensureNetworkId(entry.networkId, sdk);
        log('info', `[worker] shield: waiting for synced state...`);
        if (syncTimeoutMs) {
            await Promise.race([
                entry.facade.waitForSyncedState(),
                new Promise((_, rej) => setTimeout(() => rej(new Error('shield: sync timeout')), syncTimeoutMs))
            ]);
        }
        else {
            await entry.facade.waitForSyncedState();
        }
        log('info', `[worker] shield: synced.`);
        const ownShieldedAddr = await entry.facade.shielded.getAddress();
        const ownShieldedAddrStr = await encodeAddressString(ownShieldedAddr, entry.networkId);
        const amountBig = BigInt(amount);
        const nightRawType = sdk.ledger.nativeToken().raw;
        const ttl = ttlIso ? new Date(ttlIso) : new Date(Date.now() + 10 * 60 * 1000);
        const desiredInputs = {
            unshielded: { [nightRawType]: amountBig }
        };
        const desiredOutputs = [
            { type: 'shielded', outputs: [{ type: nightRawType, receiverAddress: ownShieldedAddr, amount: amountBig }] }
        ];
        log('info', `[worker] shield: ${amount} NIGHT → own shielded addr ${ownShieldedAddrStr.slice(0, 24)}...`);
        const recipe = await entry.facade.initSwap(desiredInputs, desiredOutputs, { shieldedSecretKeys: entry.zswapKeys, dustSecretKey: entry.dustKey }, { ttl, payFees: true });
        const finalized = await entry.facade.finalizeRecipe(recipe);
        const txId = await entry.facade.submitTransaction(finalized);
        log('info', `[worker] shield: submitted, txId=${String(txId).slice(0, 16)}...`);
        return {
            txId: String(txId),
            amount: amount,
            shieldedReceiverAddress: ownShieldedAddrStr
        };
    },
    /**
     * Read-only snapshot of the wallet's current balances and dust state.
     *
     * Pulls from the cached synced state via `waitForSyncedState()` (which
     * resolves immediately when at tip, blocks during initial catch-up).
     * No transaction is built or submitted.
     *
     * Returns only NIGHT for shielded/unshielded in this first version
     * (other custom tokens omitted; add a `tokensJson` field later if a
     * consumer needs them).
     */
    async getBalance({ sessionId, syncTimeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const sdk = await loadSdk();
        await ensureNetworkId(entry.networkId, sdk);
        const synced = syncTimeoutMs
            ? await Promise.race([
                entry.facade.waitForSyncedState(),
                new Promise((_, rej) => setTimeout(() => rej(new Error('getBalance: sync timeout')), syncTimeoutMs))
            ])
            : await entry.facade.waitForSyncedState();
        const nightRawType = sdk.ledger.nativeToken().raw;
        const shieldedBalances = synced?.shielded?.balances ?? {};
        const unshieldedBalances = synced?.unshielded?.balances ?? {};
        const totalNightCoins = synced?.unshielded?.totalCoins ?? [];
        const shieldedNight = shieldedBalances[nightRawType] ?? 0n;
        const unshieldedNight = unshieldedBalances[nightRawType] ?? 0n;
        // dust.balance(time) is synchronous and returns Balance (= bigint).
        const dustBalance = entry.facade.dust.balance(new Date());
        const registeredCount = totalNightCoins.filter((c) => c?.meta?.registeredForDustGeneration === true).length;
        return {
            shieldedNight: shieldedNight.toString(),
            unshieldedNight: unshieldedNight.toString(),
            dustBalance: dustBalance.toString(),
            registeredNightUtxoCount: registeredCount,
            totalNightUtxoCount: totalNightCoins.length
        };
    },
    /**
     * Pre-flight fee estimate for a NIGHT transfer. Builds the
     * `transferTransaction` recipe in the worker — which runs balancing
     * (lightweight) but NOT proof generation (heavy) — then calls
     * `estimateTransactionFee` to compute total fee including any
     * balancing tx. No submit. The recipe is discarded.
     */
    async estimateTransferFee({ sessionId, receiverAddress, amount, ttlIso, syncTimeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const sdk = await loadSdk();
        await ensureNetworkId(entry.networkId, sdk);
        if (syncTimeoutMs) {
            await Promise.race([
                entry.facade.waitForSyncedState(),
                new Promise((_, rej) => setTimeout(() => rej(new Error('estimateTransferFee: sync timeout')), syncTimeoutMs))
            ]);
        }
        else {
            await entry.facade.waitForSyncedState();
        }
        const receiver = await parseReceiverAddress(receiverAddress, entry.networkId);
        const amountBig = BigInt(amount);
        const nightRawType = sdk.ledger.nativeToken().raw;
        const ttl = ttlIso ? new Date(ttlIso) : new Date(Date.now() + 10 * 60 * 1000);
        const outputs = receiver.kind === 'shielded'
            ? [{ type: 'shielded', outputs: [{ type: nightRawType, receiverAddress: receiver.addr, amount: amountBig }] }]
            : [{ type: 'unshielded', outputs: [{ type: nightRawType, receiverAddress: receiver.addr, amount: amountBig }] }];
        const recipe = await entry.facade.transferTransaction(outputs, { shieldedSecretKeys: entry.zswapKeys, dustSecretKey: entry.dustKey }, { ttl });
        // recipe is UnprovenTransactionRecipe: { type: 'UNPROVEN_TRANSACTION', transaction }
        const fee = await entry.facade.estimateTransactionFee(recipe.transaction, entry.dustKey, { ttl });
        return { fee: fee.toString(), toLedger: receiver.kind };
    },
    /**
     * Pre-flight fee estimate for a shield/unshield ledger shift. Same
     * approach as `estimateTransferFee`: build the `initSwap` recipe,
     * estimate, discard.
     *
     * `direction`: 'shield' means unshielded → shielded; 'unshield' means
     * shielded → unshielded. Always operates on the wallet's own NIGHT.
     */
    async estimateSwapFee({ sessionId, direction, amount, ttlIso, syncTimeoutMs }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const sdk = await loadSdk();
        await ensureNetworkId(entry.networkId, sdk);
        if (syncTimeoutMs) {
            await Promise.race([
                entry.facade.waitForSyncedState(),
                new Promise((_, rej) => setTimeout(() => rej(new Error('estimateSwapFee: sync timeout')), syncTimeoutMs))
            ]);
        }
        else {
            await entry.facade.waitForSyncedState();
        }
        const amountBig = BigInt(amount);
        const nightRawType = sdk.ledger.nativeToken().raw;
        const ttl = ttlIso ? new Date(ttlIso) : new Date(Date.now() + 10 * 60 * 1000);
        let desiredInputs;
        let desiredOutputs;
        if (direction === 'shield') {
            const ownShieldedAddr = await entry.facade.shielded.getAddress();
            desiredInputs = { unshielded: { [nightRawType]: amountBig } };
            desiredOutputs = [{ type: 'shielded', outputs: [{ type: nightRawType, receiverAddress: ownShieldedAddr, amount: amountBig }] }];
        }
        else {
            const ownUnshieldedAddr = await entry.facade.unshielded.getAddress();
            desiredInputs = { shielded: { [nightRawType]: amountBig } };
            desiredOutputs = [{ type: 'unshielded', outputs: [{ type: nightRawType, receiverAddress: ownUnshieldedAddr, amount: amountBig }] }];
        }
        const recipe = await entry.facade.initSwap(desiredInputs, desiredOutputs, { shieldedSecretKeys: entry.zswapKeys, dustSecretKey: entry.dustKey }, { ttl, payFees: true });
        const fee = await entry.facade.estimateTransactionFee(recipe.transaction, entry.dustKey, { ttl });
        return { fee: fee.toString(), direction };
    },
    /**
     * Deploy a Compact-emitted contract via the SDK, entirely in the worker.
     * Inputs are primitives + the registration meta — the contract artifact is
     * dynamic-imported and `CompiledContract.make`'d inside the worker, cached
     * by name. The private-state provider is a proxy that round-trips to main
     * (where the real CapDbPrivateStateProvider lives, keyed by proxyId).
     *
     * Returns primitives so nothing SDK-shaped crosses the thread boundary.
     */
    async deployContract({ sessionId, proxyId, contractName, registration, indexerHttpUrl, indexerWsUrl, proofServerUrl, networkId, initialPrivateState }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const sdk = await loadSdk();
        await ensureNetworkId(networkId, sdk);
        const compiledContract = await getOrCompileContract(contractName, registration, entry);
        const contractProviders = await buildWorkerContractProviders({
            indexerHttpUrl, indexerWsUrl, proofServerUrl,
            zkConfigPath: registration.zkConfigPath
        });
        const privateStateProvider = createPrivateStateProxy(proxyId);
        const walletProvider = buildWorkerWalletProvider(entry);
        const providers = {
            ...contractProviders,
            privateStateProvider,
            walletProvider,
            midnightProvider: walletProvider
        };
        const { contracts } = await loadContractsSdk();
        log('info', `[worker] deployContract: starting ${contractName} sess=${sessionId.slice(0, 16)}`);
        const result = await contracts.deployContract(providers, {
            compiledContract,
            privateStateId: registration.privateStateId,
            initialPrivateState
        });
        const pub = result?.deployTxData?.public;
        const out = {
            txHash: String(pub?.txHash ?? ''),
            contractAddress: String(pub?.contractAddress ?? ''),
            onChainStatus: String(pub?.status ?? '')
        };
        log('info', `[worker] deployContract: done addr=${out.contractAddress.slice(0, 16)} status=${out.onChainStatus}`);
        return out;
    },
    /**
     * Submit a circuit call against an already-deployed contract. Same worker-
     * side provider assembly as deployContract; routes through
     * `findDeployedContract` and invokes the circuit by name.
     */
    async submitContractCall({ sessionId, proxyId, contractName, registration, contractAddress, circuit, args: callArgs, indexerHttpUrl, indexerWsUrl, proofServerUrl, networkId, witnessValues }) {
        const entry = facades.get(sessionId);
        if (!entry)
            throw new Error(`No facade for sessionId=${sessionId.slice(0, 16)}`);
        const sdk = await loadSdk();
        await ensureNetworkId(networkId, sdk);
        const compiledContract = await getOrCompileContract(contractName, registration, entry, witnessValues);
        const contractProviders = await buildWorkerContractProviders({
            indexerHttpUrl, indexerWsUrl, proofServerUrl,
            zkConfigPath: registration.zkConfigPath
        });
        const privateStateProvider = createPrivateStateProxy(proxyId);
        const walletProvider = buildWorkerWalletProvider(entry);
        const providers = {
            ...contractProviders,
            privateStateProvider,
            walletProvider,
            midnightProvider: walletProvider
        };
        const { contracts } = await loadContractsSdk();
        log('info', `[worker] submitContractCall: ${contractName}.${circuit}@${contractAddress.slice(0, 12)}`);
        const found = await contracts.findDeployedContract(providers, {
            contractAddress,
            compiledContract,
            privateStateId: registration.privateStateId
        });
        const fn = found?.callTx?.[circuit];
        if (typeof fn !== 'function') {
            throw new Error(`Circuit '${circuit}' not found on contract at ${contractAddress}`);
        }
        const result = await fn(...(callArgs ?? []));
        const pub = result?.public;
        const out = {
            txHash: String(pub?.txHash ?? ''),
            onChainStatus: String(pub?.status ?? '')
        };
        log('info', `[worker] submitContractCall: done txHash=${out.txHash.slice(0, 16)} status=${out.onChainStatus}`);
        return out;
    }
};
// ---- Dispatcher -----------------------------------------------------------
if (!node_worker_threads_1.parentPort) {
    throw new Error('wallet-worker must be loaded as a worker_threads worker (no parentPort)');
}
node_worker_threads_1.parentPort.on('message', async (msg) => {
    if (msg?.kind !== 'rpc' || !msg.port) {
        log('warn', `[worker] unexpected message: ${JSON.stringify(msg).slice(0, 80)}`);
        return;
    }
    const { method, args, port } = msg;
    try {
        const fn = handlers[method];
        if (!fn)
            throw new Error(`Unknown method: ${method}`);
        const result = await fn(args);
        port.postMessage({ ok: true, result });
    }
    catch (err) {
        const payload = {
            name: err?.name ?? 'Error',
            message: (0, format_error_1.formatErr)(err)
        };
        port.postMessage({ ok: false, error: payload });
    }
    finally {
        port.close();
    }
});
node_worker_threads_1.parentPort.postMessage({ kind: 'ready' });
log('info', '[worker] ready');
