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.
[]
= "0.4"
Minimal flow
use ;
// The environment decides local vs remote (see the env contract below);
// network and intent are set in code.
let signer = from_env?
.with_network
.with_intent;
// startup preflight, exactly like a key-file flow:
assert_eq!;
// 1. build the message as usual
let msg = new;
// 2. sign (remote: POST /v1/sign + verification; local: arch_sdk-identical bytes)
let tx = signer.sign_transaction.await?;
// 3. broadcast
rpc.send_transaction.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.await?;
Intent labels are per-call handles; ArchSigner is Clone and the builders
consume self:
let sweeps = signer.clone.with_intent;
let payouts = signer.clone.with_intent;
When the digest or Turnkey activity id is needed (reconciliation, shadow-mode
verification), call sign_message directly:
let resp = signer.sign_message.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:
- The backend is decided at the most specific level that expresses one: if
the prefixed level has
<P>_COSIGNER_URLor<P>_ARCH_KEY_PATH, it decides remote vs local; otherwise the bare level decides.from_env()reads the bare level only, andfrom_prefixed_env("")behaves exactly likefrom_env(). - Both
…COSIGNER_URLand…ARCH_KEY_PATHpresent at the deciding level is ambiguous and returnsSignError::Config. - After the backend is chosen, each variable fills per-variable,
prefixed-first (
ORACLE_COSIGNER_TOKEN, elseCOSIGNER_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
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
?.SignErrorhas 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 withwith_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. Verificationmeans the response failed the local BIP322 check — a wrong pubkey or a bad signature, not something a retry fixes.- The network set via
with_networkmust equal the proxy'snetworkconfig; 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 Duration;
use ;
let signer = spawn
.with_max_rps // activities per second
.with_max_batch // messages per activity
.with_max_in_flight // concurrent activities
.with_queue_depth // queued requests before rejection
.with_deadline;
// 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.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.
spawnstarts no task, channel, or limiter forArchSigner::Local, because local signing has no rate limit and no round trip. The setters are accepted and ignored. Logis_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 atqueue_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.