From 3f0a119987c94d7ace8d4ab50630e80149ded193 Mon Sep 17 00:00:00 2001 From: philipp Date: Thu, 2 Apr 2026 12:24:59 +0200 Subject: [PATCH] Add operator withdrawal path Proof: The active NEAR Intents funded market-maker loop needs a first-class operator withdrawal action so funded inventory can be exited through repo-controlled code rather than manual follow-up. Assumptions: The configured signer key is also a full-access key on the named NEAR account, and external-chain exits for active OMFT assets are triggered by intents.near::ft_withdraw with the token contract as receiver_id plus memo=WITHDRAW_TO:. Still fake: Strategy and executor remain disarmed, no live inventory is credited yet, and no live mainnet trade quote has been submitted. --- docs/operator-runbook.md | 18 ++ src/apps/liquidity-manager.mjs | 196 ++++++++++++++++++++ src/core/liquidity-withdrawals.mjs | 57 ++++++ src/venues/near-intents/bridge-client.mjs | 7 + src/venues/near-intents/verifier-client.mjs | 37 +++- test/liquidity-withdrawals.test.mjs | 64 +++++++ 6 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/core/liquidity-withdrawals.mjs create mode 100644 test/liquidity-withdrawals.test.mjs diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 1cb27ae..fd7b00f 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -82,6 +82,18 @@ curl -s -X POST http://127.0.0.1:8082/refresh curl -s -X POST http://127.0.0.1:8083/refresh curl -s -X POST http://127.0.0.1:8084/refresh +curl -s -X POST http://127.0.0.1:8084/withdrawal-estimate \ + -H 'content-type: application/json' \ + -d '{"asset_id":"nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near","amount":"5000000000000000000","destination_address":"0xYourGnosisAddress"}' + +curl -s -X POST http://127.0.0.1:8084/freeze-withdrawals \ + -H 'content-type: application/json' \ + -d '{"frozen":false}' + +curl -s -X POST http://127.0.0.1:8084/withdraw \ + -H 'content-type: application/json' \ + -d '{"asset_id":"nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near","amount":"5000000000000000000","destination_address":"0xYourGnosisAddress"}' + curl -s -X POST http://127.0.0.1:8086/arm curl -s -X POST http://127.0.0.1:8086/disarm curl -s -X PUT http://127.0.0.1:8086/limits \ @@ -100,6 +112,12 @@ curl -s -X POST http://127.0.0.1:8084/track-withdrawal \ -d '{"withdrawal_hash":"","asset_id":"nep141:btc.omft.near","chain":"btc:mainnet","amount":"1000"}' ``` +Notes: + +- Deposit addresses are built in. `liquidity-manager` refreshes them from the bridge `deposit_address` RPC and exposes them through `/state`. +- The repo withdrawal action is for external-chain exits on the active assets. It submits `intents.near::ft_withdraw`, using the active OMFT token contract as `receiver_id` and `memo=WITHDRAW_TO:`, then tracks the returned NEAR transaction hash through bridge `withdrawal_status`. +- Withdrawals stay frozen by default. Unfreeze explicitly before calling `/withdraw`, then freeze them again after the operation if you do not want further exits. + ## Safe arming sequence 1. Confirm `market-reference-ingest` is publishing fresh BTC/EUR data. diff --git a/src/apps/liquidity-manager.mjs b/src/apps/liquidity-manager.mjs index 98ac2c7..b73a81c 100644 --- a/src/apps/liquidity-manager.mjs +++ b/src/apps/liquidity-manager.mjs @@ -5,10 +5,12 @@ import { startControlApi } from '../core/control-api.mjs'; import { buildEventEnvelope } from '../core/event-envelope.mjs'; import { createJsonStateStore } from '../core/json-state-store.mjs'; import { normalizeLiquidityState } from '../core/liquidity-state.mjs'; +import { buildBridgeWithdrawalPlan } from '../core/liquidity-withdrawals.mjs'; import { createLogger, serializeError } from '../core/log.mjs'; import { assertLiquidityActionEvent } from '../core/schemas.mjs'; import { loadConfig } from '../lib/config.mjs'; import { createNearBridgeClient } from '../venues/near-intents/bridge-client.mjs'; +import { createVerifierClient } from '../venues/near-intents/verifier-client.mjs'; const config = loadConfig(); const logger = createLogger({ @@ -33,6 +35,12 @@ const producer = await createProducer({ logger, }); const bridgeClient = createNearBridgeClient({ rpcUrl: config.nearBridgeRpcUrl }); +const verifierClient = createVerifierClient({ + nearRpcUrl: config.nearRpcUrl, + verifierContract: config.nearVerifierContract, + accountId: config.nearIntentsAccountId, + signerPrivateKey: config.nearIntentsSignerPrivateKey, +}); const store = createJsonStateStore({ stateDir: config.liquidityStateDir, fileName: 'liquidity.json', @@ -45,6 +53,8 @@ const store = createJsonStateStore({ supported_tokens: {}, last_refresh_at: null, last_error: null, + last_withdrawal_request: null, + last_withdrawal_result: null, publish_count: 0, }, }); @@ -164,6 +174,106 @@ async function refreshWithdrawal(tracked, state) { } } +async function estimateWithdrawal({ assetId, amount, destinationAddress, chain = null, state }) { + const plan = buildBridgeWithdrawalPlan({ + assetId, + amount, + destinationAddress, + chain, + supportedTokens: state.supported_tokens, + config, + }); + const estimate = await bridgeClient.withdrawalEstimate({ + chain: plan.chain, + token: plan.near_token_id, + address: plan.destination_address, + }); + + return { + ...plan, + estimate, + }; +} + +async function submitWithdrawal({ assetId, amount, destinationAddress, chain = null }) { + const state = store.getState(); + normalizeLiquidityState(state, { + withdrawalsFrozen: config.withdrawalsFrozen, + }); + + if (state.paused) throw new Error('liquidity manager is paused'); + if (state.withdrawals_frozen) throw new Error('withdrawals are frozen'); + + await refresh(); + const planned = await estimateWithdrawal({ + assetId, + amount, + destinationAddress, + chain, + state, + }); + + state.last_withdrawal_request = { + asset_id: planned.asset_id, + amount: planned.amount, + chain: planned.chain, + destination_address: planned.destination_address, + requested_at: new Date().toISOString(), + estimate: planned.estimate, + }; + store.setState(state); + + const outcome = await verifierClient.ftWithdrawRaw({ + token: planned.near_token_id, + receiverId: planned.receiver_id, + amount: planned.amount, + memo: planned.memo, + }); + const withdrawalHash = outcome.transaction?.hash || outcome.transaction_outcome?.id; + if (!withdrawalHash) throw new Error('missing withdrawal hash from ft_withdraw outcome'); + + const tracked = { + withdrawal_hash: withdrawalHash, + asset_id: planned.asset_id, + chain: planned.chain, + amount: planned.amount, + address: planned.destination_address, + near_token_id: planned.near_token_id, + defuse_asset_identifier: planned.defuse_asset_identifier, + estimate: planned.estimate, + status: 'SUBMITTED', + submitted_at: new Date().toISOString(), + }; + state.tracked_withdrawals[withdrawalHash] = tracked; + state.last_withdrawal_result = { + withdrawal_hash: withdrawalHash, + status: 'SUBMITTED', + transaction: outcome.transaction || null, + transaction_outcome: outcome.transaction_outcome || null, + receipts_outcome: outcome.receipts_outcome || [], + }; + store.setState(state); + + await publishAction({ + action_type: 'withdrawal_submitted', + status: 'SUBMITTED', + chain: planned.chain, + asset_id: planned.asset_id, + details: tracked, + }, state); + store.setState(state); + + refreshWithdrawal(tracked, state) + .then(() => store.setState(state)) + .catch(() => {}); + + return { + plan: planned, + tracked, + outcome, + }; +} + async function publishAction(payload, state) { const event = buildEventEnvelope({ source: 'liquidity-manager', @@ -260,6 +370,46 @@ const controlApi = startControlApi({ return { ok: true, withdrawals_frozen: state.withdrawals_frozen }; }, }, + { + method: 'POST', + path: '/withdrawal-estimate', + handler: async ({ body }) => { + if (!body.asset_id || !body.amount || !body.destination_address) { + return { + statusCode: 400, + payload: { error: 'asset_id, amount, and destination_address are required' }, + }; + } + + const state = store.getState(); + normalizeLiquidityState(state, { + withdrawalsFrozen: config.withdrawalsFrozen, + }); + if (!Object.keys(state.supported_tokens).length) { + await refresh(); + } + try { + const withdrawal = await estimateWithdrawal({ + assetId: body.asset_id, + amount: body.amount, + destinationAddress: body.destination_address, + chain: body.chain, + state, + }); + return { + ok: true, + withdrawal, + }; + } catch (error) { + return { + statusCode: 400, + payload: { + error: error.message, + }, + }; + } + }, + }, { method: 'POST', path: '/track-withdrawal', @@ -296,6 +446,39 @@ const controlApi = startControlApi({ return { ok: true, tracked: state.tracked_withdrawals[body.withdrawal_hash] }; }, }, + { + method: 'POST', + path: '/withdraw', + handler: async ({ body }) => { + if (!body.asset_id || !body.amount || !body.destination_address) { + return { + statusCode: 400, + payload: { error: 'asset_id, amount, and destination_address are required' }, + }; + } + + try { + const submitted = await submitWithdrawal({ + assetId: body.asset_id, + amount: body.amount, + destinationAddress: body.destination_address, + chain: body.chain, + }); + return { + ok: true, + withdrawal: submitted.plan, + tracked: submitted.tracked, + }; + } catch (error) { + return { + statusCode: inferWithdrawStatusCode(error), + payload: { + error: error.message, + }, + }; + } + }, + }, { method: 'POST', path: '/notify-deposit', @@ -340,3 +523,16 @@ function mapDepositAssetId(defuseAssetIdentifier, chain) { if (chain === config.tradingEure.chain) return config.tradingEure.assetId; return defuseAssetIdentifier; } + +function inferWithdrawStatusCode(error) { + const message = String(error?.message || ''); + if ( + message.includes('required') + || message.includes('unsupported') + || message.includes('amount') + || message.includes('chain mismatch') + ) { + return 400; + } + return 409; +} diff --git a/src/core/liquidity-withdrawals.mjs b/src/core/liquidity-withdrawals.mjs new file mode 100644 index 0000000..c49b38e --- /dev/null +++ b/src/core/liquidity-withdrawals.mjs @@ -0,0 +1,57 @@ +export function buildBridgeWithdrawalPlan({ + assetId, + amount, + destinationAddress, + chain = null, + supportedTokens = {}, + config, +}) { + const normalizedAssetId = String(assetId || '').trim(); + const normalizedAmount = String(amount || '').trim(); + const normalizedDestination = String(destinationAddress || '').trim(); + const requestedChain = chain == null ? null : String(chain).trim(); + + if (!normalizedAssetId) throw new Error('asset_id is required'); + if (!/^\d+$/.test(normalizedAmount) || BigInt(normalizedAmount) <= 0n) { + throw new Error('amount must be a positive integer string in smallest units'); + } + if (!normalizedDestination) throw new Error('destination_address is required'); + + const asset = config.assetRegistry.get(normalizedAssetId); + if (!asset) throw new Error(`unsupported asset_id: ${normalizedAssetId}`); + if (requestedChain && requestedChain !== asset.chain) { + throw new Error(`chain mismatch for ${normalizedAssetId}: expected ${asset.chain}`); + } + + const nearTokenId = stripAssetPrefix(normalizedAssetId); + const supported = Object.values(supportedTokens).find((token) => ( + token.near_token_id === nearTokenId + && String(token.defuse_asset_identifier || '').startsWith(`${asset.chain}:`) + )); + + if (!supported) { + throw new Error(`unsupported bridge withdrawal token: ${normalizedAssetId}`); + } + + const minWithdrawalAmount = String(supported.min_withdrawal_amount || '0'); + if (BigInt(normalizedAmount) < BigInt(minWithdrawalAmount)) { + throw new Error(`amount below minimum withdrawal: ${minWithdrawalAmount}`); + } + + return { + asset_id: normalizedAssetId, + amount: normalizedAmount, + chain: asset.chain, + destination_address: normalizedDestination, + near_token_id: supported.near_token_id, + defuse_asset_identifier: supported.defuse_asset_identifier, + receiver_id: supported.near_token_id, + memo: `WITHDRAW_TO:${normalizedDestination}`, + min_withdrawal_amount: minWithdrawalAmount, + withdrawal_fee: String(supported.withdrawal_fee || '0'), + }; +} + +function stripAssetPrefix(assetId) { + return String(assetId || '').replace(/^nep141:/, ''); +} diff --git a/src/venues/near-intents/bridge-client.mjs b/src/venues/near-intents/bridge-client.mjs index 46f33c6..8c1f5e4 100644 --- a/src/venues/near-intents/bridge-client.mjs +++ b/src/venues/near-intents/bridge-client.mjs @@ -39,6 +39,13 @@ export function createNearBridgeClient({ rpcUrl }) { withdrawal_hash: withdrawalHash, }); }, + withdrawalEstimate({ chain, token, address }) { + return rpc('withdrawal_estimate', { + chain, + token, + address, + }); + }, notifyDeposit({ depositAddress, txHash }) { return rpc('notify_deposit', { deposit_address: depositAddress, diff --git a/src/venues/near-intents/verifier-client.mjs b/src/venues/near-intents/verifier-client.mjs index 5393c4f..2cc8146 100644 --- a/src/venues/near-intents/verifier-client.mjs +++ b/src/venues/near-intents/verifier-client.mjs @@ -1,14 +1,18 @@ -import { JsonRpcProvider, KeyPair } from 'near-api-js'; +import { Account, JsonRpcProvider, KeyPair, teraToGas } from 'near-api-js'; import { postJson } from '../../lib/http.mjs'; export function createVerifierClient({ nearRpcUrl, verifierContract, + accountId = '', signerPrivateKey = '', }) { const provider = new JsonRpcProvider({ url: nearRpcUrl }); const signer = signerPrivateKey ? KeyPair.fromString(signerPrivateKey) : null; + const operatorAccount = accountId && signerPrivateKey + ? new Account(accountId, provider, signerPrivateKey) + : null; return { async currentSalt() { @@ -37,6 +41,31 @@ export function createVerifierClient({ if (typeof result === 'boolean') return result; return null; }, + async ftWithdrawRaw({ + token, + receiverId, + amount, + memo = null, + msg = null, + storageDeposit = null, + }) { + if (!operatorAccount) throw new Error('operator account is not configured'); + + return operatorAccount.callFunctionRaw({ + contractId: verifierContract, + methodName: 'ft_withdraw', + args: compact({ + token, + receiver_id: receiverId, + amount: String(amount), + memo, + msg, + storage_deposit: storageDeposit, + }), + gas: teraToGas('100'), + deposit: 1n, + }); + }, getSigner() { return signer; }, @@ -76,3 +105,9 @@ async function callView(provider, accountId, methodName, args) { const text = Buffer.from(bytes).toString('utf8'); return text ? JSON.parse(text) : null; } + +function compact(record) { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value != null), + ); +} diff --git a/test/liquidity-withdrawals.test.mjs b/test/liquidity-withdrawals.test.mjs new file mode 100644 index 0000000..b79fccc --- /dev/null +++ b/test/liquidity-withdrawals.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildBridgeWithdrawalPlan } from '../src/core/liquidity-withdrawals.mjs'; + +const config = { + assetRegistry: new Map([ + ['nep141:btc.omft.near', { + assetId: 'nep141:btc.omft.near', + chain: 'btc:mainnet', + }], + ['nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', { + assetId: 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + chain: 'eth:100', + }], + ]), +}; + +test('buildBridgeWithdrawalPlan creates an external-chain EURe withdrawal plan', () => { + const plan = buildBridgeWithdrawalPlan({ + assetId: 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + amount: '5000000000000000000', + destinationAddress: '0x62bda91ac00CCa4e87cE3915Db56DF06773A1747', + supportedTokens: { + eure: { + near_token_id: 'gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + defuse_asset_identifier: 'eth:100:0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430', + min_withdrawal_amount: '1', + withdrawal_fee: '200000000000', + }, + }, + config, + }); + + assert.deepEqual(plan, { + asset_id: 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + amount: '5000000000000000000', + chain: 'eth:100', + destination_address: '0x62bda91ac00CCa4e87cE3915Db56DF06773A1747', + near_token_id: 'gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + defuse_asset_identifier: 'eth:100:0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430', + receiver_id: 'gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + memo: 'WITHDRAW_TO:0x62bda91ac00CCa4e87cE3915Db56DF06773A1747', + min_withdrawal_amount: '1', + withdrawal_fee: '200000000000', + }); +}); + +test('buildBridgeWithdrawalPlan rejects amounts below the bridge minimum', () => { + assert.throws(() => buildBridgeWithdrawalPlan({ + assetId: 'nep141:btc.omft.near', + amount: '9999', + destinationAddress: 'bc1qexample', + supportedTokens: { + btc: { + near_token_id: 'btc.omft.near', + defuse_asset_identifier: 'btc:mainnet:native', + min_withdrawal_amount: '10000', + withdrawal_fee: '1500', + }, + }, + config, + }), /amount below minimum withdrawal: 10000/); +});