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 memory | Private key in dedicated signing service |
| Whoever can reach the RPC can spend | RBAC, multi-approver, policies |
| No audit trail | Tamper-evident audit log |
| Single nonce coordinator | Per-wallet nonce serialization across processes |
| No idempotency | Idempotency-Key deduplicates retries |
| No webhooks | 12 webhook events for state transitions |
| No AML screening | Pluggable Chainalysis / ShuftiPro |
Migration overview
Two boundaries change:- The application no longer sends signed transactions through geth. It calls the Qustody REST API.
- 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, orcallback.
Phase 2 — Stand up infrastructure
Run side-by-side with the existing--unlock setup:
- Deploy Qustody following Deployment. Point
CUSTODY_NODE_RPC_URLat your existing geth node. - Deploy the signing service. Configure mTLS or bearer auth between Qustody and the signer.
- Smoke-test with Configure a development network on a non-production address.
Phase 3 — Move keys
For each address that will move to custody:- Create a fresh keypair on the signing service. Do not copy your geth keystore — the migration is also an opportunity to rotate.
- Register the wallet with Qustody:
- Drain the old address. Transfer funds from the old
--unlock-managed address to the new custody-managed address with one final manual transaction. - Verify on chain that the receiving address shows the expected balance.
- Lock the old account permanently — remove its keystore file, revoke the unlock passphrase, and stop using it.
Phase 4 — Switch traffic
Refactor your application: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: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: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_listAccountson geth — should be empty (or only contain irrelevant historical accounts). - Confirm there are no
personal_*oradmin_*calls in your application logs.
Common pitfalls
Mixed nonce sources
Mixed nonce sources
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.Webhook receiver not idempotent
Webhook receiver not idempotent
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.Signing service not actually decoupled
Signing service not actually decoupled
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).