Add operator withdrawal path
All checks were successful
deploy / deploy (push) Successful in 24s

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:<destination>.

Still fake: Strategy and executor remain disarmed, no live inventory is credited yet, and no live mainnet trade quote has been submitted.
This commit is contained in:
philipp 2026-04-02 12:24:59 +02:00
parent 57eb540b6e
commit 3f0a119987
6 changed files with 378 additions and 1 deletions

View file

@ -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:8083/refresh
curl -s -X POST http://127.0.0.1:8084/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/arm
curl -s -X POST http://127.0.0.1:8086/disarm curl -s -X POST http://127.0.0.1:8086/disarm
curl -s -X PUT http://127.0.0.1:8086/limits \ 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":"<near-burn-tx-hash>","asset_id":"nep141:btc.omft.near","chain":"btc:mainnet","amount":"1000"}' -d '{"withdrawal_hash":"<near-burn-tx-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:<destination>`, 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 ## Safe arming sequence
1. Confirm `market-reference-ingest` is publishing fresh BTC/EUR data. 1. Confirm `market-reference-ingest` is publishing fresh BTC/EUR data.

View file

@ -5,10 +5,12 @@ import { startControlApi } from '../core/control-api.mjs';
import { buildEventEnvelope } from '../core/event-envelope.mjs'; import { buildEventEnvelope } from '../core/event-envelope.mjs';
import { createJsonStateStore } from '../core/json-state-store.mjs'; import { createJsonStateStore } from '../core/json-state-store.mjs';
import { normalizeLiquidityState } from '../core/liquidity-state.mjs'; import { normalizeLiquidityState } from '../core/liquidity-state.mjs';
import { buildBridgeWithdrawalPlan } from '../core/liquidity-withdrawals.mjs';
import { createLogger, serializeError } from '../core/log.mjs'; import { createLogger, serializeError } from '../core/log.mjs';
import { assertLiquidityActionEvent } from '../core/schemas.mjs'; import { assertLiquidityActionEvent } from '../core/schemas.mjs';
import { loadConfig } from '../lib/config.mjs'; import { loadConfig } from '../lib/config.mjs';
import { createNearBridgeClient } from '../venues/near-intents/bridge-client.mjs'; import { createNearBridgeClient } from '../venues/near-intents/bridge-client.mjs';
import { createVerifierClient } from '../venues/near-intents/verifier-client.mjs';
const config = loadConfig(); const config = loadConfig();
const logger = createLogger({ const logger = createLogger({
@ -33,6 +35,12 @@ const producer = await createProducer({
logger, logger,
}); });
const bridgeClient = createNearBridgeClient({ rpcUrl: config.nearBridgeRpcUrl }); const bridgeClient = createNearBridgeClient({ rpcUrl: config.nearBridgeRpcUrl });
const verifierClient = createVerifierClient({
nearRpcUrl: config.nearRpcUrl,
verifierContract: config.nearVerifierContract,
accountId: config.nearIntentsAccountId,
signerPrivateKey: config.nearIntentsSignerPrivateKey,
});
const store = createJsonStateStore({ const store = createJsonStateStore({
stateDir: config.liquidityStateDir, stateDir: config.liquidityStateDir,
fileName: 'liquidity.json', fileName: 'liquidity.json',
@ -45,6 +53,8 @@ const store = createJsonStateStore({
supported_tokens: {}, supported_tokens: {},
last_refresh_at: null, last_refresh_at: null,
last_error: null, last_error: null,
last_withdrawal_request: null,
last_withdrawal_result: null,
publish_count: 0, 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) { async function publishAction(payload, state) {
const event = buildEventEnvelope({ const event = buildEventEnvelope({
source: 'liquidity-manager', source: 'liquidity-manager',
@ -260,6 +370,46 @@ const controlApi = startControlApi({
return { ok: true, withdrawals_frozen: state.withdrawals_frozen }; 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', method: 'POST',
path: '/track-withdrawal', path: '/track-withdrawal',
@ -296,6 +446,39 @@ const controlApi = startControlApi({
return { ok: true, tracked: state.tracked_withdrawals[body.withdrawal_hash] }; 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', method: 'POST',
path: '/notify-deposit', path: '/notify-deposit',
@ -340,3 +523,16 @@ function mapDepositAssetId(defuseAssetIdentifier, chain) {
if (chain === config.tradingEure.chain) return config.tradingEure.assetId; if (chain === config.tradingEure.chain) return config.tradingEure.assetId;
return defuseAssetIdentifier; 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;
}

View file

@ -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:/, '');
}

View file

@ -39,6 +39,13 @@ export function createNearBridgeClient({ rpcUrl }) {
withdrawal_hash: withdrawalHash, withdrawal_hash: withdrawalHash,
}); });
}, },
withdrawalEstimate({ chain, token, address }) {
return rpc('withdrawal_estimate', {
chain,
token,
address,
});
},
notifyDeposit({ depositAddress, txHash }) { notifyDeposit({ depositAddress, txHash }) {
return rpc('notify_deposit', { return rpc('notify_deposit', {
deposit_address: depositAddress, deposit_address: depositAddress,

View file

@ -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'; import { postJson } from '../../lib/http.mjs';
export function createVerifierClient({ export function createVerifierClient({
nearRpcUrl, nearRpcUrl,
verifierContract, verifierContract,
accountId = '',
signerPrivateKey = '', signerPrivateKey = '',
}) { }) {
const provider = new JsonRpcProvider({ url: nearRpcUrl }); const provider = new JsonRpcProvider({ url: nearRpcUrl });
const signer = signerPrivateKey ? KeyPair.fromString(signerPrivateKey) : null; const signer = signerPrivateKey ? KeyPair.fromString(signerPrivateKey) : null;
const operatorAccount = accountId && signerPrivateKey
? new Account(accountId, provider, signerPrivateKey)
: null;
return { return {
async currentSalt() { async currentSalt() {
@ -37,6 +41,31 @@ export function createVerifierClient({
if (typeof result === 'boolean') return result; if (typeof result === 'boolean') return result;
return null; 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() { getSigner() {
return signer; return signer;
}, },
@ -76,3 +105,9 @@ async function callView(provider, accountId, methodName, args) {
const text = Buffer.from(bytes).toString('utf8'); const text = Buffer.from(bytes).toString('utf8');
return text ? JSON.parse(text) : null; return text ? JSON.parse(text) : null;
} }
function compact(record) {
return Object.fromEntries(
Object.entries(record).filter(([, value]) => value != null),
);
}

View file

@ -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/);
});