# cosigner-client — integrating a bot
The signer abstraction for bots that sign Arch Network transactions through
[`arch-cosigner`](../../README.md). It gives your bot one code path for
testnet (local key) and mainnet (the proxy), typed errors that encode the
correct reaction, and it **cryptographically verifies every proxy response**
(BIP322 for the exact submitted message, against *your* configured role
pubkey) before handing you the signature.
```toml
[dependencies]
cosigner-client = "0.1"
```
(Published on crates.io. From a checkout of this workspace, a path dependency
`{ path = "../arch-custody-proxy/crates/cosigner-client" }` works too.)
## Minimal flow
```rust
use cosigner_client::{from_env, sign_transaction, ArchSigner};
// Resolved from the environment — the deployment decides local vs remote:
// mainnet: COSIGNER_URL, COSIGNER_TOKEN, COSIGNER_ROLE, COSIGNER_PUBKEY
// testnet: ARCH_KEY_PATH (+ ARCH_NETWORK, default "testnet")
let signer = from_env()?;
// startup preflight, exactly like today's key-file flows:
assert_eq!(onchain_state.operator, signer.pubkey());
// 1. build the message exactly as you do today
let msg = ArchMessage::new(&[ix], Some(signer.pubkey()), recent_blockhash);
// 2. sign (remote: POST /v1/sign + verify; local: arch_sdk-identical bytes)
let tx = sign_transaction(signer.as_ref(), msg).await?;
// 3. broadcast
rpc.send_transaction(tx).await?;
```
**Mixed-signer transactions** (ephemeral position mints, IDL buffers — the arb
LP-open case): ephemeral keys are valueless one-shots that stay in process
memory; only your role's slot is remote-signed. Placement by
`account_keys` position is handled for you:
```rust
let tx = sign_transaction_mixed(signer.as_ref(), msg, &[ephemeral_keypair]).await?;
```
**Intent labels** (audited today, enforced by a future validation engine) are
per-call handles:
```rust
let remote = RemoteSigner::new(url, token, "abtc-operator", role_pubkey, Network::Bitcoin);
let sweeps = remote.with_intent("sweep");
let payouts = remote.with_intent("fulfill_withdrawal");
```
**Full response details** — when you need the digest or Turnkey activity id
(reconciliation, shadow-mode verification), `RemoteSigner::sign_detailed`
returns a typed `SignResponse { signature, arch_account_pubkey, digest_hex,
turnkey_activity_id }` with the same verification as `sign_message`.
## The error contract, in priority order
1. **`503` or connection-refused (`Halted` / `Unreachable`) → halt-and-alert.**
Stop your signing loop, page a human, poll `/readyz` (or just retry your
next natural tick) until it returns 200. Do **not** crash-loop, do **not**
fall back to a local key — there must be no local key.
2. **`403 denied_by_turnkey` (`Denied`) → never retry, page immediately.**
With correct config this cannot happen; it means role/key confusion or
someone probing.
3. **`502` / timeout (`Transient`) → bounded retry with backoff** (e.g. 3
attempts, 250 ms × 2ⁿ). The proxy already retried internally; if it's
still failing, your retries mostly buy time for its DEGRADED→recovery
cycle.
4. **`400`/`401`/`403 role_mismatch` (`MalformedMessage` / `Unauthorized`) →
don't retry.** These are your bugs: message-builder, token config, or role
wiring respectively.
5. **`Verification` → treat as an incident.** The response failed local
crypto checks — a corrupted or malicious proxy, not a retry.
Each `SignerError` variant documents its mandated reaction; `MissingSigner`
and `Config` are startup/builder bugs.
## Best practices
- **Match networks.** Your `bitcoin::Network` must equal the proxy's `network`
config. Signatures don't carry a network domain — but the address derivation
does, and the proxy signs for *its* network. One mismatch = every signature
invalid.
- **Sign late.** Build the message with a fresh `recent_blockhash` immediately
before requesting the signature; sign → broadcast should be one motion.
- **Label intents truthfully from day one.** `intent_type` is free-form today
but it drives the audit log, metrics, and future validation — bots that lie
to it will break when enforcement lands.
- **Treat the token like a private key.** 0600 file or secret manager, one
token per role per bot, never logged, never shared between roles. A leaked
token = arbitrary in-policy signatures on that role's key until rotated.
- **Rotation is zero-downtime for you.** Ops adds the new token hash alongside
the old (overlap window), you deploy with the new token at leisure, then the
old hash is removed. If you ever see `401` after a rotation announcement, you
missed the window.
- **Set client timeouts ≥ proxy worst case.** With defaults the proxy can spend
`(1 + 2 retries) × 10 s + backoff ≈ 31 s` before answering 502. A 35 s client
timeout avoids orphaned requests you'll wrongly count as drops.
- **Verify signatures in shadow mode, not in the hot loop.** Signature
verification is the proxy's + chain's job at runtime. But during migration
(see below) verify everything.
- **Watch your own request/response counts** and reconcile daily against the
proxy's audit log (`role` + `intent_type` + `message_hash`). That
reconciliation is the earliest detector of a stolen token.
- **Concurrency is fine; storms are not.** The proxy is stateless and handles
parallel requests per role, but Turnkey sub-orgs cap around 10 RPS — batch or
pace anything hotter (the oracle's 5 s loop is the known heavy case).
## Migration path for an existing bot
1. **Refactor to the signer seam** (no behavior change): replace your direct
`build_and_sign_transaction(msg, vec![kp], net)` /
`sign_message_bip322(&kp, …)` calls with `ArchSigner` +
`sign_transaction[_mixed]`, constructed as `LocalSigner` — the equivalence
test suite (`tests/signer_equivalence.rs`) proves this produces
transactions that verify identically, so the PR is landable long before
the proxy is in your path.
2. **Shadow mode** (1 week before cutover): keep signing/broadcasting with the
old local key, *also* POST every message to the proxy and BIP322-verify the
returned signature against the new Turnkey pubkey (byte-comparison is
meaningless — different keys, and BIP340 is non-deterministic).
`arch-cosigner sign --no-send` is exactly this verifier (`--no-send`
verifies and prints the signed transaction without broadcasting — in
shadow mode the old local key already broadcasts). Exit: 7 days, zero
verification failures, latency within budget.
3. **Cutover**: flip config to the proxy (URL + token + role), run the on-chain
authority rotation if the role needs one, migrate float, shred the old key.
4. **Testnet stays local**: fresh local keys, no proxy, no Turnkey — and testnet
keys must never be granted mainnet authority (no cross-network signature
domain separation; see the pinned golden-vector test in `arch-digest`).
If you can't take the crate (non-Rust consumer), the raw HTTP contract is in
the [root README](../../README.md)'s API section; `src/lib.rs` here and
`arch-cosigner sign` are the reference implementations.