Configuration
The config lives at ~/.config/sa-forge-signer/config.toml unless --config or SA_FORGE_SIGNER_CONFIG says otherwise. sa-forge-signer config init writes the example below there (mode 0600, in a 0700 directory it creates if needed) and refuses to overwrite an existing file. It has no keys yet: add the block key new prints for each key, and [serve] if you run serve.
sa-forge-signer config show prints the config as the signer resolves it: the paths, the RPC, and the instruction table if one is set, each key with its effective class, approval, limits, policy digest and whether its key file exists, defaults filled in, then [serve] with each token’s name and keys. It reads no key material, prints no token hashes, and calls no RPC.
Settings
Only class and pubkey on each key, and a token for serve, have to be set; everything else has a default.
| Setting | Default | Set it when |
|---|---|---|
cluster | "zink-testnet" | never: it is the only cluster |
rpc_url | the cluster’s public RPC (https://rpc1.z.ink) | you use another RPC you trust (Security). It must be https://; http:// loads only for a loopback host (127.0.0.1, ::1, localhost), such as a local validator, so signed transactions and any API key in the URL never cross a network in clear |
explorer_url | the cluster’s explorer (https://explorer.z.ink) | you prefer another block explorer. Used only for links: the explorer field of a sign report and the console’s links; the signer never calls it |
key_dir | keys, beside the config | keys live elsewhere (in Docker, /run/keys) |
state_dir | state, beside the config | the audit log lives elsewhere (in Docker, /var/lib/sa-forge-signer) |
instruction_table | none | you want the instruction table’s findings: { path = "...", sha256 = "...", mode = "report" }, the path relative to the config. The signer refuses to start if the file’s sha256 differs or it is for another cluster. report adds findings to each report and audit entry and never refuses; an enforcing mode comes later |
[keys.<name>] class | required | "session" for a SAGE-scoped profile key, "wallet" for the profile authority |
pubkey | required | the pubkey key new or key import printed |
profile | none | you want key show and the MCP key_show tool to report the key’s profile, or you configure an instruction_table: its profile finding holds the key to this profile |
approval | "auto" for a session key, "confirm" for a wallet key | a key should never sign ("deny"), or a testnet wallet should sign unattended ("auto", with allow_unattended) |
allow_unattended | false | a throwaway testnet wallet key must be "auto" or on a serve token |
rate_limit_per_minute | 30 | the agent signs faster than that |
daily_lamport_cap | 50000000 (0.05 ZINK) | the key spends more in 24 h (fees and transfers it signs) |
halt_after_unknown | 3 | the key should halt after more or fewer consecutive unknown outcomes; 0 never halts it |
require_policy_approval | false | a change to the key’s policy should stop it signing until a person runs key approve <name> at a terminal (Policy digest) |
max_compute_units | 1400000 (the runtime’s maximum) | the key’s payloads should be held to a lower SetComputeUnitLimit |
max_compute_unit_price | 0 (no priority fee) | the key may pay a priority fee: the highest SetComputeUnitPrice, in micro-lamports per compute unit |
transfer_max_lamports | none (only the daily cap) | the key’s System transfers, summed over one transaction, should stay under a smaller amount |
transfer_to | none | the key funds other accounts with ZINK, such as a wallet funding its session key or vault |
extra_programs | none | the key must call a program outside the built-in list (How it works); add only programs you trust |
[serve] listen | "127.0.0.1:8790" | serve should listen elsewhere (in Docker, "0.0.0.0:8790") |
allowed_hosts | ["127.0.0.1", "localhost"] | clients reach serve under another host name |
[[serve.tokens]] | none: serve will not start | always, for serve: name, the sha256 token new printed, and the keys it may use |
The example, as config init writes it:
# sa-forge-signer config. Default location: ~/.config/sa-forge-signer/config.toml
# Relative paths resolve against this file's directory.
cluster = "zink-testnet"
# rpc_url = "https://rpc1.z.ink" # default for the cluster; https only (http only on loopback)
# explorer_url = "https://explorer.z.ink" # default for the cluster; for links only
# key_dir = "keys" # 0700; one 0600 file per key
# state_dir = "state" # audit log
# Report what the instruction table (just apps idl-table) says about each payload; never refuses.
# instruction_table = { path = "zink-testnet.json", sha256 = "SHA256_OF_THE_FILE", mode = "report" }
# A SAGE-scoped session key. Signs without asking. The config holds no key index: the agent passes
# the key's profile index (the next free one when granted, not always 1) as forge-mcp's keyIndex.
# Paste the block `sa-forge-signer key new <name> --class session` prints, or uncomment and fill in.
# [keys.alice-session]
# class = "session"
# pubkey = "PUBKEY_FROM_key_new"
# profile = "PROFILE_ADDRESS"
# rate_limit_per_minute = 30
# daily_lamport_cap = 50000000 # 0.05 ZINK
# halt_after_unknown = 3 # halt the key after this many unknown outcomes in a row; 0 never
# require_policy_approval = false # sign only while the policy digest is the one `key approve` accepted
# max_compute_units = 1400000 # highest SetComputeUnitLimit
# max_compute_unit_price = 0 # highest SetComputeUnitPrice (micro-lamports); 0: no priority fee
# extra_programs = [] # more programs this key may call at the top level. They are
# # NOT trusted as game programs: token accounts can't be created
# # for accounts they own. Add only programs you trust.
# Path A only: the profile authority and asset wallet held by the agent.
# Defaults to approval = "confirm": each signature is approved on the terminal.
# A wallet key cannot be "auto" or listed on a serve token: it signs only through the CLI
# with a person at the terminal. allow_unattended = true lifts both rules; use it only for a
# throwaway testnet key, since the signer does not decode what a profile grant gives away.
# [keys.alice-wallet]
# class = "wallet"
# pubkey = "WALLET_PUBKEY"
# transfer_to = ["PROFILE_ADDRESS", "SESSION_KEY_PUBKEY"] # fund the vault and the session key
# transfer_max_lamports = 100000000 # all transfers in one transaction
# `sa-forge-signer-serve`: HTTP at /v1/check and /v1/sign, MCP at /mcp.
# [serve]
# listen = "127.0.0.1:8790"
# allowed_hosts = ["127.0.0.1", "localhost"]
# [[serve.tokens]] # from `sa-forge-signer token new <name> --out <file>`
# name = "agent"
# sha256 = "HASH_PRINTED_BY_token_new"
# keys = ["alice-session"]
Tracing
Tracing is off by default. Set SA_FORGE_SIGNER_LOG to error, warn, info, debug or trace to write spans and events to stderr (stdout still carries only the JSON report), and SA_FORGE_SIGNER_LOG_FORMAT=json for one JSON object per line instead of the compact text format. An unknown value stops the signer with an error rather than being ignored. Each span logs its duration (time.busy, time.idle) when it closes:
| Span | Level | What it times |
|---|---|---|
sign, check | info | the whole request, with the key name and payload hash; ends with a done event carrying outcome, failed_check and sent |
key_lock | info | waiting for the key’s signing lock |
audit_read | info | reading the key’s recent audit entries, with how many were read |
approval | info | waiting for a person at the terminal (confirm keys) |
confirm | info | sending and polling until the outcome is known |
audit_append | info | writing one audit entry, with its command |
rpc | debug | each RPC call, with its method name |
request | info | each serve request, with the method, path, status and token name |
While tracing is on, serve no longer prints its plain access line (POST /v1/sign -> 200 token=agent), so a JSON log holds only JSON: the request span carries the same method, path, status and token name, plus the duration.
Spans never carry key material, partial signers, tokens or their hashes, the RPC URL (it may hold an API key), or caller-supplied text (summary, intent).