diff --git a/contract/delegate_handler.go b/contract/delegate_handler.go new file mode 100644 index 00000000..c72fc713 --- /dev/null +++ b/contract/delegate_handler.go @@ -0,0 +1,373 @@ +package contract + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/arkade-os/arkd/pkg/client-lib/identity" + "github.com/arkade-os/go-sdk/contract/handlers" + "github.com/arkade-os/go-sdk/types" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" +) + +const ( + ParamKeyID = "keyId" + ParamOwnerKey = "ownerKey" + ParamSignerKey = "signerKey" + ParamDelegateKey = "delegateKey" + ParamTapscripts = "tapscripts" + ParamExitDelay = "exitDelay" +) + +// DelegateConfig holds the server parameters needed to derive a delegate contract. +type DelegateConfig struct { + SignerKey *btcec.PublicKey + Network arklib.Network + ExitDelay arklib.RelativeLocktime +} + +// PathContext describes how the caller intends to spend a delegate contract. +type PathContext struct { + Collaborative bool + UseDelegatePath bool +} + +// PathSelection describes a chosen tapscript spending path. +type PathSelection struct { + Leaf txscript.TapLeaf + Sequence *uint32 + Locktime *uint32 +} + +// DelegateHandler derives offchain Ark VTXO contracts that add a 3-of-3 delegate +// spending path alongside the standard forfeit and unilateral-exit paths. +// +// Tapscript leaf order: +// +// [0] exit: CSVMultisigClosure{[owner], UnilateralExitDelay} +// [1] forfeit: MultisigClosure{[owner, server]} +// [2] delegate: MultisigClosure{[owner, delegate, server]} +type DelegateHandler struct{} + +var _ handlers.Handler = (*DelegateHandler)(nil) + +// DeriveContract derives the delegate VTXO contract. Only an offchain contract is +// produced; no boarding or onchain facets are derived for delegate contracts. +// +// Closure ordering is load-bearing: the arkd client-lib uses forfeitClosures[0] +// to build forfeit transactions, and ForfeitClosures() matches all *MultisigClosure +// values regardless of key count. The 2-of-2 forfeit MUST remain at index [1] so +// it is picked ahead of the 3-of-3 delegate at index [2]. Do not reorder. +func (h *DelegateHandler) DeriveContract( + _ context.Context, + key identity.KeyRef, + cfg DelegateConfig, + delegateKey *btcec.PublicKey, +) (*types.Contract, error) { + if delegateKey == nil { + return nil, fmt.Errorf("delegate key must not be nil") + } + if key.PubKey == nil { + return nil, fmt.Errorf("owner key must not be nil") + } + if cfg.SignerKey == nil { + return nil, fmt.Errorf("signer key must not be nil") + } + if delegateKey.IsEqual(key.PubKey) { + return nil, fmt.Errorf("delegate key must differ from owner key") + } + if delegateKey.IsEqual(cfg.SignerKey) { + return nil, fmt.Errorf("delegate key must differ from signer key") + } + + vtxoScript := &script.TapscriptsVtxoScript{ + Closures: []script.Closure{ + // [0] exit: owner unilateral after CSV timelock + &script.CSVMultisigClosure{ + MultisigClosure: script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey}, + }, + Locktime: cfg.ExitDelay, + }, + // [1] forfeit: owner + server cooperative, no delay + &script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey, cfg.SignerKey}, + }, + // [2] delegate: owner + delegate + server 3-of-3, no delay + &script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey, delegateKey, cfg.SignerKey}, + }, + }, + } + + vtxoTapKey, _, err := vtxoScript.TapTree() + if err != nil { + return nil, fmt.Errorf("delegate tap tree: %w", err) + } + + arkAddr := &arklib.Address{ + HRP: cfg.Network.Addr, + Signer: cfg.SignerKey, + VtxoTapKey: vtxoTapKey, + } + encodedArkAddr, err := arkAddr.EncodeV0() + if err != nil { + return nil, fmt.Errorf("encode ark address: %w", err) + } + + tapscripts, err := vtxoScript.Encode() + if err != nil { + return nil, fmt.Errorf("encode tapscripts: %w", err) + } + + pkScript, err := txscript.PayToTaprootScript(vtxoTapKey) + if err != nil { + return nil, fmt.Errorf("pkScript: %w", err) + } + + return &types.Contract{ + Type: types.ContractTypeDelegate, + Params: map[string]string{ + ParamKeyID: key.Id, + ParamOwnerKey: hex.EncodeToString(schnorr.SerializePubKey(key.PubKey)), + ParamSignerKey: hex.EncodeToString(schnorr.SerializePubKey(cfg.SignerKey)), + ParamDelegateKey: hex.EncodeToString(delegateKey.SerializeCompressed()), + ParamTapscripts: serializeTapscripts(tapscripts), + ParamExitDelay: serializeDelay(cfg.ExitDelay), + }, + Script: hex.EncodeToString(pkScript), + Address: encodedArkAddr, + State: types.ContractStateActive, + CreatedAt: time.Now(), + }, nil +} + +// NewContract returns an error: delegate contracts require a delegate key. +// Use Manager.NewDelegate instead. +func (h *DelegateHandler) NewContract( + _ context.Context, + _ identity.KeyRef, +) (*types.Contract, error) { + return nil, fmt.Errorf("delegate contracts require a delegate key: use Manager.NewDelegate") +} + +func (h *DelegateHandler) GetKeyRef(c types.Contract) (*identity.KeyRef, error) { + if len(c.Params) == 0 { + return nil, fmt.Errorf("contract %s has no parameters", c.Script) + } + keyId, ok := c.Params[ParamKeyID] + if !ok { + return nil, fmt.Errorf("contract %s is missing key ID", c.Script) + } + ownerKeyHex, ok := c.Params[ParamOwnerKey] + if !ok { + return nil, fmt.Errorf("contract %s is missing owner key", c.Script) + } + buf, err := hex.DecodeString(ownerKeyHex) + if err != nil { + return nil, fmt.Errorf("contract %s has invalid owner key format", c.Script) + } + ownerKey, err := schnorr.ParsePubKey(buf) + if err != nil { + return nil, fmt.Errorf("contract %s has invalid owner key: %w", c.Script, err) + } + return &identity.KeyRef{Id: keyId, PubKey: ownerKey}, nil +} + +func (h *DelegateHandler) GetKeyRefs(c types.Contract) (map[string]string, error) { + keyRef, err := h.GetKeyRef(c) + if err != nil { + return nil, err + } + return map[string]string{c.Script: keyRef.Id}, nil +} + +func (h *DelegateHandler) GetSignerKey(c types.Contract) (*btcec.PublicKey, error) { + if len(c.Params) == 0 { + return nil, fmt.Errorf("contract %s has no parameters", c.Script) + } + signerKeyHex, ok := c.Params[ParamSignerKey] + if !ok { + return nil, fmt.Errorf("contract %s is missing signer key", c.Script) + } + buf, err := hex.DecodeString(signerKeyHex) + if err != nil { + return nil, fmt.Errorf("contract %s has invalid signer key format", c.Script) + } + signerKey, err := schnorr.ParsePubKey(buf) + if err != nil { + return nil, fmt.Errorf("contract %s has invalid signer key: %w", c.Script, err) + } + return signerKey, nil +} + +func (h *DelegateHandler) GetExitDelay(c types.Contract) (*arklib.RelativeLocktime, error) { + if len(c.Params) == 0 { + return nil, fmt.Errorf("contract %s has no parameters", c.Script) + } + s, ok := c.Params[ParamExitDelay] + if !ok { + return nil, fmt.Errorf("contract %s is missing exit delay", c.Script) + } + lt, err := parseDelay(s) + if err != nil { + return nil, fmt.Errorf("contract %s has invalid exit delay: %w", c.Script, err) + } + return <, nil +} + +func (h *DelegateHandler) GetTapscripts(c types.Contract) ([]string, error) { + if len(c.Params) == 0 { + return nil, fmt.Errorf("contract %s has no parameters", c.Script) + } + s, ok := c.Params[ParamTapscripts] + if !ok { + return nil, fmt.Errorf("contract %s is missing tapscripts", c.Script) + } + return parseTapscripts(s) +} + +// SelectPath returns the forfeit leaf (2-of-2) for a standard collaborative spend, +// the delegate leaf (3-of-3) when pctx.UseDelegatePath is set, or the exit leaf +// for a unilateral spend. +func (h *DelegateHandler) SelectPath( + _ context.Context, c types.Contract, pctx PathContext, +) (*PathSelection, error) { + tapscripts, err := h.GetTapscripts(c) + if err != nil { + return nil, err + } + if len(tapscripts) < 3 { + return nil, fmt.Errorf("delegate contract requires 3 tapscripts, got %d", len(tapscripts)) + } + if pctx.Collaborative { + if pctx.UseDelegatePath { + return tapLeafSelection(tapscripts[2], nil, nil) + } + return tapLeafSelection(tapscripts[1], nil, nil) + } + delay, err := h.GetExitDelay(c) + if err != nil { + return nil, fmt.Errorf("exit delay: %w", err) + } + seq, err := arklib.BIP68Sequence(*delay) + if err != nil { + return nil, fmt.Errorf("BIP68 sequence: %w", err) + } + s := uint32(seq) + return tapLeafSelection(tapscripts[0], &s, nil) +} + +// GetSpendablePaths returns exit always; forfeit and delegate when collaborative. +func (h *DelegateHandler) GetSpendablePaths( + _ context.Context, c types.Contract, pctx PathContext, +) ([]PathSelection, error) { + tapscripts, err := h.GetTapscripts(c) + if err != nil { + return nil, err + } + if len(tapscripts) < 3 { + return nil, fmt.Errorf("delegate contract requires 3 tapscripts, got %d", len(tapscripts)) + } + delay, err := h.GetExitDelay(c) + if err != nil { + return nil, fmt.Errorf("exit delay: %w", err) + } + seq, err := arklib.BIP68Sequence(*delay) + if err != nil { + return nil, fmt.Errorf("BIP68 sequence: %w", err) + } + s := uint32(seq) + + exit, err := tapLeafSelection(tapscripts[0], &s, nil) + if err != nil { + return nil, err + } + paths := []PathSelection{*exit} + + if pctx.Collaborative { + forfeit, err := tapLeafSelection(tapscripts[1], nil, nil) + if err != nil { + return nil, err + } + delegate, err := tapLeafSelection(tapscripts[2], nil, nil) + if err != nil { + return nil, err + } + paths = append(paths, *forfeit, *delegate) + } + return paths, nil +} + +func tapLeafSelection( + tapscriptHex string, + sequence *uint32, + locktime *uint32, +) (*PathSelection, error) { + sc, err := hex.DecodeString(tapscriptHex) + if err != nil { + return nil, fmt.Errorf("invalid tapscript hex: %w", err) + } + return &PathSelection{ + Leaf: txscript.NewBaseTapLeaf(sc), + Sequence: sequence, + Locktime: locktime, + }, nil +} + +func serializeTapscripts(ts []string) string { + b, _ := json.Marshal(ts) + return string(b) +} + +func parseTapscripts(s string) ([]string, error) { + var ts []string + if err := json.Unmarshal([]byte(s), &ts); err != nil { + return nil, fmt.Errorf("invalid tapscripts format: %w", err) + } + return ts, nil +} + +func serializeDelay(lt arklib.RelativeLocktime) string { + if lt.Type == arklib.LocktimeTypeBlock { + return fmt.Sprintf("block:%d", lt.Value) + } + return fmt.Sprintf("second:%d", lt.Value) +} + +func parseDelay(s string) (arklib.RelativeLocktime, error) { + if after, ok := strings.CutPrefix(s, "block:"); ok { + var v uint32 + if _, err := fmt.Sscanf(after, "%d", &v); err != nil { + return arklib.RelativeLocktime{}, fmt.Errorf( + "invalid block delay value in %q: %w", + s, + err, + ) + } + return arklib.RelativeLocktime{Type: arklib.LocktimeTypeBlock, Value: v}, nil + } + if after, ok := strings.CutPrefix(s, "second:"); ok { + var v uint32 + if _, err := fmt.Sscanf(after, "%d", &v); err != nil { + return arklib.RelativeLocktime{}, fmt.Errorf( + "invalid second delay value in %q: %w", + s, + err, + ) + } + return arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: v}, nil + } + return arklib.RelativeLocktime{}, fmt.Errorf( + "invalid delay format %q: expected \"block:N\" or \"second:N\"", + s, + ) +} diff --git a/contract/delegate_handler_test.go b/contract/delegate_handler_test.go new file mode 100644 index 00000000..8131a105 --- /dev/null +++ b/contract/delegate_handler_test.go @@ -0,0 +1,428 @@ +package contract_test + +import ( + "context" + "encoding/hex" + "testing" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/arkade-os/arkd/pkg/client-lib/identity" + "github.com/arkade-os/go-sdk/contract" + sdktypes "github.com/arkade-os/go-sdk/types" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" + "github.com/stretchr/testify/require" +) + +func testKey(t *testing.T) identity.KeyRef { + t.Helper() + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + return identity.KeyRef{Id: "test-key", PubKey: priv.PubKey()} +} + +func testCfg(t *testing.T) contract.DelegateConfig { + t.Helper() + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + return contract.DelegateConfig{ + SignerKey: priv.PubKey(), + Network: arklib.BitcoinRegTest, + ExitDelay: arklib.RelativeLocktime{ + Type: arklib.LocktimeTypeBlock, + Value: 144, + }, + } +} + +func testDelegateKey(t *testing.T) *btcec.PrivateKey { + t.Helper() + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + return priv +} + +func TestDelegateHandler_DeriveContract(t *testing.T) { + t.Parallel() + + h := &contract.DelegateHandler{} + key := testKey(t) + cfg := testCfg(t) + delegatePriv := testDelegateKey(t) + ctx := context.Background() + + c, err := h.DeriveContract(ctx, key, cfg, delegatePriv.PubKey()) + require.NoError(t, err) + require.NotNil(t, c) + + require.Equal(t, sdktypes.ContractTypeDelegate, c.Type) + require.Equal(t, "test-key", c.Params[contract.ParamKeyID]) + require.Equal(t, + hex.EncodeToString(delegatePriv.PubKey().SerializeCompressed()), + c.Params[contract.ParamDelegateKey], + ) + + // Exactly 3 tapscripts: exit, forfeit, delegate. + tapscripts, err := h.GetTapscripts(*c) + require.NoError(t, err) + require.Len(t, tapscripts, 3) + + // Signer key and exit delay are stored in params. + require.Equal(t, + hex.EncodeToString(schnorr.SerializePubKey(cfg.SignerKey)), + c.Params[contract.ParamSignerKey], + ) + require.Equal(t, "block:144", c.Params[contract.ParamExitDelay]) + + // Address matches manual derivation. + vtxoScript := &script.TapscriptsVtxoScript{ + Closures: []script.Closure{ + &script.CSVMultisigClosure{ + MultisigClosure: script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey}, + }, + Locktime: cfg.ExitDelay, + }, + &script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey, cfg.SignerKey}, + }, + &script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey, delegatePriv.PubKey(), cfg.SignerKey}, + }, + }, + } + refTapKey, _, err := vtxoScript.TapTree() + require.NoError(t, err) + + refAddr := &arklib.Address{ + HRP: cfg.Network.Addr, + Signer: cfg.SignerKey, + VtxoTapKey: refTapKey, + } + refEncoded, err := refAddr.EncodeV0() + require.NoError(t, err) + require.Equal(t, refEncoded, c.Address) + + refPkScript, err := txscript.PayToTaprootScript(refTapKey) + require.NoError(t, err) + require.Equal(t, hex.EncodeToString(refPkScript), c.Script) +} + +func TestDelegateHandler_DeterministicOutput(t *testing.T) { + t.Parallel() + + h := &contract.DelegateHandler{} + key := testKey(t) + cfg := testCfg(t) + delegatePriv := testDelegateKey(t) + ctx := context.Background() + + c1, err := h.DeriveContract(ctx, key, cfg, delegatePriv.PubKey()) + require.NoError(t, err) + c2, err := h.DeriveContract(ctx, key, cfg, delegatePriv.PubKey()) + require.NoError(t, err) + + require.Equal(t, c1.Script, c2.Script) + require.Equal(t, c1.Address, c2.Address) + + ts1, err := h.GetTapscripts(*c1) + require.NoError(t, err) + ts2, err := h.GetTapscripts(*c2) + require.NoError(t, err) + require.Equal(t, ts1, ts2) +} + +func TestDelegateHandler_DifferentDelegateDifferentScript(t *testing.T) { + t.Parallel() + + h := &contract.DelegateHandler{} + key := testKey(t) + cfg := testCfg(t) + ctx := context.Background() + + c1, err := h.DeriveContract(ctx, key, cfg, testDelegateKey(t).PubKey()) + require.NoError(t, err) + c2, err := h.DeriveContract(ctx, key, cfg, testDelegateKey(t).PubKey()) + require.NoError(t, err) + + require.NotEqual(t, c1.Script, c2.Script) + require.NotEqual(t, c1.Address, c2.Address) +} + +func TestDelegateHandler_DeriveContract_Validation(t *testing.T) { + t.Parallel() + + h := &contract.DelegateHandler{} + key := testKey(t) + cfg := testCfg(t) + ctx := context.Background() + + t.Run("nil delegate key returns error", func(t *testing.T) { + t.Parallel() + _, err := h.DeriveContract(ctx, key, cfg, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "nil") + }) + + t.Run("delegate key same as owner returns error", func(t *testing.T) { + t.Parallel() + _, err := h.DeriveContract(ctx, key, cfg, key.PubKey) + require.Error(t, err) + require.Contains(t, err.Error(), "owner") + }) + + t.Run("delegate key same as signer returns error", func(t *testing.T) { + t.Parallel() + _, err := h.DeriveContract(ctx, key, cfg, cfg.SignerKey) + require.Error(t, err) + require.Contains(t, err.Error(), "signer") + }) +} + +func TestDelegateHandler_SelectPath(t *testing.T) { + t.Parallel() + + h := &contract.DelegateHandler{} + key := testKey(t) + cfg := testCfg(t) + delegatePriv := testDelegateKey(t) + + c, err := h.DeriveContract(context.Background(), key, cfg, delegatePriv.PubKey()) + require.NoError(t, err) + + t.Run("collaborative returns forfeit leaf", func(t *testing.T) { + t.Parallel() + sel, err := h.SelectPath( + context.Background(), + *c, + contract.PathContext{Collaborative: true}, + ) + require.NoError(t, err) + require.NotNil(t, sel) + require.Nil(t, sel.Sequence) + + // Forfeit leaf is tapscripts[1]. + ts, err := h.GetTapscripts(*c) + require.NoError(t, err) + refScript, _ := hex.DecodeString(ts[1]) + require.Equal(t, txscript.NewBaseTapLeaf(refScript), sel.Leaf) + }) + + t.Run("collaborative with UseDelegatePath returns delegate leaf", func(t *testing.T) { + t.Parallel() + sel, err := h.SelectPath(context.Background(), *c, contract.PathContext{ + Collaborative: true, + UseDelegatePath: true, + }) + require.NoError(t, err) + require.NotNil(t, sel) + require.Nil(t, sel.Sequence) + + // Delegate leaf is tapscripts[2]. + ts, err := h.GetTapscripts(*c) + require.NoError(t, err) + refScript, _ := hex.DecodeString(ts[2]) + require.Equal(t, txscript.NewBaseTapLeaf(refScript), sel.Leaf) + }) + + t.Run("unilateral returns exit leaf with BIP68 sequence", func(t *testing.T) { + t.Parallel() + sel, err := h.SelectPath( + context.Background(), + *c, + contract.PathContext{Collaborative: false}, + ) + require.NoError(t, err) + require.NotNil(t, sel) + require.NotNil(t, sel.Sequence) + require.Nil(t, sel.Locktime) + + // Exit leaf is tapscripts[0]. + ts, err := h.GetTapscripts(*c) + require.NoError(t, err) + refScript, _ := hex.DecodeString(ts[0]) + require.Equal(t, txscript.NewBaseTapLeaf(refScript), sel.Leaf) + }) + + t.Run("UseDelegatePath without Collaborative returns exit leaf", func(t *testing.T) { + t.Parallel() + sel, err := h.SelectPath(context.Background(), *c, contract.PathContext{ + Collaborative: false, + UseDelegatePath: true, + }) + require.NoError(t, err) + require.NotNil(t, sel.Sequence) // still an exit path + + ts, err := h.GetTapscripts(*c) + require.NoError(t, err) + refScript, _ := hex.DecodeString(ts[0]) + require.Equal(t, txscript.NewBaseTapLeaf(refScript), sel.Leaf) + }) + + t.Run("unilateral with missing exit delay returns error", func(t *testing.T) { + t.Parallel() + bad := sdktypes.Contract{ + Params: map[string]string{ + contract.ParamTapscripts: `["aabb","ccdd","eeff"]`, + // no ParamExitDelay + }, + } + _, err := h.SelectPath( + context.Background(), + bad, + contract.PathContext{Collaborative: false}, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "exit delay") + }) + + t.Run("fewer than 3 tapscripts returns error", func(t *testing.T) { + t.Parallel() + bad := sdktypes.Contract{ + Params: map[string]string{ + contract.ParamTapscripts: `["aabb","ccdd"]`, + contract.ParamExitDelay: "block:144", + }, + } + _, err := h.SelectPath(context.Background(), bad, contract.PathContext{}) + require.Error(t, err) + require.Contains(t, err.Error(), "3 tapscripts") + }) +} + +func TestDelegateHandler_GetSpendablePaths(t *testing.T) { + t.Parallel() + + h := &contract.DelegateHandler{} + delegatePriv := testDelegateKey(t) + c, err := h.DeriveContract(context.Background(), testKey(t), testCfg(t), delegatePriv.PubKey()) + require.NoError(t, err) + + t.Run("unilateral returns only exit path", func(t *testing.T) { + t.Parallel() + paths, err := h.GetSpendablePaths( + context.Background(), + *c, + contract.PathContext{Collaborative: false}, + ) + require.NoError(t, err) + require.Len(t, paths, 1) + require.NotNil(t, paths[0].Sequence) + }) + + t.Run("collaborative returns exit, forfeit, delegate paths", func(t *testing.T) { + t.Parallel() + paths, err := h.GetSpendablePaths( + context.Background(), + *c, + contract.PathContext{Collaborative: true}, + ) + require.NoError(t, err) + require.Len(t, paths, 3) + + tapscripts, err := h.GetTapscripts(*c) + require.NoError(t, err) + + exitScript, _ := hex.DecodeString(tapscripts[0]) + require.Equal(t, txscript.NewBaseTapLeaf(exitScript), paths[0].Leaf) + require.NotNil(t, paths[0].Sequence) + + forfeitScript, _ := hex.DecodeString(tapscripts[1]) + require.Equal(t, txscript.NewBaseTapLeaf(forfeitScript), paths[1].Leaf) + require.Nil(t, paths[1].Sequence) + + delegateScript, _ := hex.DecodeString(tapscripts[2]) + require.Equal(t, txscript.NewBaseTapLeaf(delegateScript), paths[2].Leaf) + require.Nil(t, paths[2].Sequence) + }) + + t.Run("missing exit delay returns error", func(t *testing.T) { + t.Parallel() + bad := sdktypes.Contract{ + Params: map[string]string{ + contract.ParamTapscripts: `["aabb","ccdd","eeff"]`, + // no ParamExitDelay + }, + } + _, err := h.GetSpendablePaths(context.Background(), bad, contract.PathContext{}) + require.Error(t, err) + require.Contains(t, err.Error(), "exit delay") + }) + + t.Run("fewer than 3 tapscripts returns error", func(t *testing.T) { + t.Parallel() + bad := sdktypes.Contract{ + Params: map[string]string{ + contract.ParamTapscripts: `["aabb","ccdd"]`, + contract.ParamExitDelay: "block:144", + }, + } + _, err := h.GetSpendablePaths(context.Background(), bad, contract.PathContext{}) + require.Error(t, err) + require.Contains(t, err.Error(), "3 tapscripts") + }) +} + +func TestDelegateHandler_ClosureOrdering(t *testing.T) { + t.Parallel() + + key := testKey(t) + cfg := testCfg(t) + delegatePriv := testDelegateKey(t) + + // Mirror the closure layout used by DeriveContract so that ForfeitClosures() + // behaviour is verified against the exact structure the handler produces. + // ForfeitClosures() matches every *MultisigClosure in Closures order; downstream + // code that builds forfeit transactions uses forfeitClosures[0]. If the 3-of-3 + // delegate closure were placed before the 2-of-2 forfeit closure the server could + // not produce a valid forfeit signature without the delegate key. + vtxoScript := &script.TapscriptsVtxoScript{ + Closures: []script.Closure{ + // [0] exit — CSVMultisigClosure; excluded from ForfeitClosures + &script.CSVMultisigClosure{ + MultisigClosure: script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey}, + }, + Locktime: cfg.ExitDelay, + }, + // [1] forfeit — 2-of-2; must be forfeitClosures[0] + &script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey, cfg.SignerKey}, + }, + // [2] delegate — 3-of-3; must be forfeitClosures[1] + &script.MultisigClosure{ + PubKeys: []*btcec.PublicKey{key.PubKey, delegatePriv.PubKey(), cfg.SignerKey}, + }, + }, + } + + forfeits := vtxoScript.ForfeitClosures() + require.Len(t, forfeits, 2) + + forfeit, ok := forfeits[0].(*script.MultisigClosure) + require.True(t, ok, "forfeitClosures[0] must be *MultisigClosure") + require.Len(t, forfeit.PubKeys, 2, "forfeitClosures[0] must be 2-of-2 (owner+server)") + + delegate, ok := forfeits[1].(*script.MultisigClosure) + require.True(t, ok, "forfeitClosures[1] must be *MultisigClosure") + require.Len(t, delegate.PubKeys, 3, "forfeitClosures[1] must be 3-of-3 (owner+delegate+server)") + + // Verify DeriveContract produces a contract whose tap key matches this layout, + // proving the handler uses this exact closure order. + c, err := (&contract.DelegateHandler{}).DeriveContract( + context.Background(), key, cfg, delegatePriv.PubKey(), + ) + require.NoError(t, err) + + refTapKey, _, err := vtxoScript.TapTree() + require.NoError(t, err) + refAddr := &arklib.Address{ + HRP: cfg.Network.Addr, Signer: cfg.SignerKey, VtxoTapKey: refTapKey, + } + refEncoded, err := refAddr.EncodeV0() + require.NoError(t, err) + require.Equal(t, refEncoded, c.Address, + "DeriveContract address must match the expected closure ordering") +} diff --git a/contract/manager.go b/contract/manager.go index 70e9e34a..882b9dca 100644 --- a/contract/manager.go +++ b/contract/manager.go @@ -2,6 +2,7 @@ package contract import ( "context" + "encoding/hex" "fmt" "maps" "slices" @@ -9,10 +10,12 @@ import ( "time" arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/client-lib/client" "github.com/arkade-os/arkd/pkg/client-lib/indexer" "github.com/arkade-os/go-sdk/contract/handlers" defaultHandler "github.com/arkade-os/go-sdk/contract/handlers/default" "github.com/arkade-os/go-sdk/types" + "github.com/btcsuite/btcd/btcec/v2" log "github.com/sirupsen/logrus" ) @@ -21,6 +24,7 @@ const logPrefix = "contract manager:" type contractManager struct { store types.ContractStore keyProvider keyProvider + client client.Client indexer offchainDataProvider explorer onchainDataProvider network arklib.Network @@ -51,6 +55,7 @@ func NewManager(args Args) (Manager, error) { return &contractManager{ store: args.Store, keyProvider: args.KeyProvider, + client: cachedClient, indexer: args.Indexer, explorer: args.Explorer, handlers: handlers, @@ -193,6 +198,10 @@ func (m *contractManager) GetContracts( func (m *contractManager) GetHandler( _ context.Context, contract types.Contract, ) (handlers.Handler, error) { + if contract.Type == types.ContractTypeDelegate { + return &DelegateHandler{}, nil + } + m.mu.RLock() defer m.mu.RUnlock() @@ -203,6 +212,117 @@ func (m *contractManager) GetHandler( return handler, nil } +func (m *contractManager) NewDelegate( + ctx context.Context, delegateKey *btcec.PublicKey, +) (*types.Contract, error) { + if delegateKey == nil { + return nil, fmt.Errorf("delegate key must not be nil") + } + + info, err := m.client.GetInfo(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get server info: %w", err) + } + + signerKeyBytes, err := hex.DecodeString(info.SignerPubKey) + if err != nil { + return nil, fmt.Errorf("failed to decode signer pubkey: invalid format") + } + signerKey, err := btcec.ParsePubKey(signerKeyBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse signer pubkey: %w", err) + } + + delay := info.UnilateralExitDelay + exitDelay := arklib.RelativeLocktime{ + Type: arklib.LocktimeTypeSecond, + Value: uint32(delay), + } + if delay < 512 { + exitDelay = arklib.RelativeLocktime{ + Type: arklib.LocktimeTypeBlock, + Value: uint32(delay), + } + } + + cfg := DelegateConfig{ + SignerKey: signerKey, + Network: m.network, + ExitDelay: exitDelay, + } + + contract, isNew, err := m.newDelegateLocked(ctx, delegateKey, cfg) + if err != nil { + return nil, err + } + if isNew { + m.emit(*contract) + } + return contract, nil +} + +func (m *contractManager) newDelegateLocked( + ctx context.Context, delegateKey *btcec.PublicKey, cfg DelegateConfig, +) (*types.Contract, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + delegateKeyHex := hex.EncodeToString(delegateKey.SerializeCompressed()) + existing, err := m.findDelegateContractByKey(ctx, delegateKeyHex) + if err != nil { + return nil, false, err + } + if existing != nil { + return existing, false, nil + } + + latestContract, err := m.store.GetLatestContract(ctx, types.ContractTypeDelegate) + if err != nil { + return nil, false, err + } + + var keyId string + if latestContract != nil { + dh := &DelegateHandler{} + keyRef, err := dh.GetKeyRef(*latestContract) + if err != nil { + return nil, false, fmt.Errorf( + "failed to get key ref for latest delegate contract: %w", + err, + ) + } + keyId = keyRef.Id + } + + nextKeyId, err := m.keyProvider.NextKeyId(ctx, keyId) + if err != nil { + return nil, false, fmt.Errorf("failed to compute next key index: %w", err) + } + + keyRef, err := m.keyProvider.GetKey(ctx, nextKeyId) + if err != nil { + return nil, false, fmt.Errorf("failed to derive key for contract: %w", err) + } + + dh := &DelegateHandler{} + contract, err := dh.DeriveContract(ctx, *keyRef, cfg, delegateKey) + if err != nil { + return nil, false, err + } + + keyIndex, err := m.keyProvider.GetKeyIndex(ctx, keyRef.Id) + if err != nil { + return nil, false, fmt.Errorf("failed to get key index: %w", err) + } + + if err := m.store.AddContract(ctx, *contract, keyIndex); err != nil { + return nil, false, fmt.Errorf("failed to store delegate contract: %w", err) + } + + log.Debugf("%s added new delegate contract %s", logPrefix, contract.Script) + return contract, true, nil +} + func (m *contractManager) Clean(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() @@ -395,6 +515,21 @@ func (m *contractManager) findUsedContracts( return used, nil } +func (m *contractManager) findDelegateContractByKey( + ctx context.Context, delegateKeyHex string, +) (*types.Contract, error) { + contracts, err := m.store.GetContractsByType(ctx, types.ContractTypeDelegate) + if err != nil { + return nil, fmt.Errorf("failed to query delegate contracts: %w", err) + } + for i := range contracts { + if contracts[i].Params[ParamDelegateKey] == delegateKeyHex { + return &contracts[i], nil + } + } + return nil, nil +} + func (m *contractManager) findUsedBoardingContracts( ctx context.Context, contracts []types.Contract, ) (map[string]struct{}, error) { diff --git a/contract/types.go b/contract/types.go index 2e20f7a7..dcffa242 100644 --- a/contract/types.go +++ b/contract/types.go @@ -11,8 +11,20 @@ import ( "github.com/arkade-os/arkd/pkg/client-lib/indexer" "github.com/arkade-os/go-sdk/contract/handlers" "github.com/arkade-os/go-sdk/types" + "github.com/btcsuite/btcd/btcec/v2" ) +// DelegateCreator is implemented by Manager implementations that support +// creating delegate contracts. Kept separate from Manager so that existing +// Manager implementors are not forced to add NewDelegate. +type DelegateCreator interface { + // NewDelegate creates and stores a new delegate contract for the given + // delegate public key. The owner key is derived from the key provider. + // Returns an error if delegateKey is nil or matches the owner or signer key. + // If a contract for this delegate key already exists it is returned as-is. + NewDelegate(ctx context.Context, delegateKey *btcec.PublicKey) (*types.Contract, error) +} + // Manager manages the lifecycle of contracts derived from wallet keys. type Manager interface { // GetSupportedContractTypes returns the list of contract types supported by the manager. diff --git a/contract/watcher_test.go b/contract/watcher_test.go index fe47606f..f68b4ff7 100644 --- a/contract/watcher_test.go +++ b/contract/watcher_test.go @@ -215,6 +215,11 @@ func (m *watcherMockManager) GetHandler( ) (handlers.Handler, error) { return &mockContractHandler{}, nil } +func (m *watcherMockManager) NewDelegate( + _ context.Context, _ *btcec.PublicKey, +) (*types.Contract, error) { + return nil, nil +} func (m *watcherMockManager) Clean(_ context.Context) error { return nil } func (m *watcherMockManager) Close() {} func (m *watcherMockManager) OnContractEvent(cb func(types.Contract)) func() { diff --git a/spendable_vtxos_test.go b/spendable_vtxos_test.go index 7e0d757d..0846a7bb 100644 --- a/spendable_vtxos_test.go +++ b/spendable_vtxos_test.go @@ -134,6 +134,11 @@ func (m *fixContractManager) GetHandler( ) (handlers.Handler, error) { return &fixHandler{}, nil } +func (m *fixContractManager) NewDelegate( + _ context.Context, _ *btcec.PublicKey, +) (*sdktypes.Contract, error) { + return nil, nil +} func (m *fixContractManager) Clean(_ context.Context) error { return nil } func (m *fixContractManager) Close() {} func (m *fixContractManager) OnContractEvent(_ func(sdktypes.Contract)) func() { diff --git a/types/types.go b/types/types.go index 4acab07d..c728ae7c 100644 --- a/types/types.go +++ b/types/types.go @@ -111,6 +111,7 @@ type ContractType string const ( ContractTypeDefault ContractType = "default" ContractTypeBoarding ContractType = "boarding" + ContractTypeDelegate ContractType = "delegate" ) type Contract struct {