Skip to content

fix(template): push a pubkey into a covenant as its x-only key - #3

Merged
louisinger merged 6 commits into
masterfrom
fix/covenant-pubkey-xonly
Oct 21, 2026
Merged

louisinger merged 6 commits into
masterfrom
fix/covenant-pubkey-xonly

Conversation

@Kukks

@Kukks Kukks commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Constructor public keys now use the same 32-byte x-only encoding in covenant and leaf lookups. A covenant previously received a 33-byte compressed key, so comparing it with a taproot witness program failed and its derived address disagreed with the SDK. Both lookups share the existing conversion. DELEGATE_KEY and TWEAK retain their compressed-key conventions.

TestBuildCovenantPushesXOnlyPubkey pins the original failure. No bundled template puts a pubkey constructor field in its covenant assembly, so their addresses remain stable.

The branch includes current master (78391fc) without rewriting its published history. The merge retains master's delegation stream, template/health API and gRPC 1.83.2 changes. The resulting PR diff contains only the x-only helper and its regression test; the earlier Go/HTTP/2 dependency updates are now on master.

Validation

  • Previous head 56fb40ba: all seven GitHub checks passed, including funded regtest and PostgreSQL integration.
  • Updated head e3c3f66: local Linux Go 1.26.9 race/coverage suites pass: baseline 309 top-level tests /712 passing test events becomes 333 /740 after incoming master, with zero failures. Application build, root/template/API vet, standalone API tests and formatting of all 106 tracked Go files pass. Formatting used temporary Linux copies; Windows source line endings were preserved.
  • All seven GitHub checks pass at e3c3f66, including the image security scan and funded regtest/PostgreSQL job. The integration log records 34 passing tests, zero failures and the existing TestLiveDelegatee gate skip.
  • The existing live Mutinynet test remains gated; regtest does not establish live deployment evidence.

Review

CodeRabbit reviewed the updated e3c3f66 contract and reported no actionable comments. The latest Arkana formal review also covers e3c3f66 and reports no new blocking issues. Its low-priority taptree/cosigner invariants were checked against current source and answered on the PR. Earlier queue-recovery and watch/spend quota follow-ups concern unchanged upstream paths. This refresh preserves those contracts; human protocol approval and live Mutinynet validation remain separate gates.

leafLookup already turns a pubkey constructor value into the 32-byte x-only
key a taproot script uses, but covenantLookup passed every field through raw,
so the same key reached a covenant as 33 compressed bytes. A covenant that
compares a key with a taproot witness program (`scriptPubKey == key`) can
then never succeed, and the derived address disagrees with the one the
ts-sdk and arkadec's own e2e derive from the same artifact and params: both
bind a pubkey with schnorr.SerializePubKey.

Both lookups now share one conversion. DELEGATE_KEY keeps its hex spelling,
since intent messages carry cosigner keys that way. No bundled artifact puts
a pubkey in a covenant, so no existing template's address moves.
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 774e3108-b2c3-43bb-95b6-8640210602b9

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 811380aa-b9b2-4388-90d8-9ded3d4b06d4
📥 Commits

Reviewing files that changed from the base of the PR and between 56fb40b and e3c3f66.

📒 Files selected for processing: 1
  • pkg/template/contract.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Walkthrough

Contract covenant and leaf lookups now use a shared helper that converts pubkey constructor values to x-only keys and returns parse errors. A test checks the x-only key in the covenant script.

Changes

Pubkey normalization

Layer / File(s) Summary
Normalize pubkeys in contract lookups
pkg/template/contract.go, pkg/template/contract_test.go
buildTree uses keyField for covenant and leaf lookups. The helper converts pubkey values to x-only keys, returns parse errors, and leaves other values unchanged. A test checks the covenant script output.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Suggested reviewers: louisinger

Merge Risk: ⚪ Minimal · up to e3c3f

Constructor pubkeys are normalized for covenant and leaf scripts while the special compressed-key paths remain unchanged. The relevant tests cover both lookup paths, and no actionable merge-blocking risk remains.

Pre-merge checks: Passed 5
Check name Status Explanation
Docstring Coverage Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 files.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: pushing public-key constructor values into covenants as 32-byte x-only keys.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Fix is correct. No changes required.


