cosigner-client 0.3.0

Local and proxy-backed Arch Network signers for the arch-cosigner custody proxy
Documentation
# cosigner-client — integrating a bot

The signer abstraction for bots that sign Arch Network transactions through
[`arch-cosigner`](../../README.md). One code path covers a local key and the
proxy, and every proxy response is verified locally (BIP322 over the exact
submitted message, against the configured role pubkey) before the signature
is handed back.

```toml
[dependencies]
cosigner-client = "0.3"
```

## Minimal flow

```rust
use cosigner_client::{ArchSigner, ArchSignerT};

// The environment decides local vs remote (see the env contract below);
// network and intent are set in code.
let signer = ArchSigner::from_env()?
    .with_network(bitcoin::Network::Bitcoin)
    .with_intent("swap");

// startup preflight, exactly like a key-file flow:
assert_eq!(onchain_state.operator, signer.pubkey());

// 1. build the message as usual
let msg = ArchMessage::new(&[ix], Some(signer.pubkey()), recent_blockhash);

// 2. sign (remote: POST /v1/sign + verification; local: arch_sdk-identical bytes)
let tx = signer.sign_transaction(msg).await?;

// 3. broadcast
rpc.send_transaction(tx).await?;
```

`ArchSigner` is an enum (`Local` / `Remote`); the signing methods live on the
`ArchSignerT` trait, so keep the trait imported wherever you sign. Signers
can also be constructed directly — `ArchSigner::local(keypair)`,
`ArchSigner::local_from_key_file(path)?`,
`ArchSigner::remote(url, token, role, pubkey)` — and tuned with the consuming
builders `with_network`, `with_intent`, `with_retries`, `with_timeout`.

Mixed-signer transactions (ephemeral position mints, IDL buffers): the
signer's own slot goes to the backend, each ephemeral keypair signs locally,
and every signature is placed by its pubkey's position in `account_keys`:

```rust
let tx = signer.sign_transaction_mixed(msg, &[ephemeral_keypair]).await?;
```

Intent labels are per-call handles; `ArchSigner` is `Clone` and the builders
consume `self`:

```rust
let sweeps  = signer.clone().with_intent("sweep");
let payouts = signer.clone().with_intent("fulfill_withdrawal");
```

When the digest or Turnkey activity id is needed (reconciliation, shadow-mode
verification), call `sign_message` directly:

```rust
let resp = signer.sign_message(&msg).await?;
// resp: SignResponse {
//     signature: [u8; 64],                  // verified before it is returned
//     arch_account_pubkey: [u8; 32],
//     digest_hex: Option<String>,           // Some from the proxy, None for local
//     turnkey_activity_id: Option<String>,  // Some from the proxy, None for local
// }
```

## Environment contract

Backend selection is environment-driven; the network never is. There is no
`COSIGNER_NETWORK` or `ARCH_NETWORK` — the network defaults to
`bitcoin::Network::Bitcoin` and is set only in code, via `with_network`.

| variable | backend | meaning |
|---|---|---|
| `COSIGNER_URL` | remote | proxy base URL, e.g. `https://cosigner.internal:9080` |
| `COSIGNER_TOKEN` | remote | the role's bearer token |
| `COSIGNER_ROLE` | remote | role name, e.g. `oracle` |
| `COSIGNER_PUBKEY` | remote | the role's Arch account key, 64 hex chars |
| `ARCH_KEY_PATH` | local | key file in `arch_sdk::with_secret_key_file` format |

Every variable also exists in a prefixed form (`ORACLE_COSIGNER_TOKEN`, …)
read by `ArchSigner::from_prefixed_env("ORACLE")`. Resolution follows three
rules:

1. The backend is decided at the most specific level that expresses one: if
   the prefixed level has `<P>_COSIGNER_URL` or `<P>_ARCH_KEY_PATH`, it
   decides remote vs local; otherwise the bare level decides. `from_env()`
   reads the bare level only, and `from_prefixed_env("")` behaves exactly
   like `from_env()`.
2. Both `…COSIGNER_URL` and `…ARCH_KEY_PATH` present at the deciding level
   is ambiguous and returns `SignError::Config`.
3. After the backend is chosen, each variable fills per-variable,
   prefixed-first (`ORACLE_COSIGNER_TOKEN`, else `COSIGNER_TOKEN`).

Empty-string values count as unset. Once a URL decides remote, token, role,
and pubkey are required; the `Config` error lists every missing variable at
once.

Rule 3 is what supports one shared URL with per-bot tokens on a host running
several bots:

```bash
COSIGNER_URL=https://cosigner.internal:9080   # shared by every bot on the host
ORACLE_COSIGNER_TOKEN=...                     # per-bot
ORACLE_COSIGNER_ROLE=oracle
ORACLE_COSIGNER_PUBKEY=<64 hex>
```

`ArchSigner::from_prefixed_env("ORACLE")?` yields a remote signer for the
shared URL carrying the oracle's token, role, and pubkey.

## Operational notes

- Errors are ordinary errors — propagate them with `?`. `SignError` has four
  variants: `Config`, `Signing`, `Proxy { status, detail }`, `Verification`.
- Retries and timeouts are built in: the remote signer retries 502 and
  transport failures (`Proxy { status: None }`) with exponential backoff
  (default 2 retries, 250 ms base) behind a 35 s request timeout. Adjust with
  `with_retries` / `with_timeout`.
- The client does not retry 503 (`SignError::Proxy { status: Some(503), .. }`
  — the proxy, or infrastructure in front of it, is unavailable); whether to
  back off, alert, or fail the operation is the caller's choice.
- `Verification` means the response failed the local BIP322 check — a wrong
  pubkey or a bad signature, not something a retry fixes.
- The network set via `with_network` must equal the proxy's `network` config;
  a mismatch invalidates every signature.
- Pacing: the proxy handles concurrent requests per role, but a Turnkey
  sub-organization caps out at a few requests per second — see batching below
  for anything hotter.

## Batching: `BatchSigner`

A Turnkey sub-organization has a per-second request ceiling, so a bot that
signs one message per inbound request is pinned to it. `BatchSigner` lifts the
ceiling by coalescing concurrent `sign_message` calls into batched proxy calls:
N messages become one `SIGN_RAW_PAYLOADS` activity, which costs one request
against the limit instead of N.

The call site does not change — `BatchSigner` implements `ArchSignerT`, so
`sign_transaction` and friends come along:

```rust
use std::time::Duration;

use cosigner_client::{ArchSigner, ArchSignerT, BatchSigner};

let signer = BatchSigner::spawn(ArchSigner::from_env()?)
    .with_max_rps(8.0)              // activities per second
    .with_max_batch(32)             // messages per activity
    .with_max_in_flight(4)          // concurrent activities
    .with_queue_depth(512)          // queued requests before rejection
    .with_deadline(Duration::from_millis(2000));

// Unchanged call site. Concurrent callers share a round trip; each still gets
// its own signature, verified against its own message.
let tx = signer.sign_transaction(msg).await?;
```

Every value above is a default you override; no Turnkey limit is hard-coded.
Configure before the first signing call — the setters shape a queue that is
already in use afterwards.

What to know before turning it on:

- **A local signer is passed straight through.** `spawn` starts no task,
  channel, or limiter for `ArchSigner::Local`, because local signing has no
  rate limit and no round trip. The setters are accepted and ignored. Log
  `is_batching()` beside your resolved backend: under a local key the batch
  metrics stay flat, which otherwise reads as a bug.
- **Low load costs nothing.** The batching window is the wait for a rate
  limiter permit, not a fixed linger, so a lone request is not delayed.
- **Two failures are new**, both `SignError::Signing`: `"batch queue full"`
  when the queue is at `queue_depth`, and `"batch deadline exceeded"` when a
  request expires before it is dispatched. Both mean overload, so a caller
  serving HTTP should answer 503 rather than 500 — a fast rejection beats
  unbounded latency when the work expires anyway.
- **Rolling back is a config change.** `with_max_batch(1)` degrades to one
  activity per message without a proxy redeploy.