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:
parent
57eb540b6e
commit
3f0a119987
6 changed files with 378 additions and 1 deletions
|
|
@ -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":"<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
|
||||
|
||||
1. Confirm `market-reference-ingest` is publishing fresh BTC/EUR data.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
57
src/core/liquidity-withdrawals.mjs
Normal file
57
src/core/liquidity-withdrawals.mjs
Normal 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:/, '');
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
64
test/liquidity-withdrawals.test.mjs
Normal file
64
test/liquidity-withdrawals.test.mjs
Normal 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/);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue