Somnia Accounts / How-to guides

How to move funds between accounts

This guide shows you how to move native value and ERC-20 tokens out of one account into another, including out of a sub-account, which is the direction that needs more authority than it looks.

The direction is in the signature

from is the account that executes. So its balance moves, its nonce is used, and its authority chain is the one that has to be satisfied.

Funding a sub-account and emptying it are the same call with the two addresses swapped — but they are not the same authority. A main account is usually reachable by a key directly; a sub-account is reached through it.

Down: main account to sub-account

import {readTransferPlan} from "@somnia-chain/accounts-sdk";

const plan = await readTransferPlan(client, {
  chainId: chain.id,
  from: account, // the main account executes
  to: other, // the sub-account
  native: 10n ** 16n,
  tokens: [{token, amount}],
});
void plan;

An ordinary execution: the key owns from directly, so through is empty.

Up: sub-account back to the main account

import {readTransferPlan} from "@somnia-chain/accounts-sdk";

const plan = await readTransferPlan(client, {
  chainId: chain.id,
  from: other, // the SUB-account executes
  to: account,
  native: 10n ** 16n,
  through: [account], // …and its authority comes through the main account
});
void plan;

through: [account] is the whole difference. Leave it off and the plan is built for a signer that does not own from, which fails as GS026 — a code that also means "wrong signature" and "signers out of order", so it tells you nothing about the real mistake.

authorityPath(client, from, signerAddress) computes that list if you would rather not hardcode it.

Then sign and send it

A transfer plan is an ordinary ExecutionPlan, so everything from How to act without a signature and the executor guide applies:

import {prepareExecution, signAndSubmit} from "@somnia-chain/accounts-sdk";

const {hash} = await signAndSubmit(client, {
  prepared: prepareExecution(plan, signer.address),
  signer,
  sender,
  chain,
});
void hash;

Reading balances first

import {readHoldings, readTokenInfo} from "@somnia-chain/accounts-sdk";

// Native plus whichever tokens you name — there is no token list on chain to enumerate.
const holdings = await readHoldings(client, account, [token]);
const info = await readTokenInfo(client, token); // symbol, decimals, for display
void [holdings, info];

You have to name the tokens. Nothing on chain enumerates what an address holds; an indexer or a curated list is the only way to discover them, and that is your application's decision rather than this SDK's. Omit tokens and you get the native balance only.

Both reads use undefined for "the read did not answer", and neither means zero.

import {readHoldings, readTokenInfo} from "@somnia-chain/accounts-sdk";

const holdings = await readHoldings(client, account, [token]);
if (holdings.native === undefined) {
  // Ask again later. Rendering this as 0 is the one wrong answer that makes someone send twice.
}

const info = await readTokenInfo(client, token);
if (info.decimals === undefined) {
  // DO NOT default to 18. A token that did not answer is not an 18-decimal token, and guessing
  // turns a display bug into an amount bug.
}

undefined does not say why — a node that is down, a token with no balanceOf, and a call that timed out all arrive as the same value. Distinguishing them needs a richer return type, which would be a breaking change to a read that is otherwise doing its job; it is tracked as holdings-failure-detail in the repository's RULES.md.

Just the calls

If you are batching a transfer into something larger:

import {erc20TransferCall, nativeTransferCall, transferCalls} from "@somnia-chain/accounts-sdk";

const calls = transferCalls({to: other, native: 10n ** 16n, tokens: [{token, amount}]});
void [calls, erc20TransferCall, nativeTransferCall];

planTransfer is the pure form of readTransferPlan when you already hold the nonce.

Amounts are raw

Every amount is in the token's own decimals — bigint, never a decimal number. This SDK does no unit conversion, because a protocol quantity that passes through a float is a protocol quantity that has been rounded.

Edit this page