From b4186d9715deaab41803e9a901f3614e4308b2f6 Mon Sep 17 00:00:00 2001 From: philipp Date: Thu, 2 Apr 2026 12:38:19 +0200 Subject: [PATCH] Add configured withdrawal defaults Proof: The funded NEAR Intents operator path should have a stable configured withdrawal destination for the active assets so exits do not depend on retyping recipient addresses. Assumptions: Active asset withdrawal destinations are long-lived operator settings and can safely live in runtime config; actual withdrawals still require explicit unfreeze and operator action. Still fake: Strategy and executor remain disarmed, no live trade quote has been submitted, and the live withdrawal transaction itself has not been exercised yet. --- .env.example | 2 ++ deploy/k8s/base/unrip.yaml | 2 ++ docs/operator-runbook.md | 1 + src/apps/liquidity-manager.mjs | 12 ++++++++---- src/core/liquidity-withdrawals.mjs | 8 ++++++-- src/lib/config.mjs | 9 ++++++++- test/liquidity-withdrawals.test.mjs | 21 +++++++++++++++++++++ 7 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 763db8e..4faacd5 100644 --- a/.env.example +++ b/.env.example @@ -14,10 +14,12 @@ TRADING_BTC_ASSET_ID=nep141:btc.omft.near TRADING_BTC_SYMBOL=BTC TRADING_BTC_DECIMALS=8 TRADING_BTC_CHAIN=btc:mainnet +TRADING_BTC_WITHDRAW_ADDRESS= TRADING_EURE_ASSET_ID=nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near TRADING_EURE_SYMBOL=EURe TRADING_EURE_DECIMALS=18 TRADING_EURE_CHAIN=eth:100 +TRADING_EURE_WITHDRAW_ADDRESS= # Control APIs NEAR_INTENTS_CONTROL_API_ENABLED=true diff --git a/deploy/k8s/base/unrip.yaml b/deploy/k8s/base/unrip.yaml index cd95af2..dc64416 100644 --- a/deploy/k8s/base/unrip.yaml +++ b/deploy/k8s/base/unrip.yaml @@ -17,10 +17,12 @@ data: TRADING_BTC_SYMBOL: BTC TRADING_BTC_DECIMALS: "8" TRADING_BTC_CHAIN: btc:mainnet + TRADING_BTC_WITHDRAW_ADDRESS: "" TRADING_EURE_ASSET_ID: nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near TRADING_EURE_SYMBOL: EURe TRADING_EURE_DECIMALS: "18" TRADING_EURE_CHAIN: "eth:100" + TRADING_EURE_WITHDRAW_ADDRESS: "0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb" NEAR_INTENTS_CONTROL_API_ENABLED: "true" NEAR_INTENTS_CONTROL_HOST: 0.0.0.0 NEAR_INTENTS_CONTROL_PORT: "8081" diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index fd7b00f..c1ec1b2 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -116,6 +116,7 @@ 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`. +- `destination_address` can be omitted only when a default withdrawal address is configured for that asset via `TRADING_BTC_WITHDRAW_ADDRESS` or `TRADING_EURE_WITHDRAW_ADDRESS`. - 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 diff --git a/src/apps/liquidity-manager.mjs b/src/apps/liquidity-manager.mjs index b73a81c..b4dddab 100644 --- a/src/apps/liquidity-manager.mjs +++ b/src/apps/liquidity-manager.mjs @@ -304,6 +304,10 @@ const controlApi = startControlApi({ getState() { return { account_id: config.nearIntentsAccountId, + withdrawal_defaults: { + [config.tradingBtc.assetId]: config.tradingBtc.withdrawAddress || null, + [config.tradingEure.assetId]: config.tradingEure.withdrawAddress || null, + }, ...store.getState(), }; }, @@ -374,10 +378,10 @@ const controlApi = startControlApi({ method: 'POST', path: '/withdrawal-estimate', handler: async ({ body }) => { - if (!body.asset_id || !body.amount || !body.destination_address) { + if (!body.asset_id || !body.amount) { return { statusCode: 400, - payload: { error: 'asset_id, amount, and destination_address are required' }, + payload: { error: 'asset_id and amount are required' }, }; } @@ -450,10 +454,10 @@ const controlApi = startControlApi({ method: 'POST', path: '/withdraw', handler: async ({ body }) => { - if (!body.asset_id || !body.amount || !body.destination_address) { + if (!body.asset_id || !body.amount) { return { statusCode: 400, - payload: { error: 'asset_id, amount, and destination_address are required' }, + payload: { error: 'asset_id and amount are required' }, }; } diff --git a/src/core/liquidity-withdrawals.mjs b/src/core/liquidity-withdrawals.mjs index c49b38e..31873bf 100644 --- a/src/core/liquidity-withdrawals.mjs +++ b/src/core/liquidity-withdrawals.mjs @@ -8,20 +8,20 @@ export function buildBridgeWithdrawalPlan({ }) { const normalizedAssetId = String(assetId || '').trim(); const normalizedAmount = String(amount || '').trim(); - const normalizedDestination = String(destinationAddress || '').trim(); + const requestedDestination = 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 normalizedDestination = requestedDestination || String(asset.withdrawAddress || '').trim(); const nearTokenId = stripAssetPrefix(normalizedAssetId); const supported = Object.values(supportedTokens).find((token) => ( @@ -33,6 +33,10 @@ export function buildBridgeWithdrawalPlan({ throw new Error(`unsupported bridge withdrawal token: ${normalizedAssetId}`); } + if (!normalizedDestination) { + throw new Error(`destination_address is required for ${normalizedAssetId}`); + } + const minWithdrawalAmount = String(supported.min_withdrawal_amount || '0'); if (BigInt(normalizedAmount) < BigInt(minWithdrawalAmount)) { throw new Error(`amount below minimum withdrawal: ${minWithdrawalAmount}`); diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 9dcca0f..2e88d99 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -41,10 +41,12 @@ const DEFAULTS = { tradingBtcSymbol: 'BTC', tradingBtcDecimals: 8, tradingBtcChain: 'btc:mainnet', + tradingBtcWithdrawAddress: '', tradingEureAssetId: 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', tradingEureSymbol: 'EURe', tradingEureDecimals: 18, tradingEureChain: 'eth:100', + tradingEureWithdrawAddress: '', marketReferenceRefreshMs: 5_000, marketReferenceCoinGeckoRefreshMs: 15_000, marketReferenceMaxAgeMs: 30_000, @@ -84,12 +86,13 @@ function parseBoolean(value, fallback) { return fallback; } -function buildAsset({ assetId, symbol, decimals, chain }) { +function buildAsset({ assetId, symbol, decimals, chain, withdrawAddress = '' }) { return { assetId, symbol, decimals, chain, + withdrawAddress, }; } @@ -101,12 +104,16 @@ export function loadConfig({ envPath = '.env' } = {}) { symbol: process.env.TRADING_BTC_SYMBOL || DEFAULTS.tradingBtcSymbol, decimals: parseNumber(process.env.TRADING_BTC_DECIMALS, DEFAULTS.tradingBtcDecimals), chain: process.env.TRADING_BTC_CHAIN || DEFAULTS.tradingBtcChain, + withdrawAddress: + process.env.TRADING_BTC_WITHDRAW_ADDRESS || DEFAULTS.tradingBtcWithdrawAddress, }); const tradingEure = buildAsset({ assetId: process.env.TRADING_EURE_ASSET_ID || DEFAULTS.tradingEureAssetId, symbol: process.env.TRADING_EURE_SYMBOL || DEFAULTS.tradingEureSymbol, decimals: parseNumber(process.env.TRADING_EURE_DECIMALS, DEFAULTS.tradingEureDecimals), chain: process.env.TRADING_EURE_CHAIN || DEFAULTS.tradingEureChain, + withdrawAddress: + process.env.TRADING_EURE_WITHDRAW_ADDRESS || DEFAULTS.tradingEureWithdrawAddress, }); const projectName = process.env.PROJECT_NAME || DEFAULTS.projectName; diff --git a/test/liquidity-withdrawals.test.mjs b/test/liquidity-withdrawals.test.mjs index b79fccc..9efefd1 100644 --- a/test/liquidity-withdrawals.test.mjs +++ b/test/liquidity-withdrawals.test.mjs @@ -8,10 +8,12 @@ const config = { ['nep141:btc.omft.near', { assetId: 'nep141:btc.omft.near', chain: 'btc:mainnet', + withdrawAddress: '', }], ['nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', { assetId: 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', chain: 'eth:100', + withdrawAddress: '0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb', }], ]), }; @@ -62,3 +64,22 @@ test('buildBridgeWithdrawalPlan rejects amounts below the bridge minimum', () => config, }), /amount below minimum withdrawal: 10000/); }); + +test('buildBridgeWithdrawalPlan falls back to configured asset withdrawal address', () => { + const plan = buildBridgeWithdrawalPlan({ + assetId: 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + amount: '1000000000000000000', + destinationAddress: '', + supportedTokens: { + eure: { + near_token_id: 'gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near', + defuse_asset_identifier: 'eth:100:0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430', + min_withdrawal_amount: '1', + withdrawal_fee: '200000000000', + }, + }, + config, + }); + + assert.equal(plan.destination_address, '0x6C40267e03A97B2132e7a7d3159C88534eBEfdFb'); +});