cosigner-client — integrating a bot
The signer abstraction for bots that sign Arch Network transactions through
arch-cosigner. 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.
[]
= "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
use ;
// 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!;
// 1. build the message exactly as you do today
let msg = new;
// 2. sign (remote: POST /v1/sign + verify; local: arch_sdk-identical bytes)
let tx = sign_transaction.await?;
// 3. broadcast
rpc.send_transaction.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:
let tx = sign_transaction_mixed.await?;
Intent labels (audited today, enforced by a future validation engine) are per-call handles:
let remote = new;
let sweeps = remote.with_intent;
let payouts = remote.with_intent;
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
503or 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.403 denied_by_turnkey(Denied) → never retry, page immediately. With correct config this cannot happen; it means role/key confusion or someone probing.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.400/401/403 role_mismatch(MalformedMessage/Unauthorized) → don't retry. These are your bugs: message-builder, token config, or role wiring respectively.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::Networkmust equal the proxy'snetworkconfig. 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_blockhashimmediately before requesting the signature; sign → broadcast should be one motion. - Label intents truthfully from day one.
intent_typeis 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
401after 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 sbefore 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
- Refactor to the signer seam (no behavior change): replace your direct
build_and_sign_transaction(msg, vec![kp], net)/sign_message_bip322(&kp, …)calls withArchSigner+sign_transaction[_mixed], constructed asLocalSigner— 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. - 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-sendis exactly this verifier (--no-sendverifies 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. - 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.
- 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's API section; src/lib.rs here and
arch-cosigner sign are the reference implementations.