cosigner-client 0.1.0

ArchSigner trait + Local/Remote signers for the arch-cosigner custody proxy (Arch Network bots)
Documentation

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.

[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

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:

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:

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's API section; src/lib.rs here and arch-cosigner sign are the reference implementations.