Add configured withdrawal defaults
All checks were successful
deploy / deploy (push) Successful in 22s

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.
This commit is contained in:
philipp 2026-04-02 12:38:19 +02:00
parent 3f0a119987
commit b4186d9715
7 changed files with 48 additions and 7 deletions

View file

@ -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

View file

@ -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"

View file

@ -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:<destination>`, 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

View file

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

View file

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

View file

@ -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;

View file

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