|
| 1 | +--- |
| 2 | +description: >- |
| 3 | + For signer-key holders and pool operators deploying and binding a |
| 4 | + signer-manager contract under PoX-5. |
| 5 | +--- |
| 6 | + |
| 7 | +# Deploy a Signer Manager Contract |
| 8 | + |
| 9 | +{% hint style="info" %} |
| 10 | +All Clarity references on this page are pinned to [`pox-5.clar` at `stacks-core` 4.0.1](https://github.com/stacks-network/stacks-core/blob/4.0.1/stackslib/src/chainstate/stacks/boot/pox-5.clar) and to a [pinned mainnet build of the reference signer-manager](https://github.com/stx-labs/signer-sidekick/blob/214c67eae2f1ce1c3c818ab6528ce4f2e1bdc22a/contracts/reference-manager/generated/mainnet/signer-manager.clar). |
| 11 | +{% endhint %} |
| 12 | + |
| 13 | +### Who needs this page |
| 14 | + |
| 15 | +Most integrators never write a signer-manager — they pick a signer and route through it, or they use the reference implementation as-is. This page is for the signer-key holder and operator side: whoever deploys a signer-manager contract and binds it to a signer key so stakers can route through it. |
| 16 | + |
| 17 | +### What a signer is in PoX-5 |
| 18 | + |
| 19 | +* A signer is no longer a `{ btcRewardAddress, signerKey }` tuple in a reward set — it is a **signer-manager contract**, identified in all contract state by its principal, with one associated signer key. |
| 20 | +* Every staker (STX-only or bond participant) delegates to a signer-manager. There is no separate "solo" signer set — a solo staker is just a signer-manager with one member. |
| 21 | +* The pox-5 contract hooks into the manager during staking actions via `validate-stake!`, replacing PoX-4's per-transaction signer signatures that scoped each call by amount, period, and pox-addr. |
| 22 | +* Most operators do not need to write a contract at all. The reference signer-manager covers the common case: it accepts delegations, registers the signer key once, and handles reward accounting and onward distribution. Write your own only for custom needs — allowlisting members, different fee logic, custom sBTC routing. |
| 23 | + |
| 24 | +{% hint style="warning" %} |
| 25 | +**"Immutable" applies to the code, not the terms.** A deployed contract's Clarity source can never change, but the reference manager's _parameters_ can. An admin can call `update-fees` at any time, up to 99.99%, and `update-admin` lets any existing admin add another. Stakers should read a manager's admin set and current fee, not assume its economics are fixed. |
| 26 | +{% endhint %} |
| 27 | + |
| 28 | +### The `signer-manager` trait |
| 29 | + |
| 30 | +Your contract must implement one method: |
| 31 | + |
| 32 | +```clarity |
| 33 | +(define-trait signer-manager-trait ( |
| 34 | + (validate-stake! |
| 35 | + ;; staker, first-index, num-indexes, amount-ustx, amount-sats, is-bond, signer-calldata |
| 36 | + (principal uint uint uint uint bool (optional (buff 500))) |
| 37 | + (response bool uint) |
| 38 | + ) |
| 39 | +)) |
| 40 | +``` |
| 41 | + |
| 42 | +* `first-index` is a reward cycle for STX-only flows and a bond index for bond flows. |
| 43 | +* `num-indexes` is the cycle count for STX-only, or `u1` for a bond. |
| 44 | +* `amount-sats` is `u0` for STX-only, or the BTC commitment for bond legs. |
| 45 | +* It is invoked only through pox-5's internal `signer-manager-validate-stake` wrapper, which raises a reentrancy guard (`signer-manager-call-active`) for the duration of the call. Any attempt to re-enter pox-5 from inside your `validate-stake!` callback fails with `ERR_REENTRANT_CALL (u49)`. |
| 46 | +* pox-5 settles per-staker rewards itself, so your contract does not need to track staker-level accounting. |
| 47 | + |
| 48 | +Your contract must also gate `validate-stake!` so only pox-5 can call it. The reference manager does this with: |
| 49 | + |
| 50 | +```clarity |
| 51 | +(define-private (authorize-pox-5) |
| 52 | + (ok (asserts! (is-eq contract-caller 'SP000000000000000000002Q6VF78.pox-5) |
| 53 | + ERR_UNAUTHORIZED_CALLER |
| 54 | + )) |
| 55 | +) |
| 56 | +``` |
| 57 | + |
| 58 | +### Deploy your contract |
| 59 | + |
| 60 | +Deploy a Clarity contract implementing `signer-manager-trait`. The [pinned mainnet reference manager](https://github.com/stx-labs/signer-sidekick/blob/214c67eae2f1ce1c3c818ab6528ce4f2e1bdc22a/contracts/reference-manager/generated/mainnet/signer-manager.clar) is the recommended starting point — it is generated for mainnet, so its pox-5 and sBTC principals are already correct. |
| 61 | + |
| 62 | +{% hint style="danger" %} |
| 63 | +Do not deploy the `stacks-core` test fixture under `contrib/core-contract-tests/`. Its pox-5 and sBTC principals do not match mainnet. Verify the canonical hash of whatever source you deploy against the expected value before broadcasting, and confirm the deployed principal on-chain afterwards — a transaction ID confirms submission, not success. |
| 64 | +{% endhint %} |
| 65 | + |
| 66 | +The reference manager's deployer becomes its first admin automatically (`(map-set admins tx-sender true)`). Rotate admin control to a cold wallet before accepting stake, and never remove the last working admin. |
| 67 | + |
| 68 | +{% hint style="warning" %} |
| 69 | +Ledger Stacks App versions through `0.26.17` cannot sign the Clarity 6 deployment payload. Deploy with a temporary software wallet, then rotate admin control to the Ledger or cold wallet before registration or staking. |
| 70 | +{% endhint %} |
| 71 | + |
| 72 | +### Register the signer key — `register-signer` |
| 73 | + |
| 74 | +After deployment, the signer-manager contract itself calls `pox-5::register-signer` to bind itself to a signer key. |
| 75 | + |
| 76 | +```clarity |
| 77 | +;; Only the signer contract itself can register itself |
| 78 | +(asserts! (is-eq contract-caller signer) |
| 79 | + ERR_UNAUTHORIZED_SIGNER_REGISTRATION |
| 80 | +) |
| 81 | +``` |
| 82 | + |
| 83 | +`contract-caller` must equal the signer-manager. It cannot be forwarded through an intermediary contract, and it cannot be sent by an EOA on the manager's behalf. Violating this aborts with `ERR_UNAUTHORIZED_SIGNER_REGISTRATION (u26)`. |
| 84 | + |
| 85 | +Registration also re-checks the grant first, so the grant must already be in place — see the next section. Read the registered key back on-chain with `get-signer-info`, or via `fetchSignerInfo` in the SDK. |
| 86 | + |
| 87 | +### Grant the signer-manager permission — `grant-signer-key` |
| 88 | + |
| 89 | +{% stepper %} |
| 90 | +{% step %} |
| 91 | +#### Build the SIP-018 grant message |
| 92 | + |
| 93 | +`get-signer-grant-message-hash` hashes a slim tuple under a domain pinned to pox-5: |
| 94 | + |
| 95 | +```clarity |
| 96 | +(define-constant POX_5_SIGNER_DOMAIN { |
| 97 | + name: "pox-5-signer", |
| 98 | + version: "1.0.0", |
| 99 | + chain-id: chain-id, |
| 100 | +}) |
| 101 | + |
| 102 | +;; hashed alongside: |
| 103 | +{ |
| 104 | + topic: "grant-authorization", |
| 105 | + signer-manager: signer-manager, |
| 106 | + auth-id: auth-id, |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +The grant binds a signer key to a signer-manager and **nothing else** — no `max-amount`, `period`, `reward-cycle`, or `pox-addr`. The on-chain grant map is keyed only on `{ signer-key, signer-manager }`; replay protection adds `auth-id` in a separate map. This is the substantive break from PoX-4's per-transaction scoped signatures. |
| 111 | +{% endstep %} |
| 112 | + |
| 113 | +{% step %} |
| 114 | +#### Sign it off-chain, then submit from the manager |
| 115 | + |
| 116 | +The signer-key holder signs the hash off-chain with `signSignerGrant`. The **signer-manager contract** then submits `grant-signer-key`, which enforces the same caller rule as registration: |
| 117 | + |
| 118 | +```clarity |
| 119 | +;; Only the signer contract itself can call this function to grant a signer key |
| 120 | +(asserts! (is-eq contract-caller signer-manager) |
| 121 | + ERR_UNAUTHORIZED_SIGNER_REGISTRATION |
| 122 | +) |
| 123 | +``` |
| 124 | + |
| 125 | +The reference manager wraps both calls in a single `register-self` entrypoint, submitting `grant-signer-key` and then `register-signer`. |
| 126 | +{% endstep %} |
| 127 | + |
| 128 | +{% step %} |
| 129 | +#### Verify before submitting |
| 130 | + |
| 131 | +* `fetchEligibleGrantSignerKey` — preflight, returns the failure reasons the call would hit |
| 132 | +* `fetchSignerGrantMessageHash` — cross-check the hash against the chain |
| 133 | +* `fetchVerifySignerKeyGrant` — is there already an active grant for this pair |
| 134 | +* `fetchSignerKeyGrantUsed` — replay-guard check for a given `authId` |
| 135 | +{% endstep %} |
| 136 | +{% endstepper %} |
| 137 | + |
| 138 | +```typescript |
| 139 | +import { |
| 140 | + buildGrantSignerKey, |
| 141 | + fetchEligibleGrantSignerKey, |
| 142 | + signSignerGrant, |
| 143 | +} from '@stacks/bitcoin-staking'; |
| 144 | + |
| 145 | +const signerSignature = signSignerGrant({ |
| 146 | + signerManager, |
| 147 | + authId, |
| 148 | + chainId, |
| 149 | + privateKey: signer.privateKey, |
| 150 | +}); |
| 151 | + |
| 152 | +const eligible = await fetchEligibleGrantSignerKey({ |
| 153 | + signerKey: signer.publicKey, |
| 154 | + signerManager, |
| 155 | + authId, |
| 156 | + signerSignature, |
| 157 | + network, |
| 158 | +}); |
| 159 | +if (!eligible.ok) { |
| 160 | + throw new Error('grant-signer-key would fail: u' + eligible.reasons.join(', u')); |
| 161 | +} |
| 162 | + |
| 163 | +const tx = await buildGrantSignerKey({ |
| 164 | + signerKey: signer.publicKey, |
| 165 | + signerManager, |
| 166 | + authId, |
| 167 | + publicKey: user.stxPublicKey, |
| 168 | + signerSignature, |
| 169 | + fee: 10_000n, |
| 170 | + nonce, |
| 171 | + network, |
| 172 | +}); |
| 173 | +``` |
| 174 | + |
| 175 | +{% hint style="danger" %} |
| 176 | +Generate the signature on the signer host. Never paste a signer private key or seed phrase into a website, chat, or email. |
| 177 | +{% endhint %} |
| 178 | + |
| 179 | +{% hint style="info" %} |
| 180 | +Builders in `@stacks/bitcoin-staking` attach no post-conditions and default to `Deny` mode. Any transaction you build that moves an asset needs explicit post-conditions. |
| 181 | +{% endhint %} |
| 182 | + |
| 183 | +### Revoke a grant — `revoke-signer-grant` |
| 184 | + |
| 185 | +Takes `signer-manager` first, then `signer-key`. It must be sent **directly** by the Stacks principal derived from the signer key — `contract-caller` is the authorization, so it cannot be forwarded through an intermediary contract, and no SIP-018 message is needed: |
| 186 | + |
| 187 | +```clarity |
| 188 | +(asserts! |
| 189 | + (is-eq |
| 190 | + (unwrap-panic (principal-construct? |
| 191 | + (if is-in-mainnet |
| 192 | + STACKS_ADDR_VERSION_MAINNET |
| 193 | + STACKS_ADDR_VERSION_TESTNET |
| 194 | + ) |
| 195 | + (hash160 signer-key) |
| 196 | + )) |
| 197 | + contract-caller |
| 198 | + ) |
| 199 | + ERR_UNAUTHORIZED |
| 200 | +) |
| 201 | +``` |
| 202 | + |
| 203 | +Calling from any other principal fails with `ERR_UNAUTHORIZED (u1)`. |
| 204 | + |
| 205 | +Revoking is not a kill switch. It blocks future `register-signer` calls for that pair, and because every new-stake entrypoint re-checks the grant, it also stops an already-registered manager accepting **new** stake. Existing positions are left alone: the manager's `signers` entry stays intact so outstanding obligations can still be settled, and those positions wind down as their bonds and stakes expire. |
| 206 | + |
| 207 | +### Error codes |
| 208 | + |
| 209 | +From pox-5: |
| 210 | + |
| 211 | +| Code | Constant | Meaning | |
| 212 | +| ----- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------ | |
| 213 | +| `u1` | `ERR_UNAUTHORIZED` | `revoke-signer-grant` sent by a principal other than the one derived from the signer key. | |
| 214 | +| `u12` | `ERR_SIGNER_KEY_GRANT_USED` | Replay protection — `(signer-key, signer-manager, auth-id)` already used. | |
| 215 | +| `u13` | `ERR_INVALID_SIGNATURE_RECOVER` | Could not recover a public key from the 65-byte RSV signature. | |
| 216 | +| `u14` | `ERR_INVALID_SIGNATURE_PUBKEY` | Recovered pubkey does not match the `signer-key` argument. | |
| 217 | +| `u17` | `ERR_SIGNER_KEY_GRANT_NOT_FOUND` | A staking or registration entrypoint ran against a `(signer-manager, signer-key)` pair with no active grant. | |
| 218 | +| `u26` | `ERR_UNAUTHORIZED_SIGNER_REGISTRATION` | `register-signer` or `grant-signer-key` invoked with `contract-caller != signer-manager`. | |
| 219 | +| `u49` | `ERR_REENTRANT_CALL` | Reentrant call into pox-5 while a `signer-manager-validate-stake` call was in flight. | |
| 220 | + |
| 221 | +The reference signer-manager has its own separate error namespace, which you will hit during setup rather than staking: |
| 222 | + |
| 223 | +| Code | Constant | Meaning | |
| 224 | +| ------- | ------------------------- | ------------------------------------------------------------------- | |
| 225 | +| `u1002` | `ERR_UNAUTHORIZED_ADMIN` | An admin-only call from a non-admin, or proxied through a contract. | |
| 226 | +| `u1005` | `ERR_INVALID_FEES_BIPS` | Fee outside the allowed range. | |
| 227 | +| `u1006` | `ERR_UNAUTHORIZED_CALLER` | `validate-stake!` invoked by something other than pox-5. | |
| 228 | +| `u1007` | `ERR_INSUFFICIENT_FEES` | Attempted to withdraw more fees than have accrued. | |
| 229 | + |
| 230 | +### Signer fees |
| 231 | + |
| 232 | +A signer-manager may take a commission on the rewards it distributes. This is contract-level logic, not a pox-5 protocol feature — pox-5 settles per-staker rewards to the manager, and the manager decides whether to retain a fee before onward distribution. |
| 233 | + |
| 234 | +In the reference manager: |
| 235 | + |
| 236 | +* `update-fees` sets the fee, in basis points. Admin-only. The default at deploy is `u0`, and the bound is one-sided — `(asserts! (< new-fees MAX_BIPS) ERR_INVALID_FEES_BIPS)` with `MAX_BIPS` of `u10000`, so any value from `u0` to `u9999` is accepted, i.e. 0% to 99.99%. |
| 237 | +* `withdraw-fees` collects accrued fees, taking an amount and a recipient. Admin-only, and capped at what has actually accrued. |
| 238 | + |
| 239 | +Both require a direct call from an admin EOA — `authorize-admin` asserts `(is-eq contract-caller tx-sender)`, so admin actions cannot be proxied through another contract. |
| 240 | + |
| 241 | +The fee is a percentage of sBTC rewards, deducted before payout, including before an sBTC-to-L1 BTC withdrawal. It applies to both bond participants and STX-only stakers. A dedicated **Take a signer fee** guide covers the operator workflow in detail. |
0 commit comments