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`](../../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.