Operating guide

MSafe-style Wallet Compatibility

Operating a legacy MultiEd25519 treasury wallet with this app, including a practical workflow for transacting against any Aptos dApp.

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

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

TargetWhat it does
make installpnpm install + pnpm rebuild better-sqlite3
make devDev server (pnpm dev)
make buildProduction build (pnpm build)
make startProduction server (pnpm start)
make deployFull deploy: install + build + db-push + restart pm2
make restartpm2 restart multisig (starts if not running)
make logspm2 logs multisig
make statuspm2 status
make db-pushpnpm drizzle-kit push — sync schema
make db-studiopnpm drizzle-kit studio — DB inspector in the browser
make lint / make format / make testBiome lint, Biome format, Vitest
make updategit pull then make deploy
make cleanRemove .next/ and node_modules/

Environment variables — complete reference

VariableRequiredDefaultWhat it does
DATABASE_URLyes SQLite path. Dev: file:local.db. Prod: file:/data/multisig.db.
JWT_SECRETyes 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_ENABLEDnofalse Set to true to enable the auto fee-payer.
GAS_STATION_PRIVATE_KEYif gas station enabled Hex Ed25519 private key (0x…) that auto-signs as fee payer.
GAS_STATION_MAX_GAS_PER_TXno10000 Cap on gas units the gas station will pay per transaction (safety guard).
GAS_STATION_NETWORKSnoempty CSV of networks where sponsorship is active, e.g. devnet,testnet. Mainnet is off by default.
BACKEND_URLonly on split frontend URL of the backend API for split deployments (see below).
CORS_ORIGINonly on split backend Origin allowed by CORS on the backend.
ADMIN_PUBLIC_KEYSnoempty 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.

Security notes
  • The private key lives in env vars on the server. Treat the server as custodial for that key.
  • GAS_STATION_MAX_GAS_PER_TX is the only guard against a misconfigured proposal draining the sponsor wallet. Keep it tight.
  • Don't enable for mainnet until you understand the cost model — leave GAS_STATION_NETWORKS on 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 .db file. 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
JWT_SECRET must be identical on both hosts. Otherwise sessions issued by the backend won't validate on the frontend.

Known limitations

  • The iframe dApp browser doesn't work on localhost for dApps whose third-party APIs (Alchemy, CoinGecko, …) whitelist by origin. Those APIs reject requests from localhost, 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.

Lookup-by-address import flow
flowchart LR A([Enter wallet address]) --> B[Fetch last 25 txns] B --> C{Any txn has\nmulti_ed25519_signature?} C -- Yes --> D[Extract public_keys + threshold] C -- No --> E[Manual import\nrequired] D --> F[Derive address\nfrom extracted keys] F --> G{Derived addr\nmatches input?} G -- Match --> H([✓ Wallet imported]) G -- Mismatch --> I[Key order wrong\nor keys missing] E --> J[Paste all keys\n+ threshold manually] J --> F style H fill:#bbf7d0,stroke:#10b981 style I fill:#fecaca,stroke:#ef4444
Why this works Every MultiEd25519 transaction submitted from the wallet reveals the entire key set in the authenticator. Even a single past transaction is enough to reconstruct the configuration. If the wallet has never sent a transaction yet, you'll need to use Manual import instead and provide all keys + threshold.

Day-to-day operations: simple transfers

For a plain APT transfer, click Propose Transaction on the wallet dashboard. Select the ABI builder and fill:

FieldValue
Module address0x1
Module nameaptos_account
Function nametransfer
Type arguments(none)
Function argumentsto (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:

Method A

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.

Method B

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

  • 1
    Install Nightly

    From nightly.app (Chrome / Firefox extension).

  • 2
    Add the multisig as a watch-only address

    Nightly → Add wallet → Aptos → Watch only / Track address. Paste the multisig wallet address.

  • 3
    Make the watch-only address active

    Switch Nightly's active wallet to the watch-only multisig before visiting the dApp.

Capturing a payload (per transaction)

dApp payload capture via Nightly watch-only
sequenceDiagram actor U as You participant D as dApp (e.g. Thala) participant N as Nightly (watch-only) participant C as Browser Console participant M as multisig-ui U->>D: open dApp tab U->>C: open DevTools → Console D->>N: connect wallet N-->>D: connected — addr = multisig U->>D: click "Swap" / "Stake" / etc. D->>N: signAndSubmitTransaction(payload) N->>C: console.log(payload) Note over N,D: no private key →
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 fieldWhere it comes from
Module addressEverything before :: in function (e.g. 0xabc...)
Module nameMiddle segment (e.g. router)
Function nameLast segment (e.g. swap_exact_input)
Type argumentstypeArguments / type_arguments — one entry per line
Function argumentsfunctionArguments / arguments — in order
Right-click → Copy object Console output may truncate long objects to [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.

  1. Open Nightly, switch active wallet to the watch-only multisig address.
  2. Open app.thala.fi in a new tab, open DevTools → Console.
  3. Connect Nightly. Confirm the dApp shows the multisig address connected.
  4. Navigate to Swap, pick input/output tokens, enter amount.
  5. Click Swap. The dApp will spin and then either hang or show an error.
  6. In Console: expand the console.log payload. Expected shape:
    {
      "data": {
        "function": "0x...::stable_pool_scripts::swap_exact_in",
        "typeArguments": [
          "0x1::aptos_coin::AptosCoin",
          "<usdc-coin-type>",
          "<lp-coin-type>",
          "..."
        ],
        "functionArguments": ["100000000", "<min-out>"]
      }
    }
    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.
  7. 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).
  8. 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.

  1. Same Nightly setup as the Thala example.
  2. Open the dApp, open DevTools → Console, connect Nightly.
  3. Set up the swap and click Swap. The dApp will fail to submit.
  4. Console will show something like:
    {
      "data": {
        "function": "0xc7efb40...::router::swap_exact_input",
        "typeArguments": [
          "0x1::aptos_coin::AptosCoin",
          "<usdt-coin-type>"
        ],
        "functionArguments": ["100000000", "9800000"]
      }
    }
    PancakeSwap's router functions are simpler — usually two type arguments (in, out) and two function arguments (amount in, min amount out).
  5. Copy → propose → ABI builder → fill → simulate → save → share.
Coin types are case- and version-sensitive Don't substitute 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:

  1. The fully qualified function — 0xMODULE::module_name::function_name
  2. Type arguments — usually coin types like 0x1::aptos_coin::AptosCoin
  3. Function arguments — in correct order & BCS-compatible form (u64 as decimal string, address as 0x..., bool as true/false, vector<u8> as 0xhex)

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:

  1. Export the unsigned transaction bytes (button Export for offline signing).
  2. Sign externally — for example with @aptos-labs/ts-sdk in 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());
  1. 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).

Simulation failure decision tree
%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 70}, "themeVariables": {"fontSize": "14px"}}}%% flowchart TD A[Simulation failed] --> B{Error mentions...} B -- oracle / STALE_PRICE --> T1[Temporary:
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 patternLikely causeVerdict
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.
Rule of thumb Oracle, slippage, RPC, or connection → usually transient.
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.