Skip to content

multi: board into custom policies + client-signed VTXO tree - #1043

Draft
Roasbeef wants to merge 13 commits into
mainfrom
frost-board-policy-and-tree-signing
Draft

multi: board into custom policies + client-signed VTXO tree#1043
Roasbeef wants to merge 13 commits into
mainfrom
frost-board-policy-and-tree-signing

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

In this PR, we make it possible to run a VTXO whose owner (and, if you
want, whose tree-signing key) lives entirely outside the daemon, driven
over the gRPC surface. The motivating case is a client that wants their
VTXO/OOR public key to be an aggregate FROST key: the group does its own
key derivation and signing off-box, and the daemon only ever sees a
pubkey and a finished signature. FROST is invisible to the rest of the
system here, since the group produces an ordinary BIP-340 Schnorr
signature that's indistinguishable on-chain from a single-signer one.

The work splits into two independent parts. Part one lets a client board
straight into a custom-owned VTXO, skipping the board-then-refresh dance.
Part two adds a seam (and two RPCs) so the client can also drive the
covenant tree-signing MuSig2 ceremony for an external cosigner key. The
two parts share only the proto regeneration; everything else is
separable, so see each commit message for the detailed w.r.t the
incremental changes.

Boarding into a custom policy

Today Board always produces a standard 2-of-2 collaborative VTXO with
a freshly derived owner key, so getting a custom-owned coin means
boarding and then converting with RefreshCustomVTXOs. In this PR we add
an optional vtxo_policy_template (and a companion pk_script) to
BoardRequest so the boarded outputs adopt that policy verbatim.

The template is validated at the RPC boundary against the operator's
terms (operator on every collab leaf, every exit leaf gated by at least
the CSV floor), so a malformed or unsafe policy is rejected up front
rather than surfacing later as a round failure. The template governs
ownership only; the operator stays the seal-time fee authority under the
#270 handshake. The interesting plumbing is the outbox: Board is
restart-replayed, so the policy has to survive persistence. We add
migration 000016 (nullable columns, so old intents keep working),
thread the template through BoardIntentPayload, and extend the
intent-ID digest with the length-prefixed policy bytes so two boards that
differ only by policy can't collide and upsert over each other. On the
round side, a custom-policy board builds its output the same way a custom
refresh does: no owner key is derived, and the FSM still assigns the
ephemeral tree-signing key at registration.

Client-signed tree

There are two independent keys on a VTXO, and only one of them is ever
"yours" to make external: the persistent owner key (family 44), which
signs forfeit/OOR/exit, and the ephemeral per-round tree-cosign key
(family 45), which enforces the sweep covenant. The owner key is fully
handled by the custom policy above plus the existing OOR/forfeit
external-signature seams. This part is about the tree-cosign key, for the
case where you want the external group to control that too.

