Instant Withdrawal

Prerequisites

Before you run the instant‐withdrawal script, make sure you have everything in place:

  1. Install Node.js

  2. Create a project directory

    mkdir symmio-instant-withdrawal
    cd symmio-instant-withdrawal
  3. Initialize a new package.json

    npm init -y
  4. Install required NPM packages The script uses:

    • dotenv for environment‐variable loading

    • axios for HTTP calls

    • ethers for EVM wallet + RPC

    • web3 for the multi‐account wrapper calls

    Run:

    npm install dotenv axios ethers web3
  5. 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)

All contract addresses can be found here.

  1. 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…   

All Muon services can be found here.


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 custom expirationTime 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;
}

This must be called with the sub-account address

    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

  1. Fetch a Muon signature that attests to your current uPnl.

  2. Format that signature into the SingleUpnlSig struct the contract expects.

  3. ABI-encode the deallocate(uint256 amount, SingleUpnlSig upnlSig) call.

  4. Wrap it in your multi-account proxy (_call) so that msg.sender is your sub-account.

  5. 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` +
    `&params%5BpartyA%5D=${accountAddress}` +
    `&params%5BchainId%5D=${CHAIN_ID}` +
    `&params%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