Skip to content

feat(covenantsigner): wallet-signed EIP-712 approvals + cooperative REDEEM/RENEW#4169

Open
piotr-roslaniec wants to merge 4 commits into
feat/psbt-covenant-final-project-prfrom
feat/covenant-eip712-eth-leg
Open

feat(covenantsigner): wallet-signed EIP-712 approvals + cooperative REDEEM/RENEW#4169
piotr-roslaniec wants to merge 4 commits into
feat/psbt-covenant-final-project-prfrom
feat/covenant-eip712-eth-leg

Conversation

@piotr-roslaniec

@piotr-roslaniec piotr-roslaniec commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Enables wallet-signed (eth_signTypedData_v4) covenant artifact approvals and adds
the cooperative REDEEM / RENEW action paths to the keep-core covenant signer.

This is the keep-core prerequisite (section A of the VBA EIP-712 change-list) that must land
before this covenant work goes live: today the signer verifies a bare EIP-712 struct
hash
(no 0x1901, no domain separator) against the depositor's secp256k1 key, which
no connected ETH wallet can produce. This PR domain-wraps the digest, binds the depositor
approval to a pinned ETH identity via ecrecover, and builds out the redeem/renew
transaction path.

Stacked on top of #3882 (feat/psbt-covenant-final-project-pr) — the only branch carrying
pkg/covenantsigner/. Tracked by tlabs-xyz/vba-dashboard#172:
https://github.com/tlabs-xyz/vba-dashboard/issues/172

What changed

A1 - Domain-wrap the digest (EIP-712 v2, off-chain / salt-based).
The approval digest is now keccak256(0x1901 ‖ domainSeparator ‖ structHash) with domain
EIP712Domain(string name,string version,uint256 chainId,bytes32 salt) (no
verifyingContract, since verification is off-chain). name = "tBTC Covenant Artifact Approval", version = "2", chainId + salt from signer config (default salt =
keccak256("tBTC Covenant Artifact Approval Domain v2")). Domain params are threaded from
config → validation options → digest, including the exported ComputeArtifactApprovalDigest
used by pkg/tbtc.

A2 - ETH identity binding. Optional EthAddress on DepositorTrustRoot (keyed by
route/reserve/network). When pinned, the depositor approval is verified via
ecrecover-and-compare (65-byte r‖s‖v, v in {27,28} or {0,1}, low-S enforced);
otherwise it falls back to the secp256k1 script key. Custodian role stays secp256k1.

A3 - Hard cut to approvalVersion = 2 (v1 rejected). Nothing is deployed yet, so no
transition window is needed.

A4 - Vectors. Regenerated the v2 approval vectors and added a real-wallet vector
whose digest is cross-verified byte-identical against the independent eth_account
(0.13.7) EIP-712 encoder
— genuine eth_signTypedData_v4 compatibility, not a
self-referential check. A startup guard rejects a config that pins depositor ETH addresses
while eip712ChainId == 0.

A5 - Cooperative REDEEM / RENEW path. New CovenantAction discriminator
(MIGRATION / REDEEM / RENEW; empty defaults to MIGRATION), RedeemDestinationReservation /
RenewDestinationReservation, redeem/renew commitment schemas (SHA-256 over canonical JSON,
mirroring migration), action-dispatched validation, and an action branch in
buildCovenantTransactionBuilder. The built transaction is proven to pay exactly the
committed output — the transaction plan's destinationValueSats is bound to the committed
output value.

Commitment schemas (normative contract for the client repos to mirror):

  • redeem: {reserve, epoch, route, revealer, vault, network, outputScriptHash, outputValueSats}
  • renew: {reserve, epoch, route, revealer, vault, network, nextCovenantScriptHash, nextMaturityHeight, outputValueSats}

Client-side contract (must be mirrored byte-for-byte)

  • digest = keccak256(0x1901 ‖ domainSeparator ‖ structHash).
  • Struct: ArtifactApproval(uint8 approvalVersion,bytes32 route,bytes32 scriptTemplateId,bytes32 destinationCommitmentHash,bytes32 planCommitmentHash), approvalVersion = 2.
  • route / scriptTemplateId are bytes32 = keccak256(identifier string) — the dApp must
    pre-hash them; a wallet encoder given the raw bytes32 reproduces the digest.

Verification

  • go test ./pkg/covenantsigner/... — 156 passed
  • go test ./pkg/tbtc/... — 275 passed (full suite)
  • go build ./... — green; gofmt clean
  • Headline flow proven end-to-end: a wallet-signed (ETH-identity) cooperative REDEEM is
    accepted through Service.Submit, and the signed transaction pays exactly the committed
    redeem/renew output (scriptPubKey + value).

Out of scope

The section-B/C client sync (covenant-manager, dashboard engine.ts, verification kit,
co-sign service, dual-connection wallet UX) lives in other repos. The pre-existing migration
depositScript-as-output gap is flagged, not fixed here — the redeem/renew paths sidestep it
by carrying an explicit output scriptPubKey.

…-signed ETH leg (v2)

Enable wallet-signed (eth_signTypedData_v4) covenant artifact approvals by
domain-wrapping the approval digest and binding the depositor approval to a
pinned Ethereum identity.

- Wrap the digest: keccak256(0x1901 ‖ domainSeparator ‖ structHash) with an
  off-chain, salt-based EIP712Domain(name,version,chainId,salt). chainId+salt are
  configured on the signer (Config.EIP712ChainId/EIP712Salt) and threaded through
  validation and the tBTC engine so recomputed digests stay consistent.
- Verify the depositor approval against an optional pinned DepositorTrustRoot
  ethAddress via ecrecover-and-compare (65-byte sig, v in {27,28}/{0,1}, low-S
  enforced); fall back to the secp256k1 script key when unset. Custodian
  verification is unchanged.
