Instant Withdrawal
Prerequisites
Before you run the instant‐withdrawal script, make sure you have everything in place:
Install Node.js
Download and install Node.js v16+ (comes with
npm
) from https://nodejs.org/.
Create a project directory
mkdir symmio-instant-withdrawal cd symmio-instant-withdrawal
Initialize a new
package.json
npm init -y
Install required NPM packages The script uses:
dotenv
for environment‐variable loadingaxios
for HTTP callsethers
for EVM wallet + RPCweb3
for the multi‐account wrapper calls
Run:
npm install dotenv axios ethers web3
ABI files Make sure you have the two ABI JSON files in
./abi/
:DIAMOND_ABI.json
(your Symmio Diamond)MULTI_ACCOUNT_ABI.json
(your multi‐account proxy)
Prepare your
.env
In the project root, create a.env
file containing exactly the variables from our guide (see next section). Never commit.env
to source control.
Once these steps are done, you’re ready to configure your environment variables and move on to the login & withdrawal flow.
Environment Variables
# Your EOA (wallet) — used for SIWE login & gas‐payment
PRIVATE_KEY=0x… # your wallet’s private key
ACTIVE_ACCOUNT=0x… # same as WALLET_ADDRESS below
WALLET_ADDRESS=0x… # your wallet address (checksum)
# RPC & HTTP endpoints
RPC_URL=https://rpc.ankr.com/base/…
BASE_URL=https://instant-withdrawal-base.symmio.foundation
DOMAIN=localhost
ORIGIN=http://localhost:3000
CHAIN_ID=8453 # Base chain ID
# Token amounts (strings)
AMOUNT_WEI=1000000000000000000 # 1 USDC in 18-decimals for deallocate()
BRIDGE_AMOUNT_U6=1000000 # 1 USDC in 6-decimals for transferToBridge()
# Contract addresses
SYMMIO_DIAMOND_ADDRESS=0x… # your Symmio Diamond
MULTI_ACCOUNT_ADDRESS=0x… # your multi-account proxy
BRIDGE_ADDRESS=0x… # the registered bridge
# Off-chain services
MUON_URL=https://muon-oracle2.rasa.capital/v1/
# Your sub-account (msg.sender on-chain)
VIBE_SUBACCOUNT=0x…
Instant Withdrawal Flow
SIWE Login
Before you can call any of the Instant-Withdrawal API endpoints, you must authenticate yourself and prove ownership of your Ethereum wallet. We use Sign-In With Ethereum (SIWE) to:
Verify that you control the private key for your
ACTIVE_ACCOUNT
Issue a short-lived JSON Web Token (JWT) that the API will accept as proof of identity on subsequent calls
Below is an example script that obtains the access token:
// — 1) SIWE login → JWT —————————————————————————
async function login() {
// Fetch nonce
const { data: { nonce } } = await axios.post(
`${BASE_URL}/v1/auth/nonce`, { address: account }
);
// Fetch message + params
const { data: { message: raw, params } } = await axios.get(
`${BASE_URL}/v1/auth/sign-in-message`,
{ params: { address: account, domain: DOMAIN, uri: ORIGIN } }
);
// Sign & package
const signature = await wallet.signMessage(raw);
const payload = {
domain: params.domain,
address: params.address,
uri: params.uri,
version: params.version,
chainId: params.chainId,
issuedAt: params.issuedAt,
nonce: params.nonce,
statement: params.statement,
expirationTime: params.expiration_time,
};
// Exchange for JWT
const resp = await axios.post(
`${BASE_URL}/v1/auth/login`,
{ message: payload, signature },
{ headers: { "Content-Type": "application/json" } }
);
console.log("→ /v1/auth/login:", resp.data);
const token = resp.data.accessToken;
if (!token) throw new Error("Login failed; no accessToken");
return token;
}
What You Get
accessToken
(JWT): A bearer token you must include on every HTTP request:Authorization: Bearer <your-accessToken>
Expiration: The token is valid until the
exp
claim in its payload. SIWE messages themselves can also include a customexpirationTime
to limit replay window.
Lock & Fetch Fee Options
Before touching any on-chain collateral, you first ask the backend to compute and reserve your available instant-withdrawal policies for the exact amount you want. This call is purely off-chain—no transactions are sent, and no gas is spent—but it locks those policies for a short window (typically ~30 seconds) so you can finalize on-chain steps without worrying the fee or cooldown will change mid-flow.
▶️ HTTP Request
POST /v1/fee-options?account=<SUBACCOUNT>&amount=<AMOUNT>
Host: instant-withdrawal-base.symmio.foundation
Authorization: Bearer <your-JWT>
Content-Type: application/json
Query Parameters
account
(string): your sub-account address (checksum)amount
(integer): the withdrawal amount in token units (wei for 6-decimals USDC! e.g.1000000
for 1 USDC)
Headers
Authorization: Bearer <accessToken>
(from your SIWE login)
Body
Empty. (Axios will send
{}
if you pass an empty object—no other fields required.)
Example Code
async function fetchFeeOptions(token, account) {
const base = BASE_URL.replace(/\/+$/, "");
const resp = await axios.post(
`${base}/v1/fee-options`,
{},
{
params: {
account, // checksumed address
amount: BRIDGE_AMOUNT_U6 // integer, in wei (USDC is 6 decimals on Base)
},
headers: {
Authorization: `Bearer ${token}`
}
}
);
return resp.data.options;
}
console.log("2) Locking fee options…");
const options = await fetchFeeOptions(token, VIBE_SUBACCOUNT);
}))); //Finding the lowest cooldown policy
▶️ Sample Response
[
{
"fee": 10100, // you’ll pay 0.010100 USDC
"cooldown": 10, // withdrawal executes in 10 s
"validTime": 1751895414 // lock expires at this Unix timestamp
},
{
"fee": 10000, // you pay 0.010000 USDC
"cooldown": 43200, // executes after 12 hours
"validTime": 1751895414
}
]
Picking your Policy
Of this set of options, you can pick the policy with the lowest cooldown like this:
// Choose the *fastest* execution (smallest cooldown)
const fastest = options.reduce((min, p) =>
p.cooldown < min.cooldown ? p : min
, options[0]);
Deallocating Funds
Before you can instantly withdraw, you must deallocate (i.e. free) the amount of collateral you previously allocated to trading. Symmio requires a uPNL signature from Muon to verify that you maintain sufficient margin; this happens off-chain via a Muon oracle call.
Flow
Fetch a Muon signature that attests to your current
uPnl
.Format that signature into the
SingleUpnlSig
struct the contract expects.ABI-encode the
deallocate(uint256 amount, SingleUpnlSig upnlSig)
call.Wrap it in your multi-account proxy (
_call
) so thatmsg.sender
is your sub-account.Estimate gas (with a buffer) and send the transaction (paid by your EOA).
Sample Script
async function deallocateForAccount(accountAddress, amountUnits) {
console.log("▶️ Starting deallocation for", accountAddress);
// 1) Build the Muon URL for uPnL_A method (for deallocation)
const base = muonUrl.endsWith("/") ? muonUrl : muonUrl + "/";
const fullUrl = `${muonUrl}?app=symmio&method=uPnl_A` +
`¶ms%5BpartyA%5D=${accountAddress}` +
`¶ms%5BchainId%5D=${CHAIN_ID}` +
`¶ms%5Bsymmio%5D=${SYMMIO_DIAMOND_ADDRESS}`;
console.log(" → Muon URL:", fullUrl);
// 2) Fetch the Muon signature
const resp = await axios.get(fullUrl);
if (!resp.data.success) {
throw new Error("Muon sig failure: " + JSON.stringify(resp.data));
}
const muon = resp.data.result;
const {
reqId,
data: {
timestamp,
result: { uPnl },
init: { nonceAddress }
},
nodeSignature: gatewaySignature,
signatures: [{ signature, owner }]
} = muon;
const upnlSigFormatted = {
reqId: web3.utils.hexToBytes(reqId),
timestamp: BigInt(timestamp).toString(),
upnl: BigInt(uPnl).toString(),
gatewaySignature: web3.utils.hexToBytes(gatewaySignature),
sigs: {
signature,
owner,
nonce: nonceAddress
}
};
console.log(`▶️ Deallocating ${amountUnits} units…`);
// 5) ABI-encode the deallocate call
const dataEncoded = web3.eth.abi.encodeFunctionCall(
deallocateFunctionAbi,
[ amountUnits.toString(), upnlSigFormatted ]
);
const callData = [ accountAddress, [ dataEncoded ] ];
const estimatedGas = await multiAccountContract.methods
._call(...callData)
.estimateGas({ from: process.env.WALLET_ADDRESS });
let gasLimit;
if (typeof estimatedGas === "bigint") {
gasLimit = (estimatedGas * BigInt(3)) / BigInt(2);
} else {
gasLimit = Math.floor(estimatedGas * 1.5);
}
const gasPrice = await web3.eth.getGasPrice();
// 7) Send the transaction
const receipt = await multiAccountContract.methods
._call(...callData)
.send({
from: process.env.WALLET_ADDRESS,
gas: gasLimit.toString(),
gasPrice
});
console.log(" Deallocation tx mined:", receipt.transactionHash);
return receipt;
}
Implementation
await deallocateForAccount(VIBE_SUBACCOUNT, AMOUNT_WEI); //Deallocating from the subaccount with 1e18
Transfer to Bridge On-Chain
After freeing your collateral, the next on-chain step is to transfer that free balance into the Instant-Withdrawal bridge, bypassing any further cooldown. This creates a bridge transaction from which you will then select your fee policy.
Sample Script
async function transferToBridgeForAccount(accountAddress, amountUnits) {
// 1) ABI-encode the Diamond's transferToBridge facet
const encodedTransferData = web3.eth.abi.encodeFunctionCall(
transferToBridgeFunctionAbi,
[ amountUnits.toString(), BRIDGE_ADDRESS ]
);
// 2) Wrap in the multi-account proxy _call
const wrapped = multiAccountContract.methods._call(
accountAddress,
[ encodedTransferData ]
);
// 3) Log Tenderly payload
const txData = wrapped.encodeABI();
const rawGas = await wrapped.estimateGas({ from: process.env.WALLET_ADDRESS });
const gasLimit = typeof rawGas === "bigint"
? (rawGas * 3n) / 2n
: Math.floor(Number(rawGas) * 1.5);
const gasPrice = await web3.eth.getGasPrice();
console.log(` ↪️ Sending tx (gasLimit=${gasLimit}, gasPrice=${gasPrice})`);
const receipt = await wrapped.send({
from: process.env.WALLET_ADDRESS,
gas: gasLimit.toString(),
gasPrice
});
console.log("✅ transferToBridge tx mined:", receipt.transactionHash);
return receipt;
}
When you call the wrapped _call(...).send()
, the Symmio Diamond contract will emit a TransferToBridge
event that includes your new transactionId
. This is the ID you’ll need in the final step to select your fee policy.
Event signature
event TransferToBridge( address indexed user, uint256 amount, address indexed bridgeAddress, uint256 transactionId ); If you’d rather automate it, you can inject a small snippet into your script immediately after sending:
// inside transferToBridgeForAccount, after receipt = await wrapped.send(...)
const iface = new ethers.utils.Interface(DIAMOND_ABI);
for (const log of receipt.logs) {
try {
const parsed = iface.parseLog(log);
if (parsed.name === "TransferToBridge") {
console.log("→ bridge transactionId (from event):", parsed.args.transactionId.toString());
}
} catch {}
}
Fetching Pending Fee Policies
After your on-chain bridge transaction is mined, you can once again retrieve the exact fee/cooldown options that were locked earlier by your /fee-options
call. These are exposed via a dedicated endpoint:
GET /v1/pending-fee-policy/{account}
Sample Script
async function fetchPendingFeePolicies(token) {
const base = BASE_URL.replace(/\/+$/, "");
const headers = { Authorization: `Bearer ${token}` };
const resp = await axios.get(
`${base}/v1/pending-fee-policy/${VIBE_SUBACCOUNT}`,
{ headers }
);
const policies = resp.data.policies || resp.data.options;
if (!policies || policies.length === 0) {
throw new Error("No pending fee policies found for account");
}
console.log("→ Retrieved pending fee policies:", policies);
return policies;
}
Submit Fee Policy Selection
With your on‐chain bridge transaction done and your fastest policy chosen, the final step is to tell the backend “yes, I agree to pay this fee after this cooldown.” The server will validate your choice, call the on‐chain selectFeePolicyForBridge
action, and schedule processWithdrawal(bridgeId)
for you.
// 4) Submit your selection
async function selectFeePolicy(token, bridgeId, policy) {
const r = await axios.post(
`${BASE_URL.replace(/\/+$/,"")}/v1/select-fee-policy`,
{
symmioBridgeId: bridgeId,
cooldown: policy.cooldown,
feeAmount: policy.fee
// receiver: { address, signature } // if you need to override
},
{ headers: { Authorization: `Bearer ${token}` } }
);
console.log("→ select-fee-policy response:", r.data);
return r.data;
}
Implementation
console.log("6) Submitting fee policy…");
const result = await selectFeePolicy(token, chainBridgeId, fastest);
console.log(
"✅ Withdrawal scheduled for:",
new Date(result.execution_time * 1000).toLocaleString()
Last updated