What I verified

keyField helper (contract.go:34–44)
Logic is sound. The if err != nil || v.typ != "pubkey" guard passes non-pubkey values and errors through unchanged; only pubkey-typed values are converted. btcec.ParsePubKey on a well-formed 33-byte compressed key (the only thing canonicalBytes allows through) will always succeed, so no silent truncation or panic surface is added.

covenantLookup change (contract.go:56)
Swapping field(name) → keyField(name) is the minimal correct fix. The DELEGATE_KEY branch is correctly left alone: intent messages carry cosigner keys as hex-encoded compressed bytes by protocol design, and that convention is unchanged.

leafLookup simplification (contract.go:109)
Removing the explicit v.typ == "pubkey" block and delegating to keyField produces identical behaviour. The TWEAK branch (contract.go:98–105) correctly keeps calling field directly — it needs the 33-byte compressed form to call btcec.ParsePubKey for the twist, so it must not go through keyField.

Address stability
I checked every bundled template (delegated_vtxo.json, boarding.json, renewal.json, vhtlc_claim.json, vhtlc_refund.json) and all pkg/template/testdata/ artifacts: none references a pubkey constructor field inside a covenant asm section. No deployed address changes.

Test (contract_test.go:67–79)
TestBuildCovenantPushesXOnlyPubkey directly reproduces the failure (21-byte push before, 20-byte after) and pins the correct output. Coverage is adequate for this targeted regression.


CI note

All CI jobs failed with "account payments have failed / spending limit" — a billing infrastructure issue unrelated to the code. The PR author reports go test ./pkg/template/, go vet, and go build ./... pass locally, which is consistent with the change.


Nits (non-blocking)

  • keyField is a small closure that only does one thing; no objection to keeping it inline as written. If this file grows further, a named package-level helper could be worth extracting, but that is out of scope here.
  • The test covenant (<owner> OP_DROP OP_1) is intentionally minimal to isolate encoding behaviour — appropriate for this case.

LGTM.

Kukks added a commit to ArkLabsHQ/arkade-taxi that referenced this pull request Oct 9, 2026
…smatch

The registration lands after receiveQuotes.bind --- which is the commit
--- and before fillToResponse offers the lockup. It is a network call
and has no business inside the transaction, and the ordering means the
advance row exists when a mismatch throws, so the failure is
diagnosable rather than invisible. The cost is the reverse: a mismatch
strands one advance and its input reservations until the quote expires.
Registering before bind would avoid that and instead leave a watch with
no advance behind a race; watches are idempotent on their fingerprint
and expire, so either is recoverable, and this ordering was the one
asked for.

It is gated on covenantVersion === 2 and on a configured delegatee, so
a v1 fill never calls it and an unset TAXI_DELEGATEE_URL leaves v2
covenants unrenewed rather than refusing to quote.

cli.ts registers the artifact and the template once at startup, before
any quote can run; both calls are idempotent on their derived id.

A mismatch means the two sides disagree about the taptree, and under D2
that covenant would never be renewed, so registerRenewal throws. That
is not hypothetical: against delegatee master f12e97e the derivations
differ, because contract.go:42-51 pushes a `pubkey` constructor
argument into the arkade covenant raw, 33 compressed bytes, where
contract.go:101-107 x-onlys the same argument for a tapscript leaf.
arkade-os/executor#3 fixes it upstream; the bytes32 workaround was
declined, so this gate is what stands between that bug and a covenant
nobody renews.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up pass: 8 commits since 9931409, including feat: spend outpoint, parallel intent confirmation, pool-based output allocation, Go 1.26.9 + dependency bumps. 0 prior issues to close (previous review was LGTM with no open findings).


Prior findings

None were open. All closed trivially — no prior issues remain.


New findings

Low — poolOf nil dereference (unreachable, but latent panic)

pkg/template/template_outputs.go:174–182, called at pkg/template/outputs.go:51:

if pool := t.poolOf(slot); pool[0] == slot {

poolOf returns nil when no output draws from slot. checkBalances (enforced at parse time) guarantees every input has exactly one remainder output, so this is unreachable through Parse(). But the call site dereferences without a nil guard. A defensive check costs nothing:

if pool := t.poolOf(slot); pool != nil && pool[0] == slot {

Low — queuedKey uses only inputs[0]

internal/core/application/lanes.go:20–21:

func queuedKey(p *pendingIntent) string { return p.inputs[0].coin.Outpoint.String() }

For the reconnection-recovery path in register(), two intents that share inputs[0] but differ on later inputs could map to the same queue key and the wrong queued entry would be reused. In the current design this can't happen: "duplicated input" from arkd means all of the intent's inputs are duplicated, so the queued entry is definitionally the right one. The assumption isn't documented. A comment on queuedKey to that effect would help the next reader.


Low — "duplicated input" string matching for arkd recovery

internal/core/application/renewal.go in register():

strings.Contains(err.Error(), "duplicated input")

This couples the reconnection-recovery path to arkd's exact error text. If arkd changes that message, the service silently fails to recover the queued intent (it returns an error instead of reusing the session). Not security-critical, but a reliability regression on arkd updates. Worth extracting into a sentinel error on the arkd client interface, or at least a named constant string.


Design note — watch and spend racing on the same coin

internal/core/application/discovery.go:258: spends are excluded from boundOwners, so a watch that covers the same coin will still discover it and attempt renewal. TestWatchKeepsCoinsASpendBinds confirms this is intentional. The practical consequence: if both the spend and the watch run in the same cycle, the watch's renewal fails with a "coin already spent" error, and that failure is charged against the watch's template quota (only spend results are excluded from templateOutcomes, not watch failures on coins a spend consumed). No correctness bug — the watch retries, the coin is already gone, the delegation expires normally — but the first failure log will be confusing without spend context and may hit the template's refusal threshold on a tight quota. If this matters operationally, the watch's failure reason could check for "already spent" before counting it.


Things checked and found correct

  • spendMessage uses TaggedHash("delegatee/spend", fp_bytes || expires_unix_be_u64) — properly binds both the authorized scope and its lifetime; checkSpendSignature verifies a Schnorr signature with that message against the x-only exit key. Sound.
  • exitKeyOfEverySlot — requires a CSV single-pubkey closure per slot. Correctly rejects keys that are only in multi-key leaves or non-exit leaves.
  • IsSpend() discriminator (Address == "" && ExpiresAt != nil) — consistent with how all three delegation kinds set those fields across the codebase; idempotent re-registration and the "outpoint bound by another spend" DB rejection are both tested.
  • followBatches goroutine + refused slice — refused is written exclusively by the goroutine before close(registered) and read only after <-registered. Happens-before is sound; no data race.
  • Parallel ConfirmRegistration in OnBatchStarted — errgroup, unconfirmed intents return to pending, empty confirmation returns skip=true. Correct; tested in TestBatchHandlerConfirmsInParallel.
  • Pool allocation in routeSats — sums the whole pool, deducts fixed outputs, deducts fee (via slices.Contains(pool, t.fee.from)), assigns remainder. Balanced; TestAllocatePooled pins the arithmetic.
  • batch: lane{queued: map[string]*pendingIntent{}} in NewServiceWithKeys — nil map write was the pre-existing bug that the monitor-intent-queue commit fixed. Correct.
  • expiresAt range gate in RegisterSpend — After(now) and Before(now+24h) — prevents stale and excessively long-lived spends; tested in "expired" and "beyond a day".
  • Dependency bumps — x/net v0.60.0, x/crypto v0.57.0, x/sync v0.23.0 and companion packages updated consistently across all three go.mod files.

@Kukks

Kukks commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

I checked the follow-up review against current master while refreshing this branch. The remaining PR diff is only the covenant/leaf x-only conversion and its regression test; the dependency updates and queue/spend changes discussed in the follow-up are now upstream.

The suggested poolOf guard is unreachable through the supported construction path: Parse calls checkBalances, which rejects an input without exactly one remainder output, before returning a Template. I am keeping that existing invariant rather than adding a second check in the allocator.

The queue identity/error-text and watch/spend quota notes remain reasonable upstream follow-ups. This refresh does not change these paths or claim to solve their design questions. DELEGATE_KEY and TWEAK still use their original compressed-key inputs, while public-key constructor values in covenants and leaves use x-only bytes.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 commits since 56fb40b. Prior findings: 1 no longer applicable, 2 still open but out of scope for this diff, 1 partially addressed. No new blocking issues.


Prior findings — close-out

# Finding Status
1 poolOf nil dereference (template_outputs.go) No longer applicable — @Kukks explicitly chose the checkBalances invariant over a defensive guard; template_outputs.go is unchanged in this delta.
2 queuedKey uses only inputs[0] without a documenting comment (lanes.go) Still open — lanes.go is not in this diff. Not worse.
3 "duplicated input" string-match coupling (renewal.go) Still open — renewal.go is not in this diff. Not worse.
4 Watch/spend quota design note (discovery.go) Partially addressed — the IsSpawned() sort in discovery.go now gives spawned watches priority over public watches on the same script, reducing the collision likelihood. The quota-charging concern (a failed watch costing a slot before a spend is visible) is not directly addressed, but the blast radius is smaller.

Incremental findings

pkg/template/contract.go — new buildTree / fromTree / decodeTaptree / taptreeScript

Protocol logic is sound. Walk-through:

  • decodeTaptree (~line 175): 8 KB cap → txutils.DecodeTapTree → TapscriptsVtxoScript.Decode. Validates every leaf as an ark-acceptable closure before the delegatee accepts the tree. Zero-leaf guard is correct. The cap prevents a DoS-sized PSBT from stalling trigger.Match.
  • fromTree: Maps declared leaves to tree positions by hex-encoded script equality. Returns tree-ordered leaves (not declared-order), so buildTree assembles the same Merkle root as taptreeScript computes — c.pkScript will match the matched output's PkScript when the trigger fires. Correct.
  • Unnamed-leaf guard (buildTree loop): names[i] == ([2]string{}) skips proof generation for tree-only leaves. Correct and tested.
  • taptreeScript inside trigger.Match: Output is skipped when err != nil or the derived key doesn't match o.PkScript. So react never receives an output whose taptree failed validation.
  • confineTaptrees: Prevents a taptree variable from appearing inside an asm / arguments / locking / ciphertext binding. Correct — avoids the push-size cap and script injection.
  • isTaptree skips canonicalBytes in Instantiate: Correct; the taptree byte slice is not pushed, so the per-element size limit does not apply. Validation is deferred to decodeTaptree.

Low / nit — tapLeaves silently swallows hex decode error (contract.go, tapLeaves function):

s, _ := hex.DecodeString(h)

Every caller obtains h from decodeTaptree → txutils.DecodeTapTree, which hex-encodes the scripts, so the decode will always succeed in practice. Still, the silent _ is the only unchecked error in the file. A panic guard or a named error return would make the invariant explicit without changing behaviour for callers.

Low — duplicate script in the given taptree (fromTree):

for i, h := range scripts {
    at[h] = i  // last occurrence wins on duplicate
}

If the taptree has two identical scripts, only the last index is recorded; the proof attached to that position is valid for either occurrence (both control blocks cover the same script). TestTaptreeContract covers this ("a duplicated leaf is still in the tree"), so behaviour is intentional and tested. Noting for future readers: the proof references the last-indexed position.


internal/core/application/stream.go (new file)

react is correct. Binding.Output comes from range tx.UnsignedTx.TxOut inside trigger.Match, so it is always a valid index into ptx.UnsignedTx.TxOut; no bounds issue.

setStreams / streamSet mutex discipline: streamSet returns a snapshot of the slice before releasing the lock; react iterates the snapshot, so concurrent setStreams calls do not corrupt the iteration. Correct.

readTransactions / watchStreams reconnect: error-suppression after the first warning, 1 s retry, streamsChanged channel with non-blocking send — all correct.

Low — undocumented invariant in triggerKeys (stream.go, triggerKeys function):

k := template.TriggerKeys{..., Delegate: s.cosigners[0].key.PubKey()}

s.cosigners[0] is accessed without a len guard. Service initialisation enforces at least one cosigner, but the assumption is not expressed at this site. A comment cross-referencing that invariant (or a panic guard consistent with how similar assumptions are handled elsewhere) would make the site self-documenting.


internal/core/application/discovery.go

successor cap change: the old }, own) passed a bool as the max int parameter (false→0 = no cap, true→1 = cap at 1). New code:

limit := s.limits.MaxDelegations
if own { limit = 0 }   // operator's own: no cap

Semantics are intentionally inverted relative to the old code: the operator's own successor discoveries bypass MaxDelegations (uncapped), while public ones are capped. Correct for the stream-spawned-watch use case and consistent with MaxStreamChildren in Limits.

IsSpawned() sort: spawned watches are sorted first, so a public watch on the same script cannot shadow a spawn in the same discovery cycle. Correct and intentional.


internal/core/application/spend.go

tmpl.Trigger() != nil → errStreamOnly guard is correctly placed before the slot-count check. RegisterSpend now uses s.limits.MaxDelegations instead of false (0); spend registrations now count against the cap. Correct fix.


Test coverage

TestTaptreeContract, TestTaptreeParse, TestNonInteractiveClaimAddress, TestNonInteractiveClaimNeedsTheClaimLeaf, TestNonInteractiveClaimCovenant directly exercise the new buildTree path, variable confinement, and failure modes. TestRegisterStreamDelegation, TestPublicRegistrationRefusesTriggers, TestScanHandsStreamsToTheWatcher cover the stream lifecycle. Coverage is adequate.


No blocking issues. The two low/nit items above are the only new findings.

@Kukks

Kukks commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Checked the nonblocking notes in the latest review against e3c3f66df02a9ca6c06dd67a0d0d92f6f7f8d952. The PR diff against current master is still limited to pubkey normalization in pkg/template/contract.go and its regression test.

  • tapLeaves already records its input invariant at contract.go:197: DecodeTapTree hex-encodes the scripts. Both callers obtain scripts through decodeTaptree; the ignored decode error therefore does not add an unvalidated public-input path.
  • The first-cosigner access comes from upstream stream support. newCosigners refuses an empty key set. That initialization invariant is unchanged by the x-only fix.

No additional guards or upstream stream changes are included in this refresh. All seven current-head checks pass, including funded regtest/PostgreSQL coverage; this is separate from live Mutinynet and human protocol sign-off.

@louisinger
louisinger merged commit 7f3adff into master Oct 21, 2026
6 of 7 checks passed
@louisinger
louisinger deleted the fix/covenant-pubkey-xonly branch October 21, 2026 21:41

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 commit since last pass ("cleaning" — extract closures to package-level functions); 0 prior findings fixed, 0 made worse, no new blocking issues.


Prior findings — close-out

# Finding Status
1 poolOf nil dereference (template_outputs.go) No longer applicable — unchanged; @Kukks' checkBalances-invariant decision stands
2 queuedKey uses only inputs[0] without a documenting comment (lanes.go) Still open — not in this diff, not worse
3 "duplicated input" string-match coupling (renewal.go) Still open — not in this diff, not worse
4 Watch/spend quota design note (discovery.go) Still open — not in this diff, not worse
5 tapLeaves silent _ on hex decode (contract.go) Fixed — invariant comment (// DecodeTapTree hex-encodes) present in the file; @Kukks' response accepted
6 triggerKeys undocumented first-cosigner access (stream.go) Acknowledged — @Kukks documented the newCosigners invariant; not in this diff

Incremental findings — pkg/template/contract.go @ 3141ef5

The "cleaning" commit does exactly one thing: it lifts the four closures that were defined inside buildTree (field, keyField, delegate, covenantLookup, tweak) into unexported package-level functions and passes the formerly-captured state as explicit parameters. All paths and all call sites are semantically identical to the prior head. No behaviour changed.

Nit — gofmt violations (two)

1. Leading space before covenantLookup declaration (contract.go, the func covenantLookup line):

 func covenantLookup(keys Keys, args map[string]value, name string) (value, error) {

There is a stray leading space before func. gofmt rejects this; make fmt would fix it.

2. Trailing space in struct literal (contract.go, c := &contract{…}):

		covenants: map[string][]byte{}, 

Trailing whitespace on that line. Also caught by gofmt / golangci-lint.

Neither affects compilation or correctness. Running make fmt before merge is sufficient.


No protocol, security, or correctness issues in this diff. The two open upstream findings (items 2 and 3 above) are pre-existing and out of scope for this change.

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.

3 participants