cosigner-client 0.4.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. 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.

[dependencies]
cosigner-client = "0.4"

Minimal flow

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:

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

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

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:

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:

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:

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.