What this guide covers
This app manages MultiEd25519 treasury wallets on Aptos at the protocol level. It is fully compatible with multisig accounts produced by older coordination services that use the same MultiEd25519 scheme — the underlying account is a standard Aptos MultiEd25519 account, and coordination is just signature collection on top.
You will be able to:
- Import an existing MultiEd25519 wallet by address
- Build and sign arbitrary entry function transactions
- Interact with any Aptos dApp — even those that don't natively support multisig
- Collect signatures from co-signers via share links
- Submit the combined transaction directly to chain
You will need:
- The wallet address
- Access to ≥ threshold of the owner private keys — either in browser wallets or as raw keys for offline signing
- Prerequisites & running the app
- Importing your multisig wallet
- Day-to-day operations: simple transfers
- Interacting with dApps — two methods
- Worked example: Thala swap
- Worked example: PancakeSwap swap
- Manual fallback: building a payload from scratch
- Collecting signatures
- Simulating, retrying, and submitting
- Troubleshooting / FAQ
- Future: a dedicated extension
Prerequisites & running the app
Prerequisites
- Node.js 20+ and pnpm
- A browser wallet that holds your personal owner key — Petra, Moveth, Rise, OKX, etc. Any standard Ed25519 wallet works.
- For capturing dApp transactions: Nightly browser extension
- Familiarity with browser DevTools (opening the Console tab)
Quickstart (local development)
git clone <this repo>
cd aptos-multisig-ui
pnpm install
pnpm rebuild better-sqlite3
pnpm drizzle-kit push # initialise local SQLite
# minimal .env.local
cat > .env.local <<'EOF'
DATABASE_URL=file:local.db
JWT_SECRET=change-me
EOF
pnpm dev
Open http://localhost:3123. Connect any standard Ed25519
wallet (e.g. Petra) to authenticate yourself as a signer. The connected
wallet is your personal signing identity — not the multisig wallet
itself.
Building for production
pnpm build # produces .next/
pnpm start # serves the built app on port 3123
pnpm start is a long-running process. Behind a reverse proxy
(nginx / Caddy / Cloudflare) for TLS, use a process supervisor — the
included Makefile assumes
pm2:
make install # pnpm install + rebuild better-sqlite3
make build # pnpm build
make db-push # apply Drizzle schema
make restart # pm2 restart multisig (or start if first run)
# convenience: make deploy = install + build + db-push + restart
make logs / make status give pm2 visibility,
and make update pulls main and redeploys.
Makefile reference
| Target | What it does |
|---|---|
make install | pnpm install + pnpm rebuild better-sqlite3 |
make dev | Dev server (pnpm dev) |
make build | Production build (pnpm build) |
make start | Production server (pnpm start) |
make deploy | Full deploy: install + build + db-push + restart pm2 |
make restart | pm2 restart multisig (starts if not running) |
make logs | pm2 logs multisig |
make status | pm2 status |
make db-push | pnpm drizzle-kit push — sync schema |
make db-studio | pnpm drizzle-kit studio — DB inspector in the browser |
make lint / make format / make test | Biome lint, Biome format, Vitest |
make update | git pull then make deploy |
make clean | Remove .next/ and node_modules/ |
Environment variables — complete reference
| Variable | Required | Default | What it does |
|---|---|---|---|
DATABASE_URL | yes | — | SQLite path. Dev: file:local.db. Prod: file:/data/multisig.db. |
JWT_SECRET | yes | — | Signing key for session JWTs issued at /api/verify. Must be stable across restarts and identical across split-deployment frontend + backend. Use a strong random value in production. |
GAS_STATION_ENABLED | no | false |
Set to true to enable the auto fee-payer. |
GAS_STATION_PRIVATE_KEY | if gas station enabled | — | Hex Ed25519 private key (0x…) that auto-signs as fee payer. |
GAS_STATION_MAX_GAS_PER_TX | no | 10000 |
Cap on gas units the gas station will pay per transaction (safety guard). |
GAS_STATION_NETWORKS | no | empty | CSV of networks where sponsorship is active, e.g. devnet,testnet. Mainnet is off by default. |
BACKEND_URL | only on split frontend | — | URL of the backend API for split deployments (see below). |
CORS_ORIGIN | only on split backend | — | Origin allowed by CORS on the backend. |
ADMIN_PUBLIC_KEYS | no | empty | CSV of Ed25519 public keys with admin privileges (e.g. submitting on behalf of a wallet). |
Selecting an Aptos network
The network (mainnet / testnet /
devnet) is a per-multisig setting — chosen
when you create or import the wallet, and stored with the multisig's
keys. Each proposal inherits its parent multisig's network.
There is no global "switch network" toggle for the whole app; the same running instance can manage multisigs on different networks side by side. Just make sure the wallet you connect with is on the network of the multisig you're operating.
Gas station — automated fee sponsorship
The gas station lets a single Ed25519 key automatically sign as the fee payer for proposals from any managed multisig. Useful so the multisig itself doesn't need to hold APT to pay gas.
GAS_STATION_ENABLED=true
GAS_STATION_PRIVATE_KEY=0x<hex>
GAS_STATION_MAX_GAS_PER_TX=10000
GAS_STATION_NETWORKS=devnet,testnet
On the proposal form, pick "Fee payer" and set it to the gas station's
address. On submission, if the proposal carries no
feePayerSignature, the server signs as the gas station
automatically. Failures are silent — submission proceeds without
sponsorship if anything goes wrong.
- The private key lives in env vars on the server. Treat the server as custodial for that key.
GAS_STATION_MAX_GAS_PER_TXis the only guard against a misconfigured proposal draining the sponsor wallet. Keep it tight.- Don't enable for
mainnetuntil you understand the cost model — leaveGAS_STATION_NETWORKSon test networks during initial setup.
Database (SQLite via Drizzle)
The app uses a single SQLite file (local.db by default).
Schema lives in src/lib/db/schema.ts and is applied with
Drizzle:
pnpm drizzle-kit push # sync schema (idempotent)
pnpm drizzle-kit studio # inspect tables in a browser UI
- Backups = copy the
.dbfile. SQLite supports hot snapshots; on Linux,sqlite3 multisig.db ".backup '/path/snap.db'"works while the app runs. - Schema migrations are applied by
make db-push; run this after every deploy. - Concurrency: SQLite uses single-writer locking. Fine for tens to low hundreds of users; for more, migrate to Postgres (Drizzle supports it — just change the driver).
Split deployment
The frontend (Next.js pages) and backend (API routes + SQLite) can run on separate hosts. Same codebase deployed to both; the difference is env config.
Backend (VPS with DB)
DATABASE_URL=file:/data/multisig.db
JWT_SECRET=<shared-secret>
CORS_ORIGIN=https://app.example.com
make deploy # builds and starts with pm2
Frontend (Vercel or another Next.js host)
BACKEND_URL=https://api.example.com
JWT_SECRET=<shared-secret> # must match backend
pnpm build && pnpm start
Known limitations
- The iframe dApp browser doesn't work on
localhostfor dApps whose third-party APIs (Alchemy, CoinGecko, …) whitelist by origin. Those APIs reject requests fromlocalhost, so the dApp's UI hangs or errors. Deploy to a real domain for Method A, or use the Nightly workflow (Method B) which is unaffected. - The app blocks keyless / OAuth wallets (Aptos Connect, Google, Apple) — they use non-Ed25519 keys that can't sit in a MultiEd25519 key set. Co-signers use a standard Ed25519 wallet or sign offline.
- SQLite single-writer locking can bottleneck under heavy concurrent proposal creation. For tens of users this is fine.
Importing an existing multisig wallet
Go to Import → "Lookup by address" and paste the multisig
wallet address. The lookup endpoint fetches the wallet's last 25 transactions
and scans for a multi_ed25519_signature authenticator. Once it
finds one, it extracts the full public-key list and threshold and
reconstructs the wallet configuration.
Day-to-day operations: simple transfers
For a plain APT transfer, click Propose Transaction on the wallet dashboard. Select the ABI builder and fill:
| Field | Value |
|---|---|
| Module address | 0x1 |
| Module name | aptos_account |
| Function name | transfer |
| Type arguments | (none) |
| Function arguments | to (address), amount (u64 octa; 1 APT = 100000000) |
Click Simulate, then Save proposal, and share the URL with your co-signers.
For fungible asset (FA) transfers, use
0x1::primary_fungible_store::transfer with a
0x1::object::Object<T> type argument; the captured
payload from a dApp will tell you the exact metadata object address.
Interacting with dApps
Most Aptos dApps assume the connected wallet is a single-key wallet that can sign immediately. A multisig wallet cannot — it needs to collect signatures over time. There are two ways to bridge the gap:
Built-in dApp browser
When: the dApp loads inside an iframe (no
X-Frame-Options or restrictive CSP).
How: navigate to
/multisig/[address]/dapp, enter the dApp URL.
The page proxies the dApp and injects a fake wallet adapter that
intercepts signAndSubmitTransaction.
Strength: seamless — payload turns into a proposal automatically, no copy/paste.
Nightly wallet in watch-only mode
When: anything else — most production DEX/CDP frontends block iframe embedding.
How: import the multisig as a watch-only address in
Nightly. When a dApp asks Nightly to sign, the payload gets
console.log'd and the sign call fails harmlessly. You
copy from the console into a proposal.
Strength: universal — works against any Aptos dApp on any wallet-adapter site, no cooperation needed.
Method B — Nightly watch-only capture in detail
One-time setup
-
1Install Nightly
From nightly.app (Chrome / Firefox extension).
-
2Add the multisig as a watch-only address
Nightly → Add wallet → Aptos → Watch only / Track address. Paste the multisig wallet address.
-
3Make the watch-only address active
Switch Nightly's active wallet to the watch-only multisig before visiting the dApp.
Capturing a payload (per transaction)
signing fails, dApp errors U->>C: copy payload object U->>M: propose transaction
(paste payload into ABI form) M-->>U: simulate ✓, share link
Console payload shapes
The payload will be logged in one of two shapes. The fields map to the ABI builder identically — only the field names differ.
Modern (AIP-62 / Aptos Wallet Standard):
{
"data": {
"function": "0xabc...::router::swap_exact_input",
"typeArguments": [
"0x1::aptos_coin::AptosCoin",
"0xabc...::usdc::USDC"
],
"functionArguments": ["100000000", "12300000", "0xowner..."]
}
}
Legacy entry_function_payload:
{
"type": "entry_function_payload",
"function": "0xabc...::router::swap_exact_input",
"type_arguments": ["0x1::aptos_coin::AptosCoin", "0xabc...::usdc::USDC"],
"arguments": ["100000000", "12300000", "0xowner..."]
}
How to translate either into the proposal ABI form:
| Form field | Where it comes from |
|---|---|
| Module address | Everything before :: in function (e.g. 0xabc...) |
| Module name | Middle segment (e.g. router) |
| Function name | Last segment (e.g. swap_exact_input) |
| Type arguments | typeArguments / type_arguments — one entry per line |
| Function arguments | functionArguments / arguments — in order |
[Object] or omit
inner array contents. Right-click the logged object and choose
Copy object (Chrome / Firefox both support this) to get the full
structure, not just the visible text.
Worked example: a Thala swap
Goal: swap 1 APT for USDC on app.thala.fi.
- Open Nightly, switch active wallet to the watch-only multisig address.
- Open
app.thala.fiin a new tab, open DevTools → Console. - Connect Nightly. Confirm the dApp shows the multisig address connected.
- Navigate to Swap, pick input/output tokens, enter amount.
- Click Swap. The dApp will spin and then either hang or show an error.
- In Console: expand the
console.logpayload. Expected shape:
The exact module name and number of type arguments vary by pool type (stable pool vs weighted pool, two-asset vs multi-asset). Trust whatever the console shows — it is exactly what Thala's frontend produced.{ "data": { "function": "0x...::stable_pool_scripts::swap_exact_in", "typeArguments": [ "0x1::aptos_coin::AptosCoin", "<usdc-coin-type>", "<lp-coin-type>", "..." ], "functionArguments": ["100000000", "<min-out>"] } } - Right-click → Copy object. In the multisig dashboard, create a new
proposal, pick the ABI builder, fill from the JSON. Set max gas to
something generous for swaps (e.g.
100000). - Simulate. If success: save & share. If fail:
check whether on-chain pool state has moved (slippage in
<min-out>may have become unreachable). Re-capture from Thala with a looser slippage. See simulation failures to tell temporary failures (oracle, slippage) from permanent ones (wrong args, no funds).
Worked example: a PancakeSwap swap
Goal: swap APT for USDT on aptos.pancakeswap.finance.
- Same Nightly setup as the Thala example.
- Open the dApp, open DevTools → Console, connect Nightly.
- Set up the swap and click Swap. The dApp will fail to submit.
- Console will show something like:
PancakeSwap's router functions are simpler — usually two type arguments (in, out) and two function arguments (amount in, min amount out).{ "data": { "function": "0xc7efb40...::router::swap_exact_input", "typeArguments": [ "0x1::aptos_coin::AptosCoin", "<usdt-coin-type>" ], "functionArguments": ["100000000", "9800000"] } } - Copy → propose → ABI builder → fill → simulate → save → share.
USDC for USDt, or use a coin
type from a different bridge. Use the exact string from the console.
Manual fallback: building a payload from scratch
If a dApp won't connect to Nightly (rare), reconstruct the call manually. You need three things:
- The fully qualified function —
0xMODULE::module_name::function_name - Type arguments — usually coin types like
0x1::aptos_coin::AptosCoin - Function arguments — in correct order & BCS-compatible form
(
u64as decimal string,addressas0x...,boolastrue/false,vector<u8>as0xhex)
Sources, in order of reliability:
- Contract source — many Aptos protocols publish their Move on GitHub. Search for the function name.
- Aptos explorer ABI viewer — paste the module address into explorer.aptoslabs.com, open Modules, find the function. This gives the signature authoritatively.
- Past on-chain transactions by other users — search the function name in the explorer, copy the Payload of a real transaction as a template.
- Protocol docs / SDKs — most DEXes publish a JS SDK whose source reveals exact module paths and arg layouts.
Collecting signatures
After saving a proposal, you get a share link of the form
http://localhost:3123/tx/<proposal-id>. Each co-signer
opens that URL, connects their own Ed25519 wallet (the one that holds
their owner key), reviews the decoded transaction, and signs.
Signers without a browser wallet
If an owner only has a raw private key, use the Offline signing panel on the proposal page:
- Export the unsigned transaction bytes (button Export for offline signing).
- Sign externally — for example with
@aptos-labs/ts-sdkin Node:
import { Ed25519PrivateKey, Hex } from "@aptos-labs/ts-sdk";
const priv = new Ed25519PrivateKey("0x<owner_priv_hex>");
const txnBytes = Hex.fromHexString("0x<exported-bytes-hex>").toUint8Array();
const signingMsg = /* SHA3-256 of "APTOS::RawTransaction" prefix + txnBytes */;
const sig = priv.sign(signingMsg);
console.log(sig.toString());
- Paste the 64-byte hex signature plus the signer index back into the offline panel. The app verifies your signature against the expected public key before accepting it.
Simulating, retrying, and submitting
Always Simulate before submitting. The button runs a dry execution against the current chain state and tells you whether the transaction would succeed.
Temporary vs permanent simulation failures
When simulation fails, decide whether the cause is transient (retry later, same proposal) or permanent (rebuild the proposal).
wait 30-120 s, retry] B -- slippage / min_amount_out --> T2[Temporary-ish:
price moved, must
RE-CAPTURE from dApp] B -- RPC / connection / 5xx --> T3[Temporary:
retry] B -- INSUFFICIENT_BALANCE / EINSUFFICIENT_FUNDS --> P1[Permanent:
top up wallet or
reduce amount] B -- SEQUENCE_NUMBER_TOO_OLD --> P2[Permanent for this proposal:
rebuild — new seq picked up] B -- OUT_OF_GAS / MAX_GAS_AMOUNT_EXCEEDED --> P3[Permanent for this proposal:
rebuild with higher max gas] B -- OBJECT_NOT_FOUND / bad args --> P4[Permanent:
arg or type-arg typo] style T1 fill:#fef3c7,stroke:#f59e0b style T2 fill:#fef3c7,stroke:#f59e0b style T3 fill:#fef3c7,stroke:#f59e0b style P1 fill:#fee2e2,stroke:#ef4444 style P2 fill:#fee2e2,stroke:#ef4444 style P3 fill:#fee2e2,stroke:#ef4444 style P4 fill:#fee2e2,stroke:#ef4444
| Error pattern | Likely cause | Verdict |
|---|---|---|
Move abort in 0x...::oracle::... / STALE_PRICE |
An oracle hasn't refreshed; DEX/CDP needs fresh prices | Temporary wait 30-120 s, simulate again. May need several retries. |
INSUFFICIENT_BALANCE / EINSUFFICIENT_FUNDS |
Wallet doesn't have enough of the input token | Permanent fix amount or top up the wallet. |
SLIPPAGE_EXCEEDED / min_amount_out abort |
Price moved between capture and simulation | Temporary re-capture from the dApp with current price / looser slippage. The old proposal is unusable. |
SEQUENCE_NUMBER_TOO_OLD |
Wallet has sent another transaction since the proposal was built | Permanent for this proposal — rebuild, it'll pick up the new seq automatically. |
OUT_OF_GAS / MAX_GAS_AMOUNT_EXCEEDED |
Gas budget too low | Permanent for this proposal — rebuild with higher maxGasAmount (e.g. 200000). |
OBJECT_NOT_FOUND / missing resource |
Wrong address or coin type | Permanent check the captured payload — usually a type argument typo. |
Connection refused / RPC 5xx |
Aptos node temporarily unreachable | Temporary retry. |
Balance, gas, sequence number, or argument errors → usually permanent, rebuild the proposal.
Submitting
Once ≥ threshold signatures are collected and a fresh simulation passes,
click Submit. The combined
TransactionAuthenticatorMultiEd25519 goes directly to the
Aptos node. The app polls for on-chain confirmation at ~5 s, ~10 s, and
~60 s after submission.
If the submitted transaction reverts on-chain (passed simulation but real execution failed), the proposal status moves to failed. The most common cause is price-moved-since-simulation; rebuild and retry.
Troubleshooting / FAQ
My import says "no on-chain history found".
The wallet has never sent a transaction. Use Manual import instead and provide all public keys + threshold.
The derived address doesn't match my wallet address.
Key order is wrong, threshold is wrong, or you're missing a key. Some MultiEd25519 wallets include extra "salt" public keys beyond the human-owned ones — make sure you provide all of them.
Nightly doesn't show the payload in the console.
Confirm you're using Nightly in watch-only mode (not a regular single-key wallet). Watch-only is what causes the signing flow to log + drop rather than prompt. Make sure the active wallet in Nightly is the multisig address, not a personal address.
The dApp loads in the iframe browser but the proposal doesn't appear.
The dApp may call non-standard wallet methods. Open the iframe page's DevTools — the proxy logs unsupported calls. Fall back to Nightly capture.
My co-signer's wallet is rejected.
This app blocks keyless / OAuth wallets (Aptos Connect, Google, Apple) because they use non-Ed25519 keys that can't sit in a MultiEd25519 key set. Co-signers must use a standard Ed25519 wallet (Petra, Moveth, Rise, OKX, …) or sign offline with their raw key.
Simulation keeps failing with an oracle error even after waiting.
Some pools depend on multiple oracles; one may be stuck. Try waiting longer (5–10 minutes), or use a different pool/path on the dApp.
Can I edit a proposal after sharing the link?
No. The signed payload bytes are committed when the proposal is created. Edits require building a new proposal and a new link.
Future: a dedicated extension
The Nightly watch-only console-capture workflow is a workaround. A future
improvement is a dedicated browser extension that presents itself to dApps
as an Aptos-Wallet-Standard wallet, intercepts
signAndSubmitTransaction properly, and submits the captured
payload directly to this app as a new proposal — no manual copy/paste.
The same approach also works for signMessage and
signTransaction flows.
Until that exists, Nightly + DevTools is the most reliable bridge.