- Hard cut approvalVersion to 2 (v1 rejected).
- Regenerate approval vectors to _v2; add a real-wallet signature vector whose
  digest is cross-verified byte-identical against an independent EIP-712 encoder,
  an ecrecover integration test (accept + reject), and a startup guard that fails
  loudly when an ethAddress is pinned without a configured chainId.

Refs: keep-core prerequisite for vba-dashboard#172.
keep-core previously implemented only the MIGRATION covenant action. Add the
cooperative REDEEM (pay out to a depositor-chosen address) and RENEW (re-lock
into the next-epoch covenant) actions end to end.

- Add a CovenantAction discriminator (MIGRATION/REDEEM/RENEW) to RouteSubmitRequest
  (empty defaults to MIGRATION) plus RedeemDestinationReservation and
  RenewDestinationReservation, alongside the existing migration destination.
- Define the redeem/renew destination commitment schemas (SHA-256 over canonical
  JSON, mirroring the migration commitment) binding the covenant identity to the
  payout/next-covenant script hash and value. These are the normative schemas the
  client (covenant-manager / dashboard / verification kit) must mirror.
- Dispatch destination validation on the action and reject destinations that do
  not belong to it; the migration plan-quote path stays MIGRATION-only. Bind the
  transaction plan's destinationValueSats to the committed output value so the
  signed output cannot diverge from what the depositor approved.
- Branch the transaction builder on the action to pay the migration deposit
  script, the redeem payout script, or the renew next-covenant script; the input,
  anchor output, and threshold signing are shared unchanged.

Tests cover the commitment schemas, action-dispatched validation (accept/reject),
and assert the signed transaction pays exactly the committed redeem/renew output.

Refs: keep-core prerequisite for vba-dashboard#172.
…new actions

trustRootLookupScope read the scope network only from MigrationDestination, so a
wallet-signed REDEEM/RENEW request (which carries a redeem/renew destination, not
a migration one) resolved an empty network and failed depositor trust-root
matching. Read the network from the destination for the request's action.

Add the headline end-to-end test for vba-dashboard#172: a cooperative REDEEM
whose depositor artifact approval is signed by a pinned ETH identity is accepted.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 57a8a581-b592-4196-a696-fc0a20ed2425

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/covenant-eip712-eth-leg

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Address findings from the PR security/simplicity review:

- Reject the zero address as a pinned depositor ethAddress so a
  misconfiguration cannot make ecrecover compare against 0x0.
- Reject mixing ethAddress presence across network entries of the same
  (route, reserve) depositor trust root, closing a silent ETH->secp
  verification downgrade steered by the request-supplied network value.
- Fail closed on unrecognized artifact-approval roles in the authenticity
  loop instead of silently skipping them.
- Correct the defaultArtifactApprovalDomainSalt comment: cross-deployment
  separation comes from the unique reserve/vault in destinationCommitmentHash
  plus trust-root scoping, not from scriptTemplateId; recommend per-deployment
  eip712Salt overrides.
- Drop the misleading "predate the field" backward-compatibility note on
  CovenantAction; v1 approvals are hard-rejected.
@piotr-roslaniec

Copy link
Copy Markdown
Collaborator Author

Security-review follow-ups (deferred from this PR)

The hardening findings addressed in d3afaa7f7 are self-contained. Three further findings were intentionally not included here because each reaches beyond the signer or conflicts with a recent decision. Documenting them so they aren't lost:

1. REDEEM/RENEW carry no freshness/authority bound — deferred (needs decision)

MIGRATION is gated by an ed25519 migrationPlanQuote with an expiresAt check (validation_quote.go), which is skipped for REDEEM/RENEW (validation.go:353). ArtifactApprovalPayload has no expiry field, so a REDEEM/RENEW depositor signature is long-lived and replayable within the epoch. Mitigation present: activeOutpointTxid/Vout is bound inside planCommitmentHash, pinning the input UTXO.

Why deferred: the "cryptographically bind and enforce signer-approval expiry" feature was reverted twice recently (af236a25a, 2faf6e764). Re-introducing a freshness field reverses that deliberate decision and is a protocol change requiring coordinated client + orchestrator updates. Needs a design call before implementation.

Options: add an expiry field to the approval payload; require a parallel REDEEM/RENEW plan-quote; or explicitly document that epoch-scoped UTXO binding satisfies the threat model and why.

2. MigrationTransactionPlan name leaks into REDEEM/RENEW — deferred (cross-repo)

REDEEM payout and RENEW re-lock values bind to MigrationTransactionPlan.DestinationValueSats; the struct, fields, and JSON key migrationTransactionPlan all say "migration" while serving three actions. Every mirroring client (covenant-manager / dashboard / verification-kit) must send a field literally named "migration" to redeem/renew.

Why deferred: a back-compat input alias alone doesn't fix the response side (the server still emits migrationTransactionPlan), so client-facing confusion persists until the three client repos are updated together. Belongs in a coordinated cross-repo rename (e.g. to covenantTransactionPlan) rather than a signer hardening commit.

3. Empty depositorTrustRoots falls back to request-supplied key — already addressed

When depositorTrustRoots is empty and structured signing is enabled, the depositor key is caller-supplied and only a warning is logged (server.go:110-118).

Status: this is already enforceable via the existing requireApprovalTrustRoots config flag — validateRequiredApprovalTrustRoots (server.go:190) hard-errors on missing trust roots when the flag is set. The warn-only default is the intentional opt-in posture. Recommend documenting the flag rather than changing the default to a hard error (which would break trustless dev/test deployments).


Suggested next steps: track (1) and (2) as separate issues; treat (3) as resolved via config documentation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant