Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ type startDeps struct {
perfMetrics *clientinfo.PerformanceMetrics,
minActiveOutpointConfirmations uint,
bridgeCovenantFraudDefenseConfirmed bool,
eip712ChainID uint64,
eip712Salt [32]byte,
) (covenantsigner.Engine, error)
initializeSigner func(
ctx context.Context,
Expand Down Expand Up @@ -242,6 +244,13 @@ func startWithDeps(cmd *cobra.Command, deps startDeps) error {
btcChain,
)

eip712Salt, err := covenantsigner.ResolveEIP712DomainSalt(
clientConfig.CovenantSigner.EIP712Salt,
)
if err != nil {
return fmt.Errorf("error resolving covenant signer EIP-712 salt: [%v]", err)
}

covenantSignerEngine, err := deps.initializeTbtc(
ctx,
tbtcChain,
Expand All @@ -256,6 +265,8 @@ func startWithDeps(cmd *cobra.Command, deps startDeps) error {
perfMetrics, // Pass the existing performance metrics instance to avoid duplicate registrations
clientConfig.CovenantSigner.MinActiveOutpointConfirmations,
clientConfig.CovenantSigner.BridgeCovenantFraudDefenseConfirmed,
clientConfig.CovenantSigner.EIP712ChainID,
eip712Salt,
)
if err != nil {
return fmt.Errorf("error initializing TBTC: [%v]", err)
Expand Down
135 changes: 135 additions & 0 deletions pkg/covenantsigner/approval_vectors_generator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package covenantsigner

import (
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
)

const approvalContractVectorsPath = "testdata/covenant_recovery_approval_vectors_v2.json"

// TestGenerateCovenantRecoveryApprovalVectorsV2 regenerates the canonical
// approval-contract vectors from the same production code the runtime uses:
// artifactApprovalDigest for the v2 domain-wrapped approval digest and
// requestDigest (which normalizes first) for the request digest. Every protocol
// change that moves either digest — a new approval or certificate version, a
// changed EIP-712 domain, a new normalization rule — therefore has a
// reproducible way to refresh the fixture instead of hand-edited hashes.
//
// Writes are guarded so the fixture cannot drift silently:
//
// UPDATE_GOLDEN=1 go test ./pkg/covenantsigner \
// -run '^TestGenerateCovenantRecoveryApprovalVectorsV2$' -count=1
//
// Without UPDATE_GOLDEN the test verifies the checked-in derived values instead
// of rewriting them, so an unregenerated fixture fails here as well as in the
// vector assertion tests.
func TestGenerateCovenantRecoveryApprovalVectorsV2(t *testing.T) {
data, err := os.ReadFile(approvalContractVectorsPath)
if err != nil {
t.Fatal(err)
}

current := approvalContractVectorsFile{}
if err := strictUnmarshal(data, &current); err != nil {
t.Fatal(err)
}

regenerated := approvalContractVectorsFile{
Version: current.Version,
Scope: current.Scope,
Vectors: make(map[string]approvalContractVector, len(current.Vectors)),
}

for key, vector := range current.Vectors {
request := RouteSubmitRequest{}
if err := strictUnmarshal(vector.CanonicalSubmitRequest, &request); err != nil {
t.Fatalf("vector %s: %v", key, err)
}
if request.ArtifactApprovals == nil {
t.Fatalf("vector %s: artifact approvals are required", key)
}

digestBytes, err := artifactApprovalDigest(
request.ArtifactApprovals.Payload,
testEIP712ChainID,
testEIP712Salt,
)
if err != nil {
t.Fatalf("vector %s: %v", key, err)
}
approvalDigest := "0x" + hex.EncodeToString(digestBytes)

// The certificate commits to the artifact approval it was issued over, so
// the certificate's approvalDigest is the same v2 domain-wrapped digest.
// Rebinding it here keeps the two in lockstep across a domain change.
if request.SignerApproval != nil {
request.SignerApproval.ApprovalDigest = approvalDigest
}

canonicalSubmitRequest, err := json.Marshal(request)
if err != nil {
t.Fatalf("vector %s: %v", key, err)
}

expectedRequestDigest, err := requestDigest(request, validationOptions{})
if err != nil {
t.Fatalf("vector %s: %v", key, err)
}

regenerated.Vectors[key] = approvalContractVector{
CanonicalSubmitRequest: canonicalSubmitRequest,
ExpectedApprovalDigest: approvalDigest,
ExpectedRequestDigest: expectedRequestDigest,
}
}

if os.Getenv("UPDATE_GOLDEN") != "1" {
for key, vector := range regenerated.Vectors {
existing, ok := current.Vectors[key]
if !ok {
t.Fatalf("vector %s is missing from %s", key, approvalContractVectorsPath)
}
if !strings.EqualFold(
existing.ExpectedApprovalDigest,
vector.ExpectedApprovalDigest,
) {
t.Errorf(
"vector %s approval digest is stale\nchecked in: %s\nregenerated: %s\nrerun with UPDATE_GOLDEN=1",
key,
existing.ExpectedApprovalDigest,
vector.ExpectedApprovalDigest,
)
}
if !strings.EqualFold(
existing.ExpectedRequestDigest,
vector.ExpectedRequestDigest,
) {
t.Errorf(
"vector %s request digest is stale\nchecked in: %s\nregenerated: %s\nrerun with UPDATE_GOLDEN=1",
key,
existing.ExpectedRequestDigest,
vector.ExpectedRequestDigest,
)
}
}

return
}

encoded, err := json.MarshalIndent(regenerated, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
approvalContractVectorsPath,
append(encoded, '\n'),
0644,
); err != nil {
t.Fatal(err)
}

t.Logf("regenerated %s", approvalContractVectorsPath)
}
9 changes: 9 additions & 0 deletions pkg/covenantsigner/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,13 @@ type Config struct {
// When set, the store acquires an exclusive file lock to prevent concurrent
// process corruption. When empty, file locking is skipped.
DataDir string `mapstructure:"dataDir"`
// EIP712ChainID pins the chainId of the EIP-712 domain used to wrap the v2
// artifact approval digest. It must equal the chain the depositor's wallet
// signs on (eth_signTypedData_v4 enforces the active chain matches). Set
// this to the covenant's Ethereum chain (e.g. 1 mainnet, 11155111 Sepolia).
EIP712ChainID uint64 `mapstructure:"eip712ChainId"`
// EIP712Salt optionally overrides the EIP-712 domain salt (32-byte hex).
// When empty, a fixed program-namespace salt is used. It must match the
// client (wallet / covenant-manager / dashboard) domain construction.
EIP712Salt string `mapstructure:"eip712Salt"`
}
75 changes: 63 additions & 12 deletions pkg/covenantsigner/covenantsigner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ func loadApprovalContractVector(
) (RouteSubmitRequest, string, string) {
t.Helper()

data, err := os.ReadFile("testdata/covenant_recovery_approval_vectors_v1.json")
data, err := os.ReadFile("testdata/covenant_recovery_approval_vectors_v2.json")
if err != nil {
t.Fatal(err)
}
Expand All @@ -252,10 +252,10 @@ func loadApprovalContractVector(
if err := strictUnmarshal(data, &vectors); err != nil {
t.Fatal(err)
}
if vectors.Version != 1 {
if vectors.Version != 2 {
t.Fatalf("unexpected vector version: %d", vectors.Version)
}
if vectors.Scope != "covenant_recovery_approval_contract_v1" {
if vectors.Scope != "covenant_recovery_approval_contract_v2" {
t.Fatalf("unexpected vector scope: %s", vectors.Scope)
}

Expand Down Expand Up @@ -296,6 +296,17 @@ const (
testCustodianPrivateKeyHex = "0x3333333333333333333333333333333333333333333333333333333333333333"
)

// testEIP712ChainID and testEIP712Salt are the EIP-712 domain params used across
// tests. They are the zero value so that every existing validationOptions{} and
// NewService (which default these to zero) stays consistent with signatures and
// pinned digests. The domain-wrap code path is still fully exercised; realistic
// wallet compatibility (chainId + salt) is proven separately by the real-wallet
// signature vector test.
var (
testEIP712ChainID uint64
testEIP712Salt [32]byte
)

var (
testDepositorPrivateKey = mustDeterministicTestPrivateKey(testDepositorPrivateKeyHex)
testSignerPrivateKey = mustDeterministicTestPrivateKey(testSignerPrivateKeyHex)
Expand Down Expand Up @@ -368,7 +379,7 @@ func mustArtifactApprovalSignature(
privateKey *btcec.PrivateKey,
payload ArtifactApprovalPayload,
) string {
digest, err := artifactApprovalDigest(payload)
digest, err := artifactApprovalDigest(payload, testEIP712ChainID, testEIP712Salt)
if err != nil {
panic(err)
}
Expand Down Expand Up @@ -595,7 +606,7 @@ func validSignerApproval(
panic("artifact approvals are required")
}

digest, err := artifactApprovalDigest(artifactApprovals.Payload)
digest, err := artifactApprovalDigest(artifactApprovals.Payload, testEIP712ChainID, testEIP712Salt)
if err != nil {
panic(err)
}
Expand Down Expand Up @@ -3220,6 +3231,46 @@ func TestNewServiceRejectsDuplicateDepositorTrustRootScope(t *testing.T) {
}
}

func TestNewServiceRejectsMixedEthAddressPresenceForSameReserve(t *testing.T) {
handle := newMemoryHandle()

// Same (route, reserve) but different networks: one pins an ethAddress, the
// other does not. Allowing this mix would let a request steer verification
// to the secp-only sibling scope via its network value, downgrading an
// operator's intended wallet-signed enforcement.
withEth := testDepositorTrustRoot(TemplateSelfV1)
withEth.Network = "regtest"
withEth.EthAddress = "0x000000000000000000000000000000000000dEaD"

withoutEth := testDepositorTrustRoot(TemplateSelfV1)
withoutEth.Network = "testnet"

_, err := NewService(
handle,
&scriptedEngine{},
WithDepositorTrustRoots([]DepositorTrustRoot{withEth, withoutEth}),
)
if err == nil || !strings.Contains(
err.Error(),
"must set ethAddress on all network entries or on none",
) {
t.Fatalf("expected mixed ethAddress presence error, got %v", err)
}
}

func TestNormalizeEthAddressRejectsZeroAddress(t *testing.T) {
_, err := normalizeEthAddress(
"depositorTrustRoots[0].ethAddress",
"0x0000000000000000000000000000000000000000",
)
if err == nil || !strings.Contains(
err.Error(),
"must not be the zero ETH address",
) {
t.Fatalf("expected zero ETH address rejection, got %v", err)
}
}

func TestNewServiceRejectsInvalidCustodianTrustRootPublicKey(t *testing.T) {
handle := newMemoryHandle()

Expand Down Expand Up @@ -3363,17 +3414,17 @@ func TestRequestDigestRejectsArtifactApprovalsWithoutMigrationTransactionPlan(t
}
}

func TestArtifactApprovalDigestMatchesPhase1Contract(t *testing.T) {
func TestArtifactApprovalDigestMatchesV2Contract(t *testing.T) {
expectedDigests := map[TemplateID]string{
TemplateQcV1: "0x4e1c72624e85c41d8d8a050d75704dc881ec6cd2dcfe1d240052887feef87ad8",
TemplateSelfV1: "0x960d7082d6eac550d7647d8fbeb90781e6cbd001b4d433e6635aa447dd937e79",
TemplateQcV1: "0xc8246daca36f0116377210140056949b23c37b3de3a5c48ec8d125405a9f05fe",
TemplateSelfV1: "0x063735af147351025209ba54a606b38598d67e60848d463bbd4bcfcbdf3506c7",
}

for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} {
t.Run(string(route), func(t *testing.T) {
request := canonicalArtifactApprovalRequest(route)

digest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload)
digest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload, testEIP712ChainID, testEIP712Salt)
if err != nil {
t.Fatal(err)
}
Expand All @@ -3391,7 +3442,7 @@ func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) {
t.Run(vectorKey, func(t *testing.T) {
request, expectedApprovalDigest, expectedDigest := loadApprovalContractVector(t, vectorKey)

digestBytes, err := artifactApprovalDigest(request.ArtifactApprovals.Payload)
digestBytes, err := artifactApprovalDigest(request.ArtifactApprovals.Payload, testEIP712ChainID, testEIP712Salt)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -3992,7 +4043,7 @@ func TestServiceAcceptsSignerApprovalCertificateWithEndBlockAboveUint32Range(t *
request := structuredSignerApprovalRequest(TemplateSelfV1)
request.SignerApproval.EndBlock = &endBlock

normalized, err := normalizeSignerApprovalCertificate(request)
normalized, err := normalizeSignerApprovalCertificate(request, testEIP712ChainID, testEIP712Salt)
if err != nil {
t.Fatalf("expected EndBlock above math.MaxUint32 to normalize, got %v", err)
}
Expand Down Expand Up @@ -4740,7 +4791,7 @@ func TestNormalizeSignerApprovalCertificateRejectsV1CertificateVersion(t *testin
request := structuredSignerApprovalRequest(TemplateSelfV1)
request.SignerApproval.CertificateVersion = 1

_, err := normalizeSignerApprovalCertificate(request)
_, err := normalizeSignerApprovalCertificate(request, testEIP712ChainID, testEIP712Salt)
if err == nil || !strings.Contains(err.Error(), "request.signerApproval.certificateVersion must equal 2") {
t.Fatalf("expected v1 certificate version rejection, got %v", err)
}
Expand Down
Loading
Loading