Skip to main content
If you have been running Quantum Chain with geth --unlock for development or a small production deployment, this guide walks through moving signing into a custody platform. The end state: your application calls Qustody instead of submitting eth_sendTransaction directly, and private keys live behind a hardened signing service rather than inside geth.

Why migrate

Local signing (--unlock)Custody (Qustody)
Private key in geth process memoryPrivate key in dedicated signing service
Whoever can reach the RPC can spendRBAC, multi-approver, policies
No audit trailTamper-evident audit log
Single nonce coordinatorPer-wallet nonce serialization across processes
No idempotencyIdempotency-Key deduplicates retries
No webhooks12 webhook events for state transitions
No AML screeningPluggable Chainalysis / ShuftiPro

Migration overview

Two boundaries change:
  1. The application no longer sends signed transactions through geth. It calls the Qustody REST API.
  2. Geth no longer holds keys. It runs as a read-only RPC node that Qustody talks to.

Phase 1 — Prepare

Inventory what you have:
  • List addresses currently unlocked in geth (personal_listAccounts).
  • Note the locations of their keystore files and passphrases.
  • Identify every code path that calls eth_sendTransaction, personal_sendTransaction, or signs locally with ethers/web3.
  • Decide on a confirmation policy. Qustody defaults to 12 blocks (CUSTODY_CONFIRMATION_DEPTH).
  • Choose a signer mode: grpc (recommended), remote, or callback.

Phase 2 — Stand up infrastructure

Run side-by-side with the existing --unlock setup:
  1. Deploy Qustody following Deployment. Point CUSTODY_NODE_RPC_URL at your existing geth node.
  2. Deploy the signing service. Configure mTLS or bearer auth between Qustody and the signer.
  3. Smoke-test with Configure a development network on a non-production address.

Phase 3 — Move keys

For each address that will move to custody:
  1. Create a fresh keypair on the signing service. Do not copy your geth keystore — the migration is also an opportunity to rotate.
    # Or, if your signer supports keygen, ask Qustody to mint:
    # CUSTODY_SIGNER_KEYGEN_MODE=qey
    
  2. Register the wallet with Qustody:
    curl -X POST $BASE_URL/v1/wallets \
      -H "Authorization: Bearer $API_KEY" \
      -d '{
        "vault_id": "...",
        "public_key": "0x...new pq pubkey...",
        "address": "0x...new address..."
      }'
    
  3. Drain the old address. Transfer funds from the old --unlock-managed address to the new custody-managed address with one final manual transaction.
  4. Verify on chain that the receiving address shows the expected balance.
  5. Lock the old account permanently — remove its keystore file, revoke the unlock passphrase, and stop using it.

Phase 4 — Switch traffic

Refactor your application:
- const tx = await wallet.signTransaction({...});
- const hash = await provider.send("eth_sendRawTransaction", [tx]);

+ const res = await fetch(`${BASE_URL}/v1/transactions`, {
+   method: "POST",
+   headers: {
+     "Authorization": `Bearer ${API_KEY}`,
+     "Idempotency-Key": crypto.randomUUID(),
+     "Content-Type": "application/json"
+   },
+   body: JSON.stringify({
+     wallet_id, to_address, value, asset: "QRC"
+   })
+ });
+ const { id } = await res.json();
+ // Subscribe to webhooks for transaction.broadcast / transaction.confirmed
Receipts no longer come back synchronously — they arrive as transaction.broadcast and transaction.confirmed webhooks. Update any code that polled eth_getTransactionReceipt to consume those events instead.

Phase 5 — Harden geth

Once the application no longer talks signing methods to geth, restrict it:
geth \
  --datadir /var/lib/geth \
  --networkid 20803 \
  --syncmode snap \
  --http --http.addr 127.0.0.1 --http.port 8545 \
  --http.api eth,net,web3                    # ← no personal, no admin
  # ← no --unlock
  # ← no --allow-insecure-unlock
Remove personal and admin namespaces. Remove --unlock. Remove --allow-insecure-unlock. The geth keystore directory can be archived offline.

Phase 6 — Add policies and approvers

Now that signing is centralized, layer in controls:
# Require multi-approver for transfers > 100 QRC
curl -X POST $BASE_URL/v1/policies \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "name": "high-value",
    "rules": [
      { "type": "REQUIRE_APPROVAL", "params": { "min_approvers": 2, "threshold_value": "100000000000000000000" } }
    ]
  }'
See Policies for the complete rule set.

Phase 7 — Verify

  • Send a small test transaction through Qustody. Confirm it lands on chain.
  • Verify webhook signatures end-to-end.
  • Pull the audit log: GET /v1/audit. Verify it: GET /v1/audit/verify.
  • Run eth_listAccounts on geth — should be empty (or only contain irrelevant historical accounts).
  • Confirm there are no personal_* or admin_* calls in your application logs.

Common pitfalls

If any process still sends transactions from a custody-managed address through eth_sendRawTransaction directly to geth, nonces collide.Fix: route 100% of outbound transactions for a given address through Qustody.
Qustody retries failed webhook deliveries up to 5 times. Without idempotent handling, you’ll process the same transaction.confirmed event multiple times.Fix: track delivered events by id and ignore duplicates.
If the signer reads its passphrase from the same env that Qustody reads from, a Qustody compromise still leaks signing access.Fix: put the signer in a separate trust zone (HSM, separate VM, separate Kubernetes namespace with its own service account).

Where to go next