The core is externalMuSig2Signer, a daemon-side input.MuSig2Signer
that does no MuSig2 crypto itself. It stands in for the wallet signer for
one external cosigner key and ferries the nonce and partial signature to
and from an external party, keyed by pubkey rather than a wallet key
locator (an aggregate key isn't wallet-derivable). It captures the
operator-aggregated combined nonce via MuSig2RegisterCombinedNonce, so
round two can hand it back to the external party. The signing executor
picks this proxy per-VTXO when the signing key is marked external;
nothing changes for ordinary VTXOs.

Behind the proxy sits treeSignatureBroker, exposed over two new RPCs,
ListPendingTreeSigningRequests and SubmitTreeSignatures. It mirrors
the existing forfeit-participant broker but blocks the FSM twice, once for
the nonce and once for the partial sig, giving the two rounds distinct
request ids. The broker does no crypto: it validates the submitted bytes
structurally and relies on the operator's aggregated tree signature,
which the FSM already verifies before releasing boarding sigs, to catch a
bad partial sig downstream. Its state is deliberately daemon-local, so a
restart abandons in-flight rounds rather than resuming a stale transcript.

One thing worth calling out for review: externalizing nonce generation
moves the nonce-reuse invariant across the RPC boundary. The external
party alone is responsible for one fresh nonce per session and for never
signing twice against the same nonce. The broker binds the request id to
the session transcript and rejects a divergent re-answer, but it can't
enforce nonce freshness cryptographically. This is noted in the proto and
in the signer's doc comment.

Testing

Both parts are covered end to end. For boarding, the round actor test
asserts a custom-policy board builds its output from the template with no
owner-key derivation, the wallet tests cover outbox persistence + restart
replay + intent-id distinctness, the store test round-trips through the
000016 columns, and an RPC test exercises the validation boundary.

For tree signing, the proxy tests assert it ferries the material through
both rounds and carries the sighash and aggregate nonce; the broker tests
drive it exactly as the proxy does, over list/submit, covering the
two-round round-trip, distinct ids, timeout, and idempotent resubmit. The
integration test proves the whole daemon-side path over a real VTXO tree:
the executor drives the proxy, which routes to a backend running a real
signer, and the externally produced nonces and partial sigs cover exactly
the transactions on the cosigner's path.

  • Live e2e against the arktest regtest harness
  • Confirm a genuinely custom (non-standard-shape) owner policy round-trips through SigningKeysForSpendPath

Roasbeef added 13 commits July 22, 2026 13:44
In this commit, we extend BoardRequest with an optional
vtxo_policy_template and a companion pk_script so a client can board
directly into a VTXO owned by a custom arkscript policy, instead of
boarding into the standard collaborative shape and converting with a
follow-up refresh. The motivating case is an owner key held outside the
daemon, such as an aggregate FROST key: the template governs ownership
while the operator remains the seal-time fee authority.

An empty template preserves the legacy behavior, and the pinned script,
when supplied, must match the one the template derives.
In this commit, we add the ListPendingTreeSigningRequests and
SubmitTreeSignatures RPCs, along with their message set and the
TreeSigningRound enum, to let an external party drive the MuSig2
VTXO-tree signing ceremony for a cosigner key it controls off-box (for
example an aggregate FROST key). Each request captures one round of the
two-round ceremony for one transaction session: the NONCE round asks for
a fresh public nonce, and the PARTIAL_SIG round asks for a partial
signature over the given sighash under the operator-aggregated combined
nonce. The private key never enters the daemon; it only ferries bytes.

The gateway config gains the matching REST routes so the new methods are
reachable over both transports.
In this commit, we regenerate the gRPC, REST-gateway, and mailbox-RPC
stubs (and the devrpc command registry) for the board custom policy
fields and the new external tree-signing RPCs added in the preceding two
commits. Generated by make rpc; no hand edits.
In this commit, we add migration 000016, which extends the
pending_board_intents outbox detail table with a nullable
vtxo_policy_template and pk_script column, and update the upsert/list
queries to carry them. The columns are nullable so intents persisted
before this migration keep working and a NULL template selects the
standard collaborative policy.

The board intent is written to the outbox before the round adopts it, so
a client that boards into a custom-owned VTXO and then restarts must
replay the same policy rather than silently re-boarding into the
standard shape; persisting the template here is what makes that
restart-safe.
In this commit, we regenerate the type-safe query layer and the schema
snapshot for the board intent custom policy columns added in the
preceding commit. Generated by make sqlc; no hand edits.
In this commit, we carry the custom VTXO policy from the BoardRequest
down through the wallet actor, the pending-intent outbox, and restart
replay. BoardRequest and BoardIntentPayload gain the policy template and
pinned script, TriggerBoardMsg forwards them to the round actor, and the
pending-intent store reads and writes the new typed columns.

The intent-ID digest is extended with the length-prefixed policy bytes
so two boards that differ only by their policy hash to distinct ids and
cannot upsert over each other in the outbox. On startup the board
replayer carries the persisted policy into the self-issued BoardRequest,
so a crash between admission and round seal re-boards into the same
custom-owned output rather than the standard shape.
In this commit, we teach the round actor to build a boarded VTXO output
from a caller-supplied policy template when the board trigger carries
one. buildCustomBoardVTXORequest mirrors the custom-refresh output path:
it derives no owner key and registers no owned script, because the
policy's owner is external to this daemon, and leaves the owner and
client keys zero while the FSM still assigns the ephemeral MuSig2
tree-signing key at registration. When no template is present the actor
keeps synthesizing the standard collaborative policy with a freshly
derived owner key.
In this commit, we validate an optional board policy template at the RPC
boundary and forward it to the wallet actor. validateBoardPolicyTemplate
decodes the template and checks it against the operator's terms so the
operator co-signs every collaborative leaf and every exit leaf meets the
CSV floor, and confirms a pinned pk_script matches the template. A
pinned script without a template, or a malformed or unsafe policy, is
rejected as InvalidArgument rather than surfacing later as a round
failure. The template governs ownership only; the operator remains the
seal-time fee authority under the #270 handshake.
In this commit, we cover the board custom policy path at every layer it
threads through. The round actor test asserts a custom-policy board
builds its output from the template verbatim, deriving no owner key and
still assigning the ephemeral tree-signing key. The wallet tests assert
the policy survives outbox persistence and restart replay and that two
boards differing only by policy hash to distinct intent ids. The store
test round-trips the template and pinned script through the 000016
migration columns, and the RPC test exercises the validation boundary.
In this commit, we add an ExternalTreeSigner flag to VTXORequest. It
marks a VTXO whose MuSig2 tree-signing (cosigner) key lives outside this
daemon, so the round FSM must not derive a wallet key for it and must
route its nonce and partial-signature production to an external party
rather than the local wallet signer. When set, the signing key's public
key is the external cosigner key and its locator is ignored, because the
key is not wallet-derivable.
In this commit, we add a daemon-side input.MuSig2Signer that performs no
MuSig2 cryptography itself. It stands in for the wallet signer for one
external cosigner key and ferries nonce generation and partial-signature
production to an ExternalTreeSignerBackend, so the key material never
enters this daemon. The proxy identifies the cosigner by public key
rather than a wallet key locator, since an external aggregate key is not
wallet-derivable, and it captures the operator-aggregated combined nonce
via MuSig2RegisterCombinedNonce so round two can carry it to the
external party.

The signing executor selects this proxy per VTXO when the signing key is
marked external, wired through the round config and FSM environment.
When a VTXO is not marked external, nothing changes: the wallet signs
its tree path as before.
In this commit, we add a daemon-local broker that exposes the round
FSM's external tree-signing callbacks to an outside party over RPC. When
a VTXO's cosigner key is external, the FSM's proxy signer calls the
broker, which parks each call as a pending request, surfaces it through
ListPendingTreeSigningRequests, and unblocks it when
SubmitTreeSignatures supplies the material. The broker blocks the FSM
twice, once for the nonce and once for the partial signature, giving the
two rounds distinct request ids so they cannot collide.

The broker performs no MuSig2 cryptography: it validates the submitted
bytes structurally and relies on the operator's aggregated tree
signature, which the FSM already verifies, to reject a bad partial
signature downstream. Its state is deliberately daemon-local, so a
restart abandons in-flight rounds rather than resuming a stale
transcript. The broker is wired onto the server, granted macaroon
permissions, and exposed through the REST client.
In this commit, we cover the external tree-signing path. The proxy tests
assert it ferries the external party's nonce and partial signature
through the two MuSig2 rounds, carries the sighash and aggregate nonce,
keeps the session id stable across rounds, and rejects the operations
the client never performs. The broker tests drive it exactly as the
proxy does, over its list and submit surface, covering the two-round
round-trip, distinct request ids, timeout, and idempotent resubmit.

The integration test proves the whole daemon-side path over a real VTXO
tree: the signing executor drives the proxy, which routes to a backend
running a real signer for the cosigner key, and the externally produced
nonces and partial signatures cover exactly the transactions on the
cosigner's path.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@levmi levmi added boarding rpc RPC transport and protobuf vtxo labels Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

boarding rpc RPC transport and protobuf vtxo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants