> ## Documentation Index
> Fetch the complete documentation index at: https://docs.quantumapi.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Move from local signing to custody

> Migrate workloads off geth's --unlock and onto Qustody without changing your application's transaction model.

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 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

```mermaid theme={null}
flowchart LR
    subgraph "Before"
        App1[App] -->|eth_sendTransaction| Geth1[geth --unlock]
        Geth1 --> KS1[Keystore in process]
    end

    subgraph "After"
        App2[App] -->|POST /v1/transactions| Qustody[Qustody API]
        Qustody --> Geth2[geth read-only]
        Qustody --> Signer[Signing service]
        Signer --> KS2[Keystore in signer]
    end
```

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](/qustody/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](/guides/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.
   ```bash theme={null}
   # Or, if your signer supports keygen, ask Qustody to mint:
   # CUSTODY_SIGNER_KEYGEN_MODE=qey
   ```
2. **Register the wallet with Qustody:**
   ```bash theme={null}
   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:

```diff theme={null}
- 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:

```bash theme={null}
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:

```bash theme={null}
# 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](/concepts/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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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).
  </Accordion>
</AccordionGroup>

## Where to go next

* [Security model](/qustody/security-model)
* [Transaction lifecycle](/guides/transaction-lifecycle)
* [Webhooks](/concepts/webhooks)
