diff --git a/go.mod b/go.mod index 85ae16811..cada41cd5 100644 --- a/go.mod +++ b/go.mod @@ -18,12 +18,15 @@ replace github.com/arkade-os/arkd/pkg/errors => ./pkg/errors replace github.com/arkade-os/arkd/pkg/client-lib => ./pkg/client-lib +replace github.com/arkade-os/arkd/pkg/client-wallet => ./pkg/client-wallet + require ( github.com/ThreeDotsLabs/watermill-sql/v3 v3.1.0 github.com/arkade-os/arkd/api-spec v0.0.0-00010101000000-000000000000 github.com/arkade-os/arkd/pkg/ark-lib v0.8.1-0.20260210151408-67ee91bbd639 github.com/arkade-os/arkd/pkg/arkd-wallet v0.0.0-00010101000000-000000000000 github.com/arkade-os/arkd/pkg/client-lib v0.0.0-00010101000000-000000000000 + github.com/arkade-os/arkd/pkg/client-wallet v0.0.0-00010101000000-000000000000 github.com/arkade-os/arkd/pkg/errors v0.0.0-00010101000000-000000000000 github.com/arkade-os/arkd/pkg/kvdb v0.0.0-20250606113434-241d3e1ec7cb github.com/arkade-os/arkd/pkg/macaroons v0.0.0-00010101000000-000000000000 @@ -138,6 +141,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/miekg/dns v1.1.61 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/user v0.4.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/internal/test/e2e/delegate_utils_test.go b/internal/test/e2e/delegate_utils_test.go index e98b9f619..c63a2d9ed 100644 --- a/internal/test/e2e/delegate_utils_test.go +++ b/internal/test/e2e/delegate_utils_test.go @@ -13,8 +13,7 @@ import ( arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/arkade-os/arkd/pkg/ark-lib/script" "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/identity" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/txscript" @@ -25,8 +24,8 @@ type delegateBatchEventsHandler struct { intentId string signerSession tree.SignerSession partialForfeitTx string - delegatorIdentity identity.Identity - client client.Client + delegatorIdentity clientlib.Identity + client clientlib.Client forfeitPubKey *btcec.PublicKey batchExpiry arklib.RelativeLocktime @@ -34,13 +33,13 @@ type delegateBatchEventsHandler struct { } func (h *delegateBatchEventsHandler) OnStreamStarted( - ctx context.Context, event client.StreamStartedEvent, + ctx context.Context, event clientlib.StreamStartedEvent, ) error { return nil } func (h *delegateBatchEventsHandler) OnBatchStarted( - ctx context.Context, event client.BatchStartedEvent, + ctx context.Context, event clientlib.BatchStartedEvent, ) (bool, time.Duration, error) { buf := sha256.Sum256([]byte(h.intentId)) hashedIntentId := hex.EncodeToString(buf[:]) @@ -60,13 +59,13 @@ func (h *delegateBatchEventsHandler) OnBatchStarted( } func (h *delegateBatchEventsHandler) OnBatchFinalized( - ctx context.Context, event client.BatchFinalizedEvent, + ctx context.Context, event clientlib.BatchFinalizedEvent, ) error { return nil } func (h *delegateBatchEventsHandler) OnBatchFailed( - ctx context.Context, event client.BatchFailedEvent, + ctx context.Context, event clientlib.BatchFailedEvent, ) error { if event.Id == h.cacheBatchId { return fmt.Errorf("batch failed: %s", event.Reason) @@ -75,19 +74,19 @@ func (h *delegateBatchEventsHandler) OnBatchFailed( } func (h *delegateBatchEventsHandler) OnTreeTxEvent( - ctx context.Context, event client.TreeTxEvent, + ctx context.Context, event clientlib.TreeTxEvent, ) error { return nil } func (h *delegateBatchEventsHandler) OnTreeSignatureEvent( - ctx context.Context, event client.TreeSignatureEvent, + ctx context.Context, event clientlib.TreeSignatureEvent, ) error { return nil } func (h *delegateBatchEventsHandler) OnTreeSigningStarted( - ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree, + ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree, ) (bool, error) { myPubkey := h.signerSession.GetPublicKey() if !slices.Contains(event.CosignersPubkeys, myPubkey) { @@ -137,15 +136,13 @@ func (h *delegateBatchEventsHandler) OnTreeSigningStarted( } func (h *delegateBatchEventsHandler) OnTreeNonces( - ctx context.Context, - event client.TreeNoncesEvent, + ctx context.Context, event clientlib.TreeNoncesEvent, ) (bool, error) { return false, nil } func (h *delegateBatchEventsHandler) OnTreeNoncesAggregated( - ctx context.Context, - event client.TreeNoncesAggregatedEvent, + ctx context.Context, event clientlib.TreeNoncesAggregatedEvent, ) (bool, error) { h.signerSession.SetAggregatedNonces(event.Nonces) @@ -155,19 +152,14 @@ func (h *delegateBatchEventsHandler) OnTreeNoncesAggregated( } err = h.client.SubmitTreeSignatures( - ctx, - event.Id, - h.signerSession.GetPublicKey(), - sigs, + ctx, event.Id, h.signerSession.GetPublicKey(), sigs, ) return err == nil, err } func (h *delegateBatchEventsHandler) OnBatchFinalization( ctx context.Context, - event client.BatchFinalizationEvent, - vtxoTree *tree.TxTree, - connectorTree *tree.TxTree, + event clientlib.BatchFinalizationEvent, vtxoTree *tree.TxTree, connectorTree *tree.TxTree, ) ([]string, error) { forfeitPtx, err := psbt.NewFromRawBytes(strings.NewReader(h.partialForfeitTx), true) if err != nil { @@ -220,20 +212,19 @@ func (h *delegateBatchEventsHandler) OnBatchFinalization( } type customBatchEventsHandler struct { - onStreamStarted func(ctx context.Context, event client.StreamStartedEvent) error - onBatchStarted func(ctx context.Context, event client.BatchStartedEvent) (bool, time.Duration, error) - onBatchFinalization func(ctx context.Context, event client.BatchFinalizationEvent, vtxoTree *tree.TxTree, connectorTree *tree.TxTree) ([]string, error) - onBatchFinalized func(ctx context.Context, event client.BatchFinalizedEvent) error - onBatchFailed func(ctx context.Context, event client.BatchFailedEvent) error - onTreeTxEvent func(ctx context.Context, event client.TreeTxEvent) error - onTreeSignatureEvent func(ctx context.Context, event client.TreeSignatureEvent) error - onTreeSigningStarted func(ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) - onTreeNoncesAggregated func(ctx context.Context, event client.TreeNoncesAggregatedEvent) (bool, error) + onStreamStarted func(ctx context.Context, event clientlib.StreamStartedEvent) error + onBatchStarted func(ctx context.Context, event clientlib.BatchStartedEvent) (bool, time.Duration, error) + onBatchFinalization func(ctx context.Context, event clientlib.BatchFinalizationEvent, vtxoTree *tree.TxTree, connectorTree *tree.TxTree) ([]string, error) + onBatchFinalized func(ctx context.Context, event clientlib.BatchFinalizedEvent) error + onBatchFailed func(ctx context.Context, event clientlib.BatchFailedEvent) error + onTreeTxEvent func(ctx context.Context, event clientlib.TreeTxEvent) error + onTreeSignatureEvent func(ctx context.Context, event clientlib.TreeSignatureEvent) error + onTreeSigningStarted func(ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) + onTreeNoncesAggregated func(ctx context.Context, event clientlib.TreeNoncesAggregatedEvent) (bool, error) } func (h *customBatchEventsHandler) OnStreamStarted( - ctx context.Context, - event client.StreamStartedEvent, + ctx context.Context, event clientlib.StreamStartedEvent, ) error { if h.onStreamStarted != nil { return h.onStreamStarted(ctx, event) @@ -242,8 +233,7 @@ func (h *customBatchEventsHandler) OnStreamStarted( } func (h *customBatchEventsHandler) OnBatchStarted( - ctx context.Context, - event client.BatchStartedEvent, + ctx context.Context, event clientlib.BatchStartedEvent, ) (bool, time.Duration, error) { if h.onBatchStarted != nil { return h.onBatchStarted(ctx, event) @@ -253,9 +243,7 @@ func (h *customBatchEventsHandler) OnBatchStarted( func (h *customBatchEventsHandler) OnBatchFinalization( ctx context.Context, - event client.BatchFinalizationEvent, - vtxoTree *tree.TxTree, - connectorTree *tree.TxTree, + event clientlib.BatchFinalizationEvent, vtxoTree *tree.TxTree, connectorTree *tree.TxTree, ) ([]string, error) { if h.onBatchFinalization != nil { return h.onBatchFinalization(ctx, event, vtxoTree, connectorTree) @@ -264,8 +252,7 @@ func (h *customBatchEventsHandler) OnBatchFinalization( } func (h *customBatchEventsHandler) OnBatchFinalized( - ctx context.Context, - event client.BatchFinalizedEvent, + ctx context.Context, event clientlib.BatchFinalizedEvent, ) error { if h.onBatchFinalized != nil { return h.onBatchFinalized(ctx, event) @@ -274,8 +261,7 @@ func (h *customBatchEventsHandler) OnBatchFinalized( } func (h *customBatchEventsHandler) OnBatchFailed( - ctx context.Context, - event client.BatchFailedEvent, + ctx context.Context, event clientlib.BatchFailedEvent, ) error { if h.onBatchFailed != nil { return h.onBatchFailed(ctx, event) @@ -284,8 +270,7 @@ func (h *customBatchEventsHandler) OnBatchFailed( } func (h *customBatchEventsHandler) OnTreeTxEvent( - ctx context.Context, - event client.TreeTxEvent, + ctx context.Context, event clientlib.TreeTxEvent, ) error { if h.onTreeTxEvent != nil { return h.onTreeTxEvent(ctx, event) @@ -294,8 +279,7 @@ func (h *customBatchEventsHandler) OnTreeTxEvent( } func (h *customBatchEventsHandler) OnTreeSignatureEvent( - ctx context.Context, - event client.TreeSignatureEvent, + ctx context.Context, event clientlib.TreeSignatureEvent, ) error { if h.onTreeSignatureEvent != nil { return h.onTreeSignatureEvent(ctx, event) @@ -304,9 +288,7 @@ func (h *customBatchEventsHandler) OnTreeSignatureEvent( } func (h *customBatchEventsHandler) OnTreeSigningStarted( - ctx context.Context, - event client.TreeSigningStartedEvent, - vtxoTree *tree.TxTree, + ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree, ) (bool, error) { if h.onTreeSigningStarted != nil { return h.onTreeSigningStarted(ctx, event, vtxoTree) @@ -315,8 +297,7 @@ func (h *customBatchEventsHandler) OnTreeSigningStarted( } func (h *customBatchEventsHandler) OnTreeNoncesAggregated( - ctx context.Context, - event client.TreeNoncesAggregatedEvent, + ctx context.Context, event clientlib.TreeNoncesAggregatedEvent, ) (bool, error) { if h.onTreeNoncesAggregated != nil { return h.onTreeNoncesAggregated(ctx, event) @@ -325,8 +306,7 @@ func (h *customBatchEventsHandler) OnTreeNoncesAggregated( } func (h *customBatchEventsHandler) OnTreeNonces( - ctx context.Context, - event client.TreeNoncesEvent, + ctx context.Context, event clientlib.TreeNoncesEvent, ) (bool, error) { return false, nil } diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index 9a00f38d7..7a5a04918 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -23,13 +23,12 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/script" "github.com/arkade-os/arkd/pkg/ark-lib/tree" "github.com/arkade-os/arkd/pkg/ark-lib/txutils" - wallet "github.com/arkade-os/arkd/pkg/client-lib" - "github.com/arkade-os/arkd/pkg/client-lib/client" - grpcclient "github.com/arkade-os/arkd/pkg/client-lib/client/grpc" - mempoolexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" - "github.com/arkade-os/arkd/pkg/client-lib/redemption" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsession "github.com/arkade-os/arkd/pkg/client-lib/batch-session" + grpcclient "github.com/arkade-os/arkd/pkg/client-lib/client" + mempoolexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer" + "github.com/arkade-os/arkd/pkg/client-lib/unroll" + wallet "github.com/arkade-os/arkd/pkg/client-wallet" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" @@ -111,7 +110,7 @@ func TestBatchSession(t *testing.T) { wg.Done() }() - var aliceBatchRes, bobBatchRes *wallet.BatchTxRes + var aliceBatchRes, bobBatchRes *batchsession.BatchTxRes var aliceBatchErr, bobBatchErr error go func() { aliceBatchRes, aliceBatchErr = alice.Settle(ctx) @@ -285,7 +284,7 @@ func TestUnilateralExit(t *testing.T) { time.Sleep(15 * time.Second) - txid, err := alice.CompleteUnroll(t.Context(), "") + txid, err := alice.CompleteUnroll(t.Context()) require.NoError(t, err) require.NotEmpty(t, txid) }) @@ -317,7 +316,7 @@ func TestUnilateralExit(t *testing.T) { _, incomingErr = bob.NotifyIncomingFunds(t.Context(), bobOffchainAddr.Address) wg.Done() }() - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ To: bobOffchainAddr.Address, Amount: 21000, }}) @@ -372,7 +371,7 @@ func TestUnilateralExit(t *testing.T) { time.Sleep(15 * time.Second) - txid, err := alice.CompleteUnroll(t.Context(), "") + txid, err := bob.CompleteUnroll(t.Context()) require.NoError(t, err) require.NotEmpty(t, txid) }) @@ -431,7 +430,7 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { _, spentVtxos, err := alice.ListVtxos(ctx) require.NoError(t, err) - var unrolledVtxo types.Vtxo + var unrolledVtxo clientlib.Vtxo for _, v := range spentVtxos { if v.Unrolled && !v.Spent { unrolledVtxo = v @@ -440,12 +439,17 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { } require.NotZero(t, unrolledVtxo.Amount) - // Receive returns *types.Address which carries Tapscripts — use them + signingClosure, err := offchainAddr.CollaborativeClosure() + require.NoError(t, err) + require.NotNil(t, signingClosure) + + // Receive returns *clientlib.Address which carries Tapscripts — use them // to present the unrolled VTXO as a boarding input. - boardingUtxo := types.Utxo{ - Outpoint: unrolledVtxo.Outpoint, - Amount: unrolledVtxo.Amount, - Tapscripts: offchainAddr.Tapscripts, + boardingUtxo := clientlib.Utxo{ + Outpoint: unrolledVtxo.Outpoint, + Amount: unrolledVtxo.Amount, + Tapscripts: offchainAddr.Tapscripts, + SigningClosure: signingClosure, } // Rejoin the batch — unrolled VTXO should be accepted as a boarding input @@ -457,9 +461,17 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { wg.Done() }() - res, err := alice.Settle(ctx, - wallet.WithFunds([]types.Utxo{boardingUtxo}, nil), - ) + cfgData, err := alice.GetConfigData(ctx) + require.NoError(t, err) + require.NotNil(t, cfgData) + + res, err := batchsession.Settle(ctx, batchsession.SettleArgs{ + Client: alice.Client(), + ServerInfo: cfgData.ClientInfo(), + SignTx: alice.SignTransaction, + BoardingUtxos: []clientlib.Utxo{boardingUtxo}, + ReceiverAddr: offchainAddr.Address, + }) require.NoError(t, err) require.NotEmpty(t, res.CommitmentTxid) @@ -480,7 +492,7 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { time.Sleep(5 * time.Second) - _, err = alice.CompleteUnroll(ctx, "") + _, err = alice.CompleteUnroll(ctx) require.ErrorContains(t, err, "no mature funds available") }) @@ -558,7 +570,7 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { _, spentVtxos, err := alice.ListVtxos(ctx) require.NoError(t, err) - var unrolledAssetVtxo types.Vtxo + var unrolledAssetVtxo clientlib.Vtxo for _, v := range spentVtxos { if v.Unrolled && !v.Spent && len(v.Assets) > 0 { unrolledAssetVtxo = v @@ -567,14 +579,19 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { } require.NotZero(t, unrolledAssetVtxo.Amount) + signingClosure, err := offchainAddr.CollaborativeClosure() + require.NoError(t, err) + require.NotNil(t, signingClosure) + // Same flow as the without-asset case: present the unrolled // asset VTXO as a boarding input. The Assets field carries the // asset metadata so the SDK builds the intent's asset packet. - boardingUtxo := types.Utxo{ - Outpoint: unrolledAssetVtxo.Outpoint, - Amount: unrolledAssetVtxo.Amount, - Tapscripts: offchainAddr.Tapscripts, - Assets: unrolledAssetVtxo.Assets, + boardingUtxo := clientlib.Utxo{ + Outpoint: unrolledAssetVtxo.Outpoint, + Amount: unrolledAssetVtxo.Amount, + Tapscripts: offchainAddr.Tapscripts, + SigningClosure: signingClosure, + Assets: unrolledAssetVtxo.Assets, } wg := &sync.WaitGroup{} @@ -585,10 +602,19 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { wg.Done() }() - res, err := alice.Settle(ctx, - wallet.WithFunds([]types.Utxo{boardingUtxo}, nil), - ) + cfgData, err := alice.GetConfigData(ctx) require.NoError(t, err) + require.NotNil(t, cfgData) + + res, err := batchsession.Settle(ctx, batchsession.SettleArgs{ + Client: alice.Client(), + ServerInfo: cfgData.ClientInfo(), + SignTx: alice.SignTransaction, + BoardingUtxos: []clientlib.Utxo{boardingUtxo}, + ReceiverAddr: offchainAddr.Address, + }) + require.NoError(t, err) + require.NotNil(t, res) require.NotEmpty(t, res.CommitmentTxid) wg.Wait() @@ -643,7 +669,7 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { _, spentVtxos, err := alice.ListVtxos(ctx) require.NoError(t, err) - var unrolledVtxo types.Vtxo + var unrolledVtxo clientlib.Vtxo for _, v := range spentVtxos { if v.Unrolled && !v.Spent { unrolledVtxo = v @@ -658,15 +684,28 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { time.Sleep(25 * time.Second) require.NoError(t, generateBlocks(1)) - boardingUtxo := types.Utxo{ - Outpoint: unrolledVtxo.Outpoint, - Amount: unrolledVtxo.Amount, - Tapscripts: offchainAddr.Tapscripts, + signingClosure, err := offchainAddr.CollaborativeClosure() + require.NoError(t, err) + require.NotNil(t, signingClosure) + + boardingUtxo := clientlib.Utxo{ + Outpoint: unrolledVtxo.Outpoint, + Amount: unrolledVtxo.Amount, + Tapscripts: offchainAddr.Tapscripts, + SigningClosure: signingClosure, } - _, err = alice.Settle(ctx, - wallet.WithFunds([]types.Utxo{boardingUtxo}, nil), - ) + cfgData, err := alice.GetConfigData(ctx) + require.NoError(t, err) + require.NotNil(t, cfgData) + + _, err = batchsession.Settle(ctx, batchsession.SettleArgs{ + Client: alice.Client(), + ServerInfo: cfgData.ClientInfo(), + SignTx: alice.SignTransaction, + BoardingUtxos: []clientlib.Utxo{boardingUtxo}, + ReceiverAddr: offchainAddr.Address, + }) require.Error(t, err) require.ErrorContains(t, err, "expired") }) @@ -707,7 +746,7 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { _, spentVtxos, err := alice.ListVtxos(ctx) require.NoError(t, err) - var unrolledVtxo types.Vtxo + var unrolledVtxo clientlib.Vtxo for _, v := range spentVtxos { if v.Unrolled && !v.Spent { unrolledVtxo = v @@ -717,18 +756,31 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { require.NotZero(t, unrolledVtxo.Amount) require.Empty(t, unrolledVtxo.Assets) - boardingUtxo := types.Utxo{ - Outpoint: unrolledVtxo.Outpoint, - Amount: unrolledVtxo.Amount, - Tapscripts: offchainAddr.Tapscripts, - Assets: []types.Asset{ + signingClosure, err := offchainAddr.CollaborativeClosure() + require.NoError(t, err) + require.NotNil(t, signingClosure) + + boardingUtxo := clientlib.Utxo{ + Outpoint: unrolledVtxo.Outpoint, + Amount: unrolledVtxo.Amount, + Tapscripts: offchainAddr.Tapscripts, + SigningClosure: signingClosure, + Assets: []clientlib.Asset{ {AssetId: fakeAssetId, Amount: 1}, }, } - _, err = alice.Settle(ctx, - wallet.WithFunds([]types.Utxo{boardingUtxo}, nil), - ) + cfgData, err := alice.GetConfigData(ctx) + require.NoError(t, err) + require.NotNil(t, cfgData) + + _, err = batchsession.Settle(ctx, batchsession.SettleArgs{ + Client: alice.Client(), + ServerInfo: cfgData.ClientInfo(), + SignTx: alice.SignTransaction, + BoardingUtxos: []clientlib.Utxo{boardingUtxo}, + ReceiverAddr: offchainAddr.Address, + }) require.Error(t, err) require.ErrorContains(t, err, "does not contain any assets") }) @@ -832,7 +884,8 @@ func TestCollaborativeExit(t *testing.T) { t.Run("invalid", func(t *testing.T) { // In this test Alice funds her boarding address without settling and tries to join a batch - // funding Bob's onchain address. The server should reject the request + // funding Bob's onchain address. The operation should be rejected client-side and not + // even reach server t.Run("with boarding inputs", func(t *testing.T) { alice := setupClientWallet(t) bob := setupClientWallet(t) @@ -845,13 +898,12 @@ func TestCollaborativeExit(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, aliceBoardingAddr) - faucetOffchain(t, alice, 0.00021) faucetOnchain(t, aliceBoardingAddr.Address, 0.001) time.Sleep(5 * time.Second) _, err = alice.CollaborativeExit(t.Context(), bobOnchainAddr, 21000) require.Error(t, err) - require.ErrorContains(t, err, "include onchain inputs and outputs") + require.ErrorContains(t, err, "missing funds") }) }) } @@ -871,13 +923,13 @@ func TestOffchainTx(t *testing.T) { wg := &sync.WaitGroup{} wg.Add(1) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error go func() { incomingFunds, incomingErr = bob.NotifyIncomingFunds(ctx, bobAddress.Address) wg.Done() }() - _, err = alice.SendOffChain(ctx, []types.Receiver{{To: bobAddress.Address, Amount: 1000}}) + _, err = alice.SendOffChain(ctx, []clientlib.Receiver{{To: bobAddress.Address, Amount: 1000}}) require.NoError(t, err) wg.Wait() @@ -894,7 +946,7 @@ func TestOffchainTx(t *testing.T) { incomingFunds, incomingErr = bob.NotifyIncomingFunds(ctx, bobAddress.Address) wg.Done() }() - _, err = alice.SendOffChain(ctx, []types.Receiver{{To: bobAddress.Address, Amount: 10000}}) + _, err = alice.SendOffChain(ctx, []clientlib.Receiver{{To: bobAddress.Address, Amount: 10000}}) require.NoError(t, err) wg.Wait() @@ -911,7 +963,7 @@ func TestOffchainTx(t *testing.T) { incomingFunds, incomingErr = bob.NotifyIncomingFunds(ctx, bobAddress.Address) wg.Done() }() - _, err = alice.SendOffChain(ctx, []types.Receiver{{ + _, err = alice.SendOffChain(ctx, []clientlib.Receiver{{ To: bobAddress.Address, Amount: 10000, }}) @@ -931,7 +983,7 @@ func TestOffchainTx(t *testing.T) { incomingFunds, incomingErr = bob.NotifyIncomingFunds(ctx, bobAddress.Address) wg.Done() }() - _, err = alice.SendOffChain(ctx, []types.Receiver{{To: bobAddress.Address, Amount: 10000}}) + _, err = alice.SendOffChain(ctx, []clientlib.Receiver{{To: bobAddress.Address, Amount: 10000}}) require.NoError(t, err) wg.Wait() @@ -978,7 +1030,7 @@ func TestOffchainTx(t *testing.T) { _, incomingErr = alice.NotifyIncomingFunds(t.Context(), aliceOffchainAddr.Address) wg.Done() }() - _, err := alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err := alice.SendOffChain(t.Context(), []clientlib.Receiver{{ To: bobOffchainAddr.Address, Amount: amount, }}) @@ -994,7 +1046,7 @@ func TestOffchainTx(t *testing.T) { _, incomingErr = alice.NotifyIncomingFunds(t.Context(), aliceOffchainAddr.Address) wg.Done() }() - _, err = bob.SendOffChain(t.Context(), []types.Receiver{{ + _, err = bob.SendOffChain(t.Context(), []clientlib.Receiver{{ To: aliceOffchainAddr.Address, Amount: numInputs * amount, }}) @@ -1030,7 +1082,7 @@ func TestOffchainTx(t *testing.T) { wg.Done() }() - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ To: bobOffchainAddr.Address, Amount: 100, }}) @@ -1041,7 +1093,7 @@ func TestOffchainTx(t *testing.T) { time.Sleep(time.Second) // Bob can't spend his VTXO - _, err = bob.SendOffChain(t.Context(), []types.Receiver{{ + _, err = bob.SendOffChain(t.Context(), []clientlib.Receiver{{ To: aliceOffchainAddr.Address, Amount: 100, }}) @@ -1058,7 +1110,7 @@ func TestOffchainTx(t *testing.T) { wg.Done() }() - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ To: bobOffchainAddr.Address, Amount: 250, }}) @@ -1348,9 +1400,9 @@ func TestOffchainTx(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "duplicated") - time.Sleep(time.Second) + time.Sleep(5 * time.Second) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error wg := &sync.WaitGroup{} wg.Go(func() { @@ -1482,7 +1534,7 @@ func TestOffchainTx(t *testing.T) { // Ensure the vtxo is pending and swept scriptStr := hex.EncodeToString(pkscript) resp, err := alice.Indexer().GetVtxos( - ctx, indexer.WithScripts([]string{scriptStr}), indexer.WithPendingOnly(), + ctx, clientlib.WithScripts([]string{scriptStr}), clientlib.WithPendingOnly(), ) require.NoError(t, err) require.NotNil(t, resp) @@ -1490,7 +1542,7 @@ func TestOffchainTx(t *testing.T) { require.True(t, resp.Vtxos[0].Spent) require.True(t, resp.Vtxos[0].Swept) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error wg := &sync.WaitGroup{} wg.Go(func() { @@ -1618,7 +1670,7 @@ func TestOffchainTx(t *testing.T) { // Ensure the vtxo is pending but not swept yet scriptStr := hex.EncodeToString(pkscript) resp, err := alice.Indexer().GetVtxos( - ctx, indexer.WithScripts([]string{scriptStr}), indexer.WithPendingOnly(), + ctx, clientlib.WithScripts([]string{scriptStr}), clientlib.WithPendingOnly(), ) require.NoError(t, err) require.NotNil(t, resp) @@ -1626,7 +1678,7 @@ func TestOffchainTx(t *testing.T) { require.True(t, resp.Vtxos[0].Spent) require.False(t, resp.Vtxos[0].Swept) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error wg := &sync.WaitGroup{} wg.Go(func() { @@ -1658,8 +1710,7 @@ func TestOffchainTx(t *testing.T) { alice := setupClientWallet(t) aliceClient := alice.Client() - fund := faucetOffchain(t, alice, 0.00021) - vtxo := types.VtxoWithTapTree{Vtxo: fund} + vtxo := faucetOffchain(t, alice, 0.00021) _, offchainAddresses, _, _, err := alice.GetAddresses(ctx) require.NoError(t, err) @@ -1669,11 +1720,13 @@ func TestOffchainTx(t *testing.T) { serverParams, err := aliceClient.GetInfo(ctx) require.NoError(t, err) - vtxoScript, err := script.ParseVtxoScript(offchainAddress.Tapscripts) + vtxoScript, err := offchainAddress.RawScript() require.NoError(t, err) - forfeitClosures := vtxoScript.ForfeitClosures() - require.Len(t, forfeitClosures, 1) - closure := forfeitClosures[0] + require.NotNil(t, vtxoScript) + + closure, err := offchainAddress.CollaborativeClosure() + require.NoError(t, err) + require.NotNil(t, closure) scriptBytes, err := closure.Script() require.NoError(t, err) @@ -1753,7 +1806,7 @@ func TestOffchainTx(t *testing.T) { // Unroll the input vtxo onchain. Submit has already marked it spent server-side, // so we pass the vtxo explicitly to bypass the SDK's spendable filter. - unrollRes, err := alice.Unroll(ctx, wallet.WithVtxos([]types.VtxoWithTapTree{vtxo})) + unrollRes, err := alice.Unroll(ctx, wallet.WithVtxos([]clientlib.Vtxo{vtxo})) require.NoError(t, err) require.NotEmpty(t, unrollRes) @@ -2039,7 +2092,7 @@ func TestDelegateRefresh(t *testing.T) { require.NotNil(t, bob) require.NotNil(t, bobPubKey) - bobTreeSigner, err := bob.NewVtxoTreeSigner(ctx) + bobTreeSigner, err := tree.NewVtxoTreeSigner() require.NoError(t, err) require.NotNil(t, bobTreeSigner) @@ -2097,13 +2150,13 @@ func TestDelegateRefresh(t *testing.T) { // Move all her funds to the new address including the delegate script path. wg := &sync.WaitGroup{} wg.Add(1) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error go func() { incomingFunds, incomingErr = alice.NotifyIncomingFunds(ctx, arkAddressStr) wg.Done() }() - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ To: arkAddressStr, Amount: 21000, }}) @@ -2270,29 +2323,37 @@ func TestDelegateRefresh(t *testing.T) { intentId, err := aliceClient.RegisterIntent(ctx, encodedIntentProof, encodedIntentMessage) require.NoError(t, err) - topics := wallet.GetEventStreamTopics( - []types.Outpoint{aliceVtxo.Outpoint}, []tree.SignerSession{bobTreeSigner}, - ) - stream, close, err := aliceClient.GetEventStream(ctx, topics) - require.NoError(t, err) - t.Cleanup(close) - - commitmentTxid, commitmentTx, batchExpiry, forfeitTxs, vtxoTree, err := wallet.JoinBatchSession( - ctx, stream, &delegateBatchEventsHandler{ - signerSession: bobTreeSigner, - partialForfeitTx: signedPartialForfeitTx, - delegatorIdentity: bob, - client: aliceClient, - forfeitPubKey: aliceConfig.ForfeitPubKey, - intentId: intentId, + handler := &delegateBatchEventsHandler{ + signerSession: bobTreeSigner, + partialForfeitTx: signedPartialForfeitTx, + delegatorIdentity: bob, + client: aliceClient, + forfeitPubKey: aliceConfig.ForfeitPubKey, + intentId: intentId, + } + + args := batchsession.JoinBatchArgs{ + BaseArgs: batchsession.BaseArgs{ + Vtxos: []clientlib.Vtxo{aliceVtxo}, + Outputs: []clientlib.Receiver{{ + To: arkAddressStr, + Amount: aliceVtxo.Amount, + }}, + SignTx: alice.SignTransaction, }, + TreeSigners: []tree.SignerSession{bobTreeSigner}, + IntentId: intentId, + Client: aliceClient, + ServerInfo: aliceConfig.ClientInfo(), + } + res, err := batchsession.JoinBatch( + ctx, args, batchsession.WithHandler(handler), ) require.NoError(t, err) - require.NotEmpty(t, commitmentTxid) - require.NotEmpty(t, commitmentTx) - require.NotEmpty(t, forfeitTxs) - require.NotNil(t, vtxoTree) - require.Greater(t, int64(batchExpiry), int64(0)) + require.NotNil(t, res) + require.NotEmpty(t, res.CommitmentTxid) + require.NotEmpty(t, res.CommitmentTx) + require.NotEmpty(t, res.ForfeitTxs) } // TestSendToCLTVMultisigClosure shows how to send to an ark address that includes a closure locked @@ -2377,7 +2438,7 @@ func TestSendToCLTVMultisigClosure(t *testing.T) { wg.Done() }() res, err := alice.SendOffChain( - ctx, []types.Receiver{{To: bobAddrStr, Amount: sendAmount}}, + ctx, []clientlib.Receiver{{To: bobAddrStr, Amount: sendAmount}}, ) require.NoError(t, err) require.NotNil(t, res) @@ -2591,7 +2652,7 @@ func TestSendToConditionMultisigClosure(t *testing.T) { }() res, err := alice.SendOffChain( - ctx, []types.Receiver{{To: bobAddrStr, Amount: sendAmount}}, + ctx, []clientlib.Receiver{{To: bobAddrStr, Amount: sendAmount}}, ) require.NoError(t, err) require.NotNil(t, res) @@ -2776,7 +2837,7 @@ func TestReactToFraud(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, spentVtxos) - var vtxo types.Vtxo + var vtxo clientlib.Vtxo for _, v := range spentVtxos { if !v.Preconfirmed && v.CommitmentTxids[0] == res.CommitmentTxid { vtxo = v @@ -2790,7 +2851,7 @@ func TestReactToFraud(t *testing.T) { ) require.NoError(t, err) - branch, err := redemption.NewRedeemBranch(ctx, explorer, indexerClient, vtxo) + branch, err := unroll.NewRedeemBranch(ctx, explorer, indexerClient, vtxo) require.NoError(t, err) // The tree we want to unroll contains only one tx, therefore there's only one tx to broadcast. @@ -2878,7 +2939,7 @@ func TestReactToFraud(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, spentVtxos) - var vtxo types.Vtxo + var vtxo clientlib.Vtxo for _, v := range spentVtxos { if !v.Preconfirmed && v.CommitmentTxids[0] == res.CommitmentTxid { vtxo = v @@ -2892,7 +2953,7 @@ func TestReactToFraud(t *testing.T) { ) require.NoError(t, err) - branch, err := redemption.NewRedeemBranch(ctx, explorer, indexerClient, vtxo) + branch, err := unroll.NewRedeemBranch(ctx, explorer, indexerClient, vtxo) require.NoError(t, err) // The tree we want to unroll contains only one tx, therefore there's only one tx to broadcast. @@ -2975,7 +3036,7 @@ func TestReactToFraud(t *testing.T) { }() _, err = client.SendOffChain( - ctx, []types.Receiver{{To: offchainAddress.Address, Amount: 1000}}, + ctx, []clientlib.Receiver{{To: offchainAddress.Address, Amount: 1000}}, ) require.NoError(t, err) @@ -2999,7 +3060,7 @@ func TestReactToFraud(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, spentVtxos) - var vtxo types.Vtxo + var vtxo clientlib.Vtxo for _, v := range spentVtxos { if !v.Preconfirmed && v.CommitmentTxids[0] == res.CommitmentTxid { vtxo = v @@ -3014,7 +3075,7 @@ func TestReactToFraud(t *testing.T) { ) require.NoError(t, err) - branch, err := redemption.NewRedeemBranch(ctx, explorer, indexerClient, vtxo) + branch, err := unroll.NewRedeemBranch(ctx, explorer, indexerClient, vtxo) require.NoError(t, err) for parentTx, err := branch.NextRedeemTx(); err == nil; parentTx, err = branch.NextRedeemTx() { @@ -3138,7 +3199,7 @@ func TestReactToFraud(t *testing.T) { }() res, err := alice.SendOffChain( - ctx, []types.Receiver{{To: bobAddrStr, Amount: sendAmount}}, + ctx, []clientlib.Receiver{{To: bobAddrStr, Amount: sendAmount}}, ) require.NoError(t, err) require.NotNil(t, res) @@ -3284,7 +3345,7 @@ func TestReactToFraud(t *testing.T) { } require.True(t, found) - branch, err := redemption.NewRedeemBranch(ctx, explorer, indexerClient, initialTreeVtxo) + branch, err := unroll.NewRedeemBranch(ctx, explorer, indexerClient, initialTreeVtxo) require.NoError(t, err) for parentTx, err := branch.NextRedeemTx(); err == nil; parentTx, err = branch.NextRedeemTx() { @@ -3302,8 +3363,8 @@ func TestReactToFraud(t *testing.T) { require.NotEmpty(t, bobScript) resp, err := indexerClient.GetVtxos(ctx, - indexer.WithScripts([]string{hex.EncodeToString(bobScript)}), - indexer.WithSpentOnly(), + clientlib.WithScripts([]string{hex.EncodeToString(bobScript)}), + clientlib.WithSpentOnly(), ) require.NoError(t, err) require.NotNil(t, resp) @@ -3334,7 +3395,7 @@ func TestSweep(t *testing.T) { wg := &sync.WaitGroup{} wg.Add(1) - var incominFunds []types.Vtxo + var incominFunds []clientlib.Vtxo var incomingErr error go func() { incominFunds, incomingErr = alice.NotifyIncomingFunds(ctx, offchainAddr.Address) @@ -3359,8 +3420,8 @@ func TestSweep(t *testing.T) { require.NoError(t, err) t.Cleanup(closeStream) - var sweepEvent *client.TxNotification - sweepCh := make(chan *client.TxNotification, 1) + var sweepEvent *clientlib.TxNotification + sweepCh := make(chan *clientlib.TxNotification, 1) go func() { for ev := range txStream { if ev.SweepTx == nil { @@ -3407,7 +3468,7 @@ func TestSweep(t *testing.T) { }() // Test fund recovery - res, err := alice.Settle(ctx, wallet.WithRecoverableVtxos()) + res, err := alice.Settle(ctx, batchsession.WithRecoverableVtxos()) require.NoError(t, err) require.NotNil(t, res) require.NotEmpty(t, res.CommitmentTxid) @@ -3442,7 +3503,7 @@ func TestSweep(t *testing.T) { wg := &sync.WaitGroup{} wg.Add(1) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error go func() { incomingFunds, incomingErr = alice.NotifyIncomingFunds(ctx, offchainAddr.Address) @@ -3471,7 +3532,7 @@ func TestSweep(t *testing.T) { // self-send the VTXO to create a checkpoint output res1, err := alice.SendOffChain( ctx, - []types.Receiver{{To: offchainAddr.Address, Amount: boardedVtxo.Amount}}, + []clientlib.Receiver{{To: offchainAddr.Address, Amount: boardedVtxo.Amount}}, ) require.NoError(t, err) require.NotNil(t, res1) @@ -3485,7 +3546,7 @@ func TestSweep(t *testing.T) { // self-send again to create a second checkpoint output res2, err := alice.SendOffChain( ctx, - []types.Receiver{{To: offchainAddr.Address, Amount: boardedVtxo.Amount}}, + []clientlib.Receiver{{To: offchainAddr.Address, Amount: boardedVtxo.Amount}}, ) require.NoError(t, err) require.NotNil(t, res2) @@ -3497,7 +3558,7 @@ func TestSweep(t *testing.T) { mempoolexplorer.WithTracker(false)) require.NoError(t, err) - branch, err := redemption.NewRedeemBranch(ctx, explorer, alice.Indexer(), boardedVtxo) + branch, err := unroll.NewRedeemBranch(ctx, explorer, alice.Indexer(), boardedVtxo) require.NoError(t, err) for parentTx, err := branch.NextRedeemTx(); err == nil; parentTx, err = branch.NextRedeemTx() { @@ -3522,8 +3583,8 @@ func TestSweep(t *testing.T) { require.Len(t, spendable, 1) // find first offchain tx vtxo, must be in spent - var firstOffchainTxVtxo *types.Vtxo - var unrolledVtxo *types.Vtxo + var firstOffchainTxVtxo *clientlib.Vtxo + var unrolledVtxo *clientlib.Vtxo for _, v := range spent { switch v.Txid { case res1.Txid: @@ -3567,7 +3628,7 @@ func TestSweep(t *testing.T) { wg := &sync.WaitGroup{} wg.Add(1) - var incominFunds []types.Vtxo + var incominFunds []clientlib.Vtxo var incomingErr error go func() { incominFunds, incomingErr = alice.NotifyIncomingFunds(ctx, offchainAddr.Address) @@ -3612,7 +3673,7 @@ func TestSweep(t *testing.T) { }) // Test fund recovery - res, err := alice.Settle(ctx, wallet.WithRecoverableVtxos()) + res, err := alice.Settle(ctx, batchsession.WithRecoverableVtxos()) require.NoError(t, err) require.NotNil(t, res) require.NotEmpty(t, res.CommitmentTxid) @@ -3660,7 +3721,7 @@ func TestSweep(t *testing.T) { wg := &sync.WaitGroup{} var aliceErr, bobErr, charlieErr, daveErr error - var aliceRes, bobRes, charlieRes, daveRes *wallet.BatchTxRes + var aliceRes, bobRes, charlieRes, daveRes *batchsession.BatchTxRes wg.Go(func() { aliceRes, aliceErr = alice.RedeemNotes(ctx, []string{aliceNote}) }) @@ -3824,7 +3885,7 @@ func TestSweep(t *testing.T) { wg := &sync.WaitGroup{} wg.Add(1) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error go func() { incomingFunds, incomingErr = alice.NotifyIncomingFunds(ctx, offchainAddr.Address) @@ -3934,7 +3995,7 @@ func TestCollisionBetweenInRoundAndRedeemVtxo(t *testing.T) { go func() { time.Sleep(50 * time.Millisecond) defer wg.Done() - res, err := alice.SendOffChain(ctx, []types.Receiver{{To: bobAddr.Address, Amount: 1000}}) + res, err := alice.SendOffChain(ctx, []clientlib.Receiver{{To: bobAddr.Address, Amount: 1000}}) if err != nil { ch <- resp{"", err} return @@ -3983,20 +4044,20 @@ func TestIntent(t *testing.T) { require.NoError(t, err) cosigners := []string{hex.EncodeToString(cosignerKey.PubKey().SerializeCompressed())} - outs := []types.Receiver{{To: offchainAddr.Address, Amount: 20000}} - _, err = alice.RegisterIntent(ctx, aliceVtxos, []types.Utxo{}, nil, outs, cosigners) + outs := []clientlib.Receiver{{To: offchainAddr.Address, Amount: 20000}} + _, err = alice.RegisterIntent(ctx, aliceVtxos, []clientlib.Utxo{}, nil, outs, cosigners) require.NoError(t, err) // should fail because previous intent spend same vtxos - _, err = alice.RegisterIntent(ctx, aliceVtxos, []types.Utxo{}, nil, outs, cosigners) + _, err = alice.RegisterIntent(ctx, aliceVtxos, []clientlib.Utxo{}, nil, outs, cosigners) require.Error(t, err) // should delete the intent - err = alice.DeleteIntent(ctx, aliceVtxos, []types.Utxo{}, nil) + err = alice.DeleteIntent(ctx, aliceVtxos, []clientlib.Utxo{}, nil) require.NoError(t, err) // should fail because no intent is associated with the vtxos - err = alice.DeleteIntent(ctx, aliceVtxos, []types.Utxo{}, nil) + err = alice.DeleteIntent(ctx, aliceVtxos, []clientlib.Utxo{}, nil) require.Error(t, err) }) @@ -4019,8 +4080,8 @@ func TestIntent(t *testing.T) { require.NoError(t, err) cosigners := []string{hex.EncodeToString(cosignerKey.PubKey().SerializeCompressed())} - outs := []types.Receiver{{To: offchainAddr.Address, Amount: 20000}} - outsBis := []types.Receiver{ + outs := []clientlib.Receiver{{To: offchainAddr.Address, Amount: 20000}} + outsBis := []clientlib.Receiver{ {To: offchainAddr.Address, Amount: 10000}, {To: offchainAddr.Address, Amount: 10000}, } @@ -4032,9 +4093,9 @@ func TestIntent(t *testing.T) { doRegister := func( ctx context.Context, wg *sync.WaitGroup, errChan chan error, - aliceVtxos []types.Vtxo, outs []types.Receiver, cosigners []string, + aliceVtxos []clientlib.Vtxo, outs []clientlib.Receiver, cosigners []string, ) { - _, err := alice.RegisterIntent(ctx, aliceVtxos, []types.Utxo{}, nil, outs, cosigners) + _, err := alice.RegisterIntent(ctx, aliceVtxos, []clientlib.Utxo{}, nil, outs, cosigners) errChan <- err wg.Done() } @@ -4083,10 +4144,10 @@ func TestBan(t *testing.T) { intentId, err := alice.RegisterIntent( t.Context(), - []types.Vtxo{aliceVtxo}, - []types.Utxo{}, + []clientlib.Vtxo{aliceVtxo}, + []clientlib.Utxo{}, nil, - []types.Receiver{ + []clientlib.Receiver{ { Amount: aliceVtxo.Amount, To: aliceAddr.Address, @@ -4096,15 +4157,8 @@ func TestBan(t *testing.T) { ) require.NoError(t, err) - topics := wallet.GetEventStreamTopics( - []types.Outpoint{aliceVtxo.Outpoint}, []tree.SignerSession{signerSession}, - ) - stream, close, err := aliceClient.GetEventStream(t.Context(), topics) - require.NoError(t, err) - t.Cleanup(close) - - handlers := &customBatchEventsHandler{ - onBatchStarted: func(ctx context.Context, event client.BatchStartedEvent) (bool, time.Duration, error) { + handler := &customBatchEventsHandler{ + onBatchStarted: func(ctx context.Context, event clientlib.BatchStartedEvent) (bool, time.Duration, error) { buf := sha256.Sum256([]byte(intentId)) hashedIntentId := hex.EncodeToString(buf[:]) @@ -4115,12 +4169,29 @@ func TestBan(t *testing.T) { return true, -1, nil }, - onTreeSigningStarted: func(ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { + onTreeSigningStarted: func(ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { return true, nil // just skip, do not submit nonces }, } - _, _, _, _, _, err = wallet.JoinBatchSession(t.Context(), stream, handlers) + cfgData, err := alice.GetConfigData(t.Context()) + require.NoError(t, err) + require.NotNil(t, cfgData) + + _, err = batchsession.JoinBatch(t.Context(), batchsession.JoinBatchArgs{ + BaseArgs: batchsession.BaseArgs{ + SignTx: alice.SignTransaction, + Vtxos: []clientlib.Vtxo{aliceVtxo}, + Outputs: []clientlib.Receiver{{ + To: aliceAddr.Address, + Amount: aliceVtxo.Amount, + }}, + }, + TreeSigners: []tree.SignerSession{signerSession}, + IntentId: intentId, + Client: aliceClient, + ServerInfo: cfgData.ClientInfo(), + }, batchsession.WithHandler(handler)) require.Error(t, err) // next settle should fail because the nonce has not been submitted @@ -4128,7 +4199,7 @@ func TestBan(t *testing.T) { require.Error(t, err) // send should fail - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ Amount: aliceVtxo.Amount, To: aliceAddr.Address, }}) @@ -4158,10 +4229,10 @@ func TestBan(t *testing.T) { intentId, err := alice.RegisterIntent( t.Context(), - []types.Vtxo{aliceVtxo}, - []types.Utxo{}, + []clientlib.Vtxo{aliceVtxo}, + []clientlib.Utxo{}, nil, - []types.Receiver{ + []clientlib.Receiver{ { Amount: aliceVtxo.Amount, To: aliceAddr.Address, @@ -4171,16 +4242,9 @@ func TestBan(t *testing.T) { ) require.NoError(t, err) - topics := wallet.GetEventStreamTopics( - []types.Outpoint{aliceVtxo.Outpoint}, []tree.SignerSession{signerSession}, - ) - stream, close, err := aliceClient.GetEventStream(t.Context(), topics) - require.NoError(t, err) - t.Cleanup(close) - var batchExpiry arklib.RelativeLocktime - handlers := &customBatchEventsHandler{ - onBatchStarted: func(ctx context.Context, event client.BatchStartedEvent) (bool, time.Duration, error) { + handler := &customBatchEventsHandler{ + onBatchStarted: func(ctx context.Context, event clientlib.BatchStartedEvent) (bool, time.Duration, error) { buf := sha256.Sum256([]byte(intentId)) hashedIntentId := hex.EncodeToString(buf[:]) @@ -4192,7 +4256,7 @@ func TestBan(t *testing.T) { return true, -1, nil }, - onTreeSigningStarted: func(ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { + onTreeSigningStarted: func(ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { myPubkey := signerSession.GetPublicKey() if !slices.Contains(event.CosignersPubkeys, myPubkey) { return true, nil @@ -4251,20 +4315,36 @@ func TestBan(t *testing.T) { return false, nil }, - onTreeNoncesAggregated: func(ctx context.Context, event client.TreeNoncesAggregatedEvent) (bool, error) { + onTreeNoncesAggregated: func(ctx context.Context, event clientlib.TreeNoncesAggregatedEvent) (bool, error) { return false, nil // skip sending signatures }, } - _, _, _, _, _, err = wallet.JoinBatchSession(t.Context(), stream, handlers) - require.Error(t, err) + cfgData, err := alice.GetConfigData(t.Context()) + require.NoError(t, err) + require.NotNil(t, cfgData) + + _, err = batchsession.JoinBatch(t.Context(), batchsession.JoinBatchArgs{ + BaseArgs: batchsession.BaseArgs{ + SignTx: alice.SignTransaction, + Vtxos: []clientlib.Vtxo{aliceVtxo}, + Outputs: []clientlib.Receiver{{ + To: aliceAddr.Address, + Amount: aliceVtxo.Amount, + }}, + }, + TreeSigners: []tree.SignerSession{signerSession}, + IntentId: intentId, + Client: aliceClient, + ServerInfo: cfgData.ClientInfo(), + }, batchsession.WithHandler(handler)) // next settle should fail because the signature has not been submitted _, err = alice.Settle(t.Context()) require.Error(t, err) // send should fail - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ Amount: aliceVtxo.Amount, To: aliceAddr.Address, }}) @@ -4293,10 +4373,10 @@ func TestBan(t *testing.T) { intentId, err := alice.RegisterIntent( t.Context(), - []types.Vtxo{aliceVtxo}, - []types.Utxo{}, + []clientlib.Vtxo{aliceVtxo}, + []clientlib.Utxo{}, nil, - []types.Receiver{ + []clientlib.Receiver{ { Amount: aliceVtxo.Amount, To: aliceAddr.Address, @@ -4306,15 +4386,8 @@ func TestBan(t *testing.T) { ) require.NoError(t, err) - topics := wallet.GetEventStreamTopics( - []types.Outpoint{aliceVtxo.Outpoint}, []tree.SignerSession{signerSession}, - ) - stream, close, err := aliceClient.GetEventStream(t.Context(), topics) - require.NoError(t, err) - t.Cleanup(close) - - handlers := &customBatchEventsHandler{ - onBatchStarted: func(ctx context.Context, event client.BatchStartedEvent) (bool, time.Duration, error) { + handler := &customBatchEventsHandler{ + onBatchStarted: func(ctx context.Context, event clientlib.BatchStartedEvent) (bool, time.Duration, error) { buf := sha256.Sum256([]byte(intentId)) hashedIntentId := hex.EncodeToString(buf[:]) @@ -4325,7 +4398,7 @@ func TestBan(t *testing.T) { return true, -1, nil }, - onTreeSigningStarted: func(ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { + onTreeSigningStarted: func(ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { myPubkey := signerSession.GetPublicKey() if !slices.Contains(event.CosignersPubkeys, myPubkey) { return true, nil @@ -4369,7 +4442,7 @@ func TestBan(t *testing.T) { return false, nil }, - onTreeNoncesAggregated: func(ctx context.Context, event client.TreeNoncesAggregatedEvent) (bool, error) { + onTreeNoncesAggregated: func(ctx context.Context, event clientlib.TreeNoncesAggregatedEvent) (bool, error) { signerSession.SetAggregatedNonces(event.Nonces) sigs, err := signerSession.Sign() @@ -4387,7 +4460,24 @@ func TestBan(t *testing.T) { }, } - _, _, _, _, _, err = wallet.JoinBatchSession(t.Context(), stream, handlers) + cfgData, err := alice.GetConfigData(t.Context()) + require.NoError(t, err) + require.NotNil(t, cfgData) + + _, err = batchsession.JoinBatch(t.Context(), batchsession.JoinBatchArgs{ + BaseArgs: batchsession.BaseArgs{ + SignTx: alice.SignTransaction, + Vtxos: []clientlib.Vtxo{aliceVtxo}, + Outputs: []clientlib.Receiver{{ + To: aliceAddr.Address, + Amount: aliceVtxo.Amount, + }}, + }, + TreeSigners: []tree.SignerSession{signerSession}, + IntentId: intentId, + Client: aliceClient, + ServerInfo: cfgData.ClientInfo(), + }, batchsession.WithHandler(handler)) require.Error(t, err) // next settle should fail because the signature was invalid @@ -4395,7 +4485,7 @@ func TestBan(t *testing.T) { require.Error(t, err) // send should fail - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ Amount: aliceVtxo.Amount, To: aliceAddr.Address, }}) @@ -4424,10 +4514,10 @@ func TestBan(t *testing.T) { intentId, err := alice.RegisterIntent( t.Context(), - []types.Vtxo{aliceVtxo}, - []types.Utxo{}, + []clientlib.Vtxo{aliceVtxo}, + []clientlib.Utxo{}, nil, - []types.Receiver{ + []clientlib.Receiver{ { Amount: aliceVtxo.Amount, To: aliceAddr.Address, @@ -4437,16 +4527,9 @@ func TestBan(t *testing.T) { ) require.NoError(t, err) - topics := wallet.GetEventStreamTopics( - []types.Outpoint{aliceVtxo.Outpoint}, []tree.SignerSession{signerSession}, - ) - stream, close, err := aliceClient.GetEventStream(t.Context(), topics) - require.NoError(t, err) - t.Cleanup(close) - var batchExpiry arklib.RelativeLocktime - handlers := &customBatchEventsHandler{ - onBatchStarted: func(ctx context.Context, event client.BatchStartedEvent) (bool, time.Duration, error) { + handler := &customBatchEventsHandler{ + onBatchStarted: func(ctx context.Context, event clientlib.BatchStartedEvent) (bool, time.Duration, error) { buf := sha256.Sum256([]byte(intentId)) hashedIntentId := hex.EncodeToString(buf[:]) @@ -4458,7 +4541,7 @@ func TestBan(t *testing.T) { return true, -1, nil }, - onTreeSigningStarted: func(ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { + onTreeSigningStarted: func(ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { myPubkey := signerSession.GetPublicKey() if !slices.Contains(event.CosignersPubkeys, myPubkey) { return true, nil @@ -4517,7 +4600,7 @@ func TestBan(t *testing.T) { return false, nil }, - onTreeNoncesAggregated: func(ctx context.Context, event client.TreeNoncesAggregatedEvent) (bool, error) { + onTreeNoncesAggregated: func(ctx context.Context, event clientlib.TreeNoncesAggregatedEvent) (bool, error) { signerSession.SetAggregatedNonces(event.Nonces) sigs, err := signerSession.Sign() @@ -4533,12 +4616,29 @@ func TestBan(t *testing.T) { ) return err == nil, err }, - onBatchFinalization: func(ctx context.Context, event client.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree) ([]string, error) { + onBatchFinalization: func(ctx context.Context, event clientlib.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree) ([]string, error) { return nil, nil // do not submit forfeit txs }, } - _, _, _, _, _, err = wallet.JoinBatchSession(t.Context(), stream, handlers) + cfgData, err := alice.GetConfigData(t.Context()) + require.NoError(t, err) + require.NotNil(t, cfgData) + + _, err = batchsession.JoinBatch(t.Context(), batchsession.JoinBatchArgs{ + BaseArgs: batchsession.BaseArgs{ + SignTx: alice.SignTransaction, + Vtxos: []clientlib.Vtxo{aliceVtxo}, + Outputs: []clientlib.Receiver{{ + To: aliceAddr.Address, + Amount: aliceVtxo.Amount, + }}, + }, + TreeSigners: []tree.SignerSession{signerSession}, + IntentId: intentId, + Client: aliceClient, + ServerInfo: cfgData.ClientInfo(), + }, batchsession.WithHandler(handler)) require.Error(t, err) // next settle should fail because the forfeit txs have not been submitted @@ -4546,7 +4646,7 @@ func TestBan(t *testing.T) { require.Error(t, err) // send should fail - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ Amount: aliceVtxo.Amount, To: aliceAddr.Address, }}) @@ -4575,10 +4675,10 @@ func TestBan(t *testing.T) { intentId, err := alice.RegisterIntent( t.Context(), - []types.Vtxo{aliceVtxo}, - []types.Utxo{}, + []clientlib.Vtxo{aliceVtxo}, + []clientlib.Utxo{}, nil, - []types.Receiver{ + []clientlib.Receiver{ { Amount: aliceVtxo.Amount, To: aliceAddr.Address, @@ -4588,19 +4688,15 @@ func TestBan(t *testing.T) { ) require.NoError(t, err) - topics := wallet.GetEventStreamTopics( - []types.Outpoint{aliceVtxo.Outpoint}, []tree.SignerSession{signerSession}, - ) - stream, close, err := aliceClient.GetEventStream(t.Context(), topics) + cfgData, err := alice.GetConfigData(t.Context()) require.NoError(t, err) - t.Cleanup(close) + require.NotNil(t, cfgData) - info, err := aliceClient.GetInfo(t.Context()) - require.NoError(t, err) + info := cfgData.ClientInfo() var batchExpiry arklib.RelativeLocktime - handlers := &customBatchEventsHandler{ - onBatchStarted: func(ctx context.Context, event client.BatchStartedEvent) (bool, time.Duration, error) { + handler := &customBatchEventsHandler{ + onBatchStarted: func(ctx context.Context, event clientlib.BatchStartedEvent) (bool, time.Duration, error) { buf := sha256.Sum256([]byte(intentId)) hashedIntentId := hex.EncodeToString(buf[:]) @@ -4612,7 +4708,7 @@ func TestBan(t *testing.T) { return true, -1, nil }, - onTreeSigningStarted: func(ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { + onTreeSigningStarted: func(ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { myPubkey := signerSession.GetPublicKey() if !slices.Contains(event.CosignersPubkeys, myPubkey) { return true, nil @@ -4671,7 +4767,7 @@ func TestBan(t *testing.T) { return false, nil }, - onTreeNoncesAggregated: func(ctx context.Context, event client.TreeNoncesAggregatedEvent) (bool, error) { + onTreeNoncesAggregated: func(ctx context.Context, event clientlib.TreeNoncesAggregatedEvent) (bool, error) { signerSession.SetAggregatedNonces(event.Nonces) sigs, err := signerSession.Sign() @@ -4687,7 +4783,7 @@ func TestBan(t *testing.T) { ) return err == nil, err }, - onBatchFinalization: func(ctx context.Context, event client.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree) ([]string, error) { + onBatchFinalization: func(ctx context.Context, event clientlib.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree) ([]string, error) { txhash, err := chainhash.NewHashFromStr(aliceVtxo.Txid) if err != nil { return nil, err @@ -4740,15 +4836,27 @@ func TestBan(t *testing.T) { }, } - _, _, _, _, _, err = wallet.JoinBatchSession(t.Context(), stream, handlers) - require.Error(t, err) + _, err = batchsession.JoinBatch(t.Context(), batchsession.JoinBatchArgs{ + BaseArgs: batchsession.BaseArgs{ + SignTx: alice.SignTransaction, + Vtxos: []clientlib.Vtxo{aliceVtxo}, + Outputs: []clientlib.Receiver{{ + To: aliceAddr.Address, + Amount: aliceVtxo.Amount, + }}, + }, + TreeSigners: []tree.SignerSession{signerSession}, + IntentId: intentId, + Client: aliceClient, + ServerInfo: cfgData.ClientInfo(), + }, batchsession.WithHandler(handler)) // next settle should fail because the forfeit txs have not been submitted _, err = alice.Settle(t.Context()) require.Error(t, err) // send should fail - _, err = alice.SendOffChain(t.Context(), []types.Receiver{{ + _, err = alice.SendOffChain(t.Context(), []clientlib.Receiver{{ Amount: aliceVtxo.Amount, To: aliceAddr.Address, }}) @@ -4778,6 +4886,10 @@ func TestBan(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, boardingUtxos) + signingClosure, err := boardingAddr.CollaborativeClosure() + require.NoError(t, err) + require.NotNil(t, signingClosure) + aliceUtxo := boardingUtxos[0] utxo := aliceUtxo.ToUtxo( arklib.RelativeLocktime{ @@ -4785,6 +4897,7 @@ func TestBan(t *testing.T) { Value: uint32(info.BoardingExitDelay), }, boardingAddr.Tapscripts, + signingClosure, ) // setup a random musig2 tree signer @@ -4794,29 +4907,20 @@ func TestBan(t *testing.T) { intentId, err := alice.RegisterIntent( t.Context(), - []types.Vtxo{}, - []types.Utxo{utxo}, nil, - []types.Receiver{ - { - Amount: aliceUtxo.Amount, - To: offchainAddr.Address, - }, - }, + []clientlib.Utxo{utxo}, + nil, + []clientlib.Receiver{{ + Amount: aliceUtxo.Amount, + To: offchainAddr.Address, + }}, []string{signerSession.GetPublicKey()}, ) require.NoError(t, err) - topics := wallet.GetEventStreamTopics( - []types.Outpoint{utxo.Outpoint}, []tree.SignerSession{signerSession}, - ) - stream, close, err := aliceClient.GetEventStream(t.Context(), topics) - require.NoError(t, err) - t.Cleanup(close) - var batchExpiry arklib.RelativeLocktime - handlers := &customBatchEventsHandler{ - onBatchStarted: func(ctx context.Context, event client.BatchStartedEvent) (bool, time.Duration, error) { + handler := &customBatchEventsHandler{ + onBatchStarted: func(ctx context.Context, event clientlib.BatchStartedEvent) (bool, time.Duration, error) { buf := sha256.Sum256([]byte(intentId)) hashedIntentId := hex.EncodeToString(buf[:]) @@ -4828,7 +4932,7 @@ func TestBan(t *testing.T) { return true, -1, nil }, - onTreeSigningStarted: func(ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { + onTreeSigningStarted: func(ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree) (bool, error) { myPubkey := signerSession.GetPublicKey() if !slices.Contains(event.CosignersPubkeys, myPubkey) { return true, nil @@ -4887,7 +4991,7 @@ func TestBan(t *testing.T) { return false, nil }, - onTreeNoncesAggregated: func(ctx context.Context, event client.TreeNoncesAggregatedEvent) (bool, error) { + onTreeNoncesAggregated: func(ctx context.Context, event clientlib.TreeNoncesAggregatedEvent) (bool, error) { signerSession.SetAggregatedNonces(event.Nonces) sigs, err := signerSession.Sign() @@ -4903,7 +5007,7 @@ func TestBan(t *testing.T) { ) return err == nil, err }, - onBatchFinalization: func(ctx context.Context, event client.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree) ([]string, error) { + onBatchFinalization: func(ctx context.Context, event clientlib.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree) ([]string, error) { commitmentPtx, err := psbt.NewFromRawBytes(strings.NewReader(event.Tx), true) if err != nil { return nil, err @@ -4932,8 +5036,24 @@ func TestBan(t *testing.T) { }, } - _, _, _, _, _, err = wallet.JoinBatchSession(t.Context(), stream, handlers) - require.Error(t, err) + cfgData, err := alice.GetConfigData(t.Context()) + require.NoError(t, err) + require.NotNil(t, cfgData) + + _, err = batchsession.JoinBatch(t.Context(), batchsession.JoinBatchArgs{ + BaseArgs: batchsession.BaseArgs{ + SignTx: alice.SignTransaction, + BoardingUtxos: []clientlib.Utxo{utxo}, + Outputs: []clientlib.Receiver{{ + To: offchainAddr.Address, + Amount: aliceUtxo.Amount, + }}, + }, + TreeSigners: []tree.SignerSession{signerSession}, + IntentId: intentId, + Client: aliceClient, + ServerInfo: cfgData.ClientInfo(), + }, batchsession.WithHandler(handler)) // next settle should fail because the forfeit txs have not been submitted _, err = alice.Settle(t.Context()) @@ -5006,7 +5126,7 @@ func TestFee(t *testing.T) { // They join the same batch to settle their funds var aliceIncomingErr, bobIncomingErr error - var aliceIncomingFunds, bobIncomingFunds []types.Vtxo + var aliceIncomingFunds, bobIncomingFunds []clientlib.Vtxo go func() { aliceIncomingFunds, aliceIncomingErr = alice.NotifyIncomingFunds( ctx, aliceOffchainAddr.Address, @@ -5018,7 +5138,7 @@ func TestFee(t *testing.T) { wg.Done() }() - var aliceBatchRes, bobBatchRes *wallet.BatchTxRes + var aliceBatchRes, bobBatchRes *batchsession.BatchTxRes var aliceBatchErr, bobBatchErr error go func() { aliceBatchRes, aliceBatchErr = alice.Settle(ctx) @@ -5166,8 +5286,8 @@ func TestAsset(t *testing.T) { require.NotEmpty(t, bobAddr) _, err = alice.SendOffChain( - ctx, []types.Receiver{ - {To: bobAddr.Address, Amount: 400, Assets: []types.Asset{ + ctx, []clientlib.Receiver{ + {To: bobAddr.Address, Amount: 400, Assets: []clientlib.Asset{ {AssetId: assetId, Amount: transferAmount}, }}, }, @@ -5236,7 +5356,7 @@ func TestAsset(t *testing.T) { alice := setupClientWallet(t) faucetOffchain(t, alice, 0.01) - res, err := alice.IssueAsset(ctx, 1, types.NewControlAsset{Amount: 1}, nil) + res, err := alice.IssueAsset(ctx, 1, clientlib.NewControlAsset{Amount: 1}, nil) require.NoError(t, err) require.NotNil(t, res) require.Len(t, res.IssuedAssets, 2) @@ -5264,7 +5384,7 @@ func TestAsset(t *testing.T) { res2, err := alice.IssueAsset( ctx, 1, - types.ExistingControlAsset{ID: controlAssetId}, + clientlib.ExistingControlAsset{Id: controlAssetId}, nil, ) require.NoError(t, err) @@ -5283,7 +5403,7 @@ func TestAsset(t *testing.T) { faucetOffchain(t, alice, 0.01) // issue an asset with a control asset - res, err := alice.IssueAsset(ctx, 1, types.NewControlAsset{Amount: 1}, nil) + res, err := alice.IssueAsset(ctx, 1, clientlib.NewControlAsset{Amount: 1}, nil) require.NoError(t, err) require.NotNil(t, res) require.Len(t, res.IssuedAssets, 2) @@ -5436,8 +5556,8 @@ func TestAsset(t *testing.T) { require.NoError(t, err) // tx with a regular asset output greater than dust + a subdust output - _, err = alice.SendOffChain(ctx, []types.Receiver{ - {To: bobAddr.Address, Amount: 400, Assets: []types.Asset{ + _, err = alice.SendOffChain(ctx, []clientlib.Receiver{ + {To: bobAddr.Address, Amount: 400, Assets: []clientlib.Asset{ {AssetId: assetId, Amount: 1_200}, }}, {To: bobAddr.Address, Amount: 100}, @@ -5470,9 +5590,9 @@ func TestAsset(t *testing.T) { require.NoError(t, err) // send asset to Bob with a subdust sat amount (100 sats) - _, err = alice.SendOffChain(ctx, []types.Receiver{{ + _, err = alice.SendOffChain(ctx, []clientlib.Receiver{{ To: bobAddr.Address, Amount: 100, - Assets: []types.Asset{{AssetId: assetId, Amount: 1_200}}, + Assets: []clientlib.Asset{{AssetId: assetId, Amount: 1_200}}, }}) require.NoError(t, err) @@ -5489,7 +5609,7 @@ func TestAsset(t *testing.T) { wg.Done() }() - _, err = alice.SendOffChain(ctx, []types.Receiver{{ + _, err = alice.SendOffChain(ctx, []clientlib.Receiver{{ To: bobAddr.Address, Amount: 1000, }}) require.NoError(t, err) @@ -5730,7 +5850,7 @@ func TestTxListenerChurn(t *testing.T) { return } - res, err := sender.SendOffChain(stressCtx, []types.Receiver{{ + res, err := sender.SendOffChain(stressCtx, []clientlib.Receiver{{ To: receiverOffchainAddr.Address, Amount: sendAmount, }}) @@ -6016,7 +6136,7 @@ func TestEventListenerChurn(t *testing.T) { roundCtx, cancelRound := context.WithTimeout(stressCtx, roundTimeout) notifyErrors := make([]error, len(participants)) settleErrors := make([]error, len(participants)) - batchRes := make([]*wallet.BatchTxRes, len(participants)) + batchRes := make([]*batchsession.BatchTxRes, len(participants)) // Kick off Settle + NotifyIncomingFunds for every participant // in parallel — this is what triggers event-stream events. diff --git a/internal/test/e2e/single_batch_smoke_test.go b/internal/test/e2e/single_batch_smoke_test.go index 12c42b5f9..c060a9af0 100644 --- a/internal/test/e2e/single_batch_smoke_test.go +++ b/internal/test/e2e/single_batch_smoke_test.go @@ -13,7 +13,7 @@ import ( "testing" "github.com/arkade-os/arkd/internal/core/application" - wallet "github.com/arkade-os/arkd/pkg/client-lib" + wallet "github.com/arkade-os/arkd/pkg/client-wallet" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index 6a83a4beb..7fa9bee9c 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -16,13 +16,11 @@ import ( arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/arkade-os/arkd/pkg/ark-lib/txutils" - wallet "github.com/arkade-os/arkd/pkg/client-lib" - "github.com/arkade-os/arkd/pkg/client-lib/explorer" - "github.com/arkade-os/arkd/pkg/client-lib/identity" - singlekeyidentity "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey" - identityinmemorystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store/inmemory" - "github.com/arkade-os/arkd/pkg/client-lib/store" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + wallet "github.com/arkade-os/arkd/pkg/client-wallet" + singlekeyidentity "github.com/arkade-os/arkd/pkg/client-wallet/identity" + identityinmemorystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store/inmemory" + "github.com/arkade-os/arkd/pkg/client-wallet/store" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" @@ -133,7 +131,7 @@ func newCommand(name string, arg ...string) *exec.Cmd { return cmd } -func bumpAndBroadcastTx(t *testing.T, tx string, explorer explorer.Explorer) { +func bumpAndBroadcastTx(t *testing.T, tx string, explorer clientlib.Explorer) { var transaction wire.MsgTx err := transaction.Deserialize(hex.NewDecoder(strings.NewReader(tx))) require.NoError(t, err) @@ -149,7 +147,7 @@ func bumpAndBroadcastTx(t *testing.T, tx string, explorer explorer.Explorer) { // bumpAnchorTx is crafting and signing a transaction bumping the fees for a given tx with P2A output // it is using the onchain P2TR account to select UTXOs -func bumpAnchorTx(t *testing.T, parent *wire.MsgTx, explorerSvc explorer.Explorer) string { +func bumpAnchorTx(t *testing.T, parent *wire.MsgTx, explorerSvc clientlib.Explorer) string { randomPrivKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -258,9 +256,7 @@ func bumpAnchorTx(t *testing.T, parent *wire.MsgTx, explorerSvc explorer.Explore } func setupClientWallet(t *testing.T) wallet.Wallet { - appDataStore, err := store.NewStore(store.Config{ - ConfigStoreType: types.InMemoryStore, - }) + appDataStore, err := store.NewStore(wallet.InMemoryStore, "") require.NoError(t, err) client, err := wallet.NewWallet(appDataStore) @@ -287,7 +283,7 @@ func setupClientWallet(t *testing.T) wallet.Wallet { return client } -func setupIdentity(t *testing.T) (identity.Identity, *btcec.PublicKey, error) { +func setupIdentity(t *testing.T) (clientlib.Identity, *btcec.PublicKey, error) { store, err := identityinmemorystore.NewStore() require.NoError(t, err) require.NotNil(t, store) @@ -355,7 +351,7 @@ func faucetOnchain(t *testing.T, address string, amount float64) { require.NoError(t, err) } -func faucetOffchain(t *testing.T, client wallet.Wallet, amount float64) types.Vtxo { +func faucetOffchain(t *testing.T, client wallet.Wallet, amount float64) clientlib.Vtxo { _, offchainAddr, _, err := client.Receive(t.Context()) require.NoError(t, err) @@ -363,7 +359,7 @@ func faucetOffchain(t *testing.T, client wallet.Wallet, amount float64) types.Vt wg := &sync.WaitGroup{} wg.Add(1) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error go func() { incomingFunds, incomingErr = client.NotifyIncomingFunds(t.Context(), offchainAddr.Address) @@ -383,7 +379,7 @@ func faucetOffchain(t *testing.T, client wallet.Wallet, amount float64) types.Vt return incomingFunds[0] } -func faucetOffchainWithAddress(t *testing.T, addr string, amount float64) types.Vtxo { +func faucetOffchainWithAddress(t *testing.T, addr string, amount float64) clientlib.Vtxo { client := setupClientWallet(t) _, offchainAddr, _, err := client.Receive(t.Context()) @@ -393,7 +389,7 @@ func faucetOffchainWithAddress(t *testing.T, addr string, amount float64) types. wg := &sync.WaitGroup{} wg.Add(1) - var incomingFunds []types.Vtxo + var incomingFunds []clientlib.Vtxo var incomingErr error go func() { incomingFunds, incomingErr = client.NotifyIncomingFunds(t.Context(), offchainAddr.Address) @@ -419,7 +415,7 @@ func faucetOffchainWithAddress(t *testing.T, addr string, amount float64) types. wg.Done() }() - res, err := client.SendOffChain(t.Context(), []types.Receiver{{ + res, err := client.SendOffChain(t.Context(), []clientlib.Receiver{{ To: addr, Amount: uint64(amount * 1e8), }}) @@ -678,12 +674,12 @@ func refill(httpClient *http.Client) error { return nil } -func listVtxosWithAsset(t *testing.T, client wallet.Wallet, assetID string) []types.Vtxo { +func listVtxosWithAsset(t *testing.T, client wallet.Wallet, assetID string) []clientlib.Vtxo { t.Helper() vtxos, _, err := client.ListVtxos(t.Context()) require.NoError(t, err) - assetVtxos := make([]types.Vtxo, 0, len(vtxos)) + assetVtxos := make([]clientlib.Vtxo, 0, len(vtxos)) for _, vtxo := range vtxos { for _, asset := range vtxo.Assets { if asset.AssetId == assetID { @@ -695,17 +691,17 @@ func listVtxosWithAsset(t *testing.T, client wallet.Wallet, assetID string) []ty return assetVtxos } -func findAssetInVtxo(vtxo types.Vtxo, assetID string) (types.Asset, bool) { +func findAssetInVtxo(vtxo clientlib.Vtxo, assetID string) (clientlib.Asset, bool) { for _, asset := range vtxo.Assets { if asset.AssetId == assetID { return asset, true } } - return types.Asset{}, false + return clientlib.Asset{}, false } // requireVtxoHasAsset asserts that the given VTXO contains an asset with the given ID and amount. -func requireVtxoHasAsset(t *testing.T, vtxo types.Vtxo, assetID string, expectedAmount uint64) { +func requireVtxoHasAsset(t *testing.T, vtxo clientlib.Vtxo, assetID string, expectedAmount uint64) { t.Helper() asset, found := findAssetInVtxo(vtxo, assetID) require.True(t, found) diff --git a/pkg/ark-cli/go.mod b/pkg/ark-cli/go.mod index 7a16efa0d..8a1f854c5 100644 --- a/pkg/ark-cli/go.mod +++ b/pkg/ark-cli/go.mod @@ -10,11 +10,14 @@ replace github.com/arkade-os/arkd/pkg/errors => ../errors replace github.com/arkade-os/arkd/pkg/client-lib => ../client-lib +replace github.com/arkade-os/arkd/pkg/client-wallet => ../client-wallet + replace github.com/arkade-os/arkd/api-spec => ../../api-spec require ( github.com/arkade-os/arkd/pkg/ark-lib v0.7.2-0.20251020193908-f401a905e83f github.com/arkade-os/arkd/pkg/client-lib v0.0.0-00010101000000-000000000000 + github.com/arkade-os/arkd/pkg/client-wallet v0.0.0-00010101000000-000000000000 github.com/urfave/cli/v2 v2.27.4 golang.org/x/term v0.40.0 ) @@ -39,7 +42,6 @@ require ( github.com/btcsuite/btcwallet/wtxmgr v1.5.3 // indirect github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect @@ -50,7 +52,6 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kkdai/bstream v1.0.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd // indirect github.com/lightninglabs/neutrino/cache v1.1.2 // indirect @@ -62,7 +63,6 @@ require ( github.com/lightningnetwork/lnd/tlv v1.2.6 // indirect github.com/ltcsuite/ltcd/chaincfg/chainhash v1.0.2 // indirect github.com/meshapi/grpc-api-gateway v0.1.0 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -72,9 +72,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/net v0.51.0 // indirect diff --git a/pkg/ark-cli/go.sum b/pkg/ark-cli/go.sum index b968d4d4a..beb4875f7 100644 --- a/pkg/ark-cli/go.sum +++ b/pkg/ark-cli/go.sum @@ -1,7 +1,5 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= @@ -59,20 +57,17 @@ github.com/btcsuite/winsvc v1.0.0 h1:J9B4L7e3oqhXOcm+2IuNApwzQec85lE+QaikUcCs+dk github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= -github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= -github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -87,8 +82,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3 github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/decred/dcrd/lru v1.1.3 h1:w9EAbvGLyzm6jTjF83UKuqZEiUtJmvRhQDOCEIvSuE0= github.com/decred/dcrd/lru v1.1.3/go.mod h1:Tw0i0pJyiLEx/oZdHLe1Wdv/Y7EGzAX+sYftnmxBR4o= -github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE= -github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M= +github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= @@ -97,22 +92,20 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fergusstrange/embedded-postgres v1.28.0 h1:Atixd24HCuBHBavnG4eiZAjRizOViwUahKGSjJdz1SU= -github.com/fergusstrange/embedded-postgres v1.28.0/go.mod h1:t/MLs0h9ukYM6FSt99R7InCHs1nW0ordoVCcnzmpTYw= +github.com/fergusstrange/embedded-postgres v1.25.0 h1:sa+k2Ycrtz40eCRPOzI7Ry7TtkWXXJ+YRsxpKMDhxK0= +github.com/fergusstrange/embedded-postgres v1.25.0/go.mod h1:t/MLs0h9ukYM6FSt99R7InCHs1nW0ordoVCcnzmpTYw= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= -github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -126,8 +119,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -142,8 +135,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= -github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= @@ -155,6 +148,8 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= @@ -167,23 +162,17 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgtype v1.14.3 h1:h6W9cPuHsRWQFTWUZMAKMgG5jSwQI0Zurzdvlx3Plus= -github.com/jackc/pgtype v1.14.3/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= -github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= -github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= -github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= -github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= -github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= @@ -193,8 +182,6 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8 github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -207,48 +194,48 @@ github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd h1:D8aRo github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd/go.mod h1:x3OmY2wsA18+Kc3TSV2QpSUewOCiscw2mKpXgZv2kZk= github.com/lightninglabs/neutrino/cache v1.1.2 h1:C9DY/DAPaPxbFC+xNNEI/z1SJY9GS3shmlu5hIQ798g= github.com/lightninglabs/neutrino/cache v1.1.2/go.mod h1:XJNcgdOw1LQnanGjw8Vj44CvguYA25IMKjWFZczwZuo= -github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb h1:yfM05S8DXKhuCBp5qSMZdtSwvJ+GFzl94KbXMNB1JDY= -github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb/go.mod h1:c0kvRShutpj3l6B9WtTsNTBUtjSmjZXbJd9ZBRQOSKI= +github.com/lightningnetwork/lightning-onion v1.2.1-0.20230823005744-06182b1d7d2f h1:Pua7+5TcFEJXIIZ1I2YAUapmbcttmLj4TTi786bIi3s= +github.com/lightningnetwork/lightning-onion v1.2.1-0.20230823005744-06182b1d7d2f/go.mod h1:c0kvRShutpj3l6B9WtTsNTBUtjSmjZXbJd9ZBRQOSKI= github.com/lightningnetwork/lnd v0.18.2-beta h1:Qv4xQ2ka05vqzmdkFdISHCHP6CzHoYNVKfD18XPjHsM= github.com/lightningnetwork/lnd v0.18.2-beta/go.mod h1:cGQR1cVEZFZQcCx2VBbDY8xwGjCz+SupSopU1HpjP2I= github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0= github.com/lightningnetwork/lnd/clock v1.1.1/go.mod h1:mGnAhPyjYZQJmebS7aevElXKTFDuO+uNFFfMXK1W8xQ= github.com/lightningnetwork/lnd/fn v1.2.1 h1:pPsVGrwi9QBwdLJzaEGK33wmiVKOxs/zc8H7+MamFf0= github.com/lightningnetwork/lnd/fn v1.2.1/go.mod h1:SyFohpVrARPKH3XVAJZlXdVe+IwMYc4OMAvrDY32kw0= -github.com/lightningnetwork/lnd/healthcheck v1.2.5 h1:aTJy5xeBpcWgRtW/PGBDe+LMQEmNm/HQewlQx2jt7OA= -github.com/lightningnetwork/lnd/healthcheck v1.2.5/go.mod h1:G7Tst2tVvWo7cx6mSBEToQC5L1XOGxzZTPB29g9Rv2I= -github.com/lightningnetwork/lnd/kvdb v1.4.10 h1:vK89IVv1oVH9ubQWU+EmoCQFeVRaC8kfmOrqHbY5zoY= -github.com/lightningnetwork/lnd/kvdb v1.4.10/go.mod h1:J2diNABOoII9UrMnxXS5w7vZwP7CA1CStrl8MnIrb3A= +github.com/lightningnetwork/lnd/healthcheck v1.2.4 h1:lLPLac+p/TllByxGSlkCwkJlkddqMP5UCoawCj3mgFQ= +github.com/lightningnetwork/lnd/healthcheck v1.2.4/go.mod h1:G7Tst2tVvWo7cx6mSBEToQC5L1XOGxzZTPB29g9Rv2I= +github.com/lightningnetwork/lnd/kvdb v1.4.8 h1:xH0a5Vi1yrcZ5BEeF2ba3vlKBRxrL9uYXlWTjOjbNTY= +github.com/lightningnetwork/lnd/kvdb v1.4.8/go.mod h1:J2diNABOoII9UrMnxXS5w7vZwP7CA1CStrl8MnIrb3A= github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= -github.com/lightningnetwork/lnd/sqldb v1.0.3 h1:zLfAwOvM+6+3+hahYO9Q3h8pVV0TghAR7iJ5YMLCd3I= -github.com/lightningnetwork/lnd/sqldb v1.0.3/go.mod h1:4cQOkdymlZ1znnjuRNvMoatQGJkRneTj2CoPSPaQhWo= +github.com/lightningnetwork/lnd/sqldb v1.0.2 h1:PfuYzScYMD9/QonKo/QvgsbXfTnH5DfldIimkfdW4Bk= +github.com/lightningnetwork/lnd/sqldb v1.0.2/go.mod h1:V2Xl6JNWLTKE97WJnwfs0d0TYJdIQTqK8/3aAwkd3qI= github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM= github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.2.6 h1:icvQG2yDr6k3ZuZzfRdG3EJp6pHurcuh3R6dg0gv/Mw= github.com/lightningnetwork/lnd/tlv v1.2.6/go.mod h1:/CmY4VbItpOldksocmGT4lxiJqRP9oLxwSZOda2kzNQ= -github.com/lightningnetwork/lnd/tor v1.1.3 h1:hPIxSpT0UUJmt7iCbF4n4nsmkYe++fvQ/zRadeFfprY= -github.com/lightningnetwork/lnd/tor v1.1.3/go.mod h1:/LwOzgL6c+bVW0Aegoj1pGlxx9wSvbulBe876knJetc= -github.com/ltcsuite/ltcd v0.23.5 h1:MFWjmx2hCwxrUu9v0wdIPOSN7PHg9BWQeh+AO4FsVLI= -github.com/ltcsuite/ltcd v0.23.5/go.mod h1:JV6swXR5m0cYFi0VYdQPp3UnMdaDQxaRUCaU1PPjb+g= +github.com/lightningnetwork/lnd/tor v1.1.2 h1:3zv9z/EivNFaMF89v3ciBjCS7kvCj4ZFG7XvD2Qq0/k= +github.com/lightningnetwork/lnd/tor v1.1.2/go.mod h1:j7T9uJ2NLMaHwE7GiBGnpYLn4f7NRoTM6qj+ul6/ycA= +github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw= +github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796/go.mod h1:3p7ZTf9V1sNPI5H8P3NkTFF4LuwMdPl2DodF60qAKqY= github.com/ltcsuite/ltcd/chaincfg/chainhash v1.0.2 h1:xuWxvRKxLvOKuS7/Q/7I3tpc3cWAB0+hZpU8YdVqkzg= github.com/ltcsuite/ltcd/chaincfg/chainhash v1.0.2/go.mod h1:nkLkAFGhursWf2U68gt61hPieK1I+0m78e+2aevNyD8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/meshapi/grpc-api-gateway v0.1.0 h1:0rGp4qZQ6T9Ud0KfzdHYsEju4AX/Q3AQOU7unoBLssY= github.com/meshapi/grpc-api-gateway v0.1.0/go.mod h1:lkFQUbwq7i/JqEPZMzCIRskp9Jb7tm1uLODwsOdw064= -github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= -github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= +github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -264,23 +251,23 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/runc v1.2.4 h1:yWFgLkghp71D76Fa0l349yAl5g4Gse7DPYNlvkQ9Eiw= -github.com/opencontainers/runc v1.2.4/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM= -github.com/ory/dockertest/v3 v3.11.0 h1:OiHcxKAvSDUwsEVh2BjxQQc/5EHz9n0va9awCtNGuyA= -github.com/ory/dockertest/v3 v3.11.0/go.mod h1:VIPxS1gwT9NpPOrfD3rACs8Y9Z7yhzO4SB194iUDnUI= +github.com/opencontainers/runc v1.1.12 h1:BOIssBaW1La0/qbNZHXOOa71dZfZEQOzW7dqQf3phss= +github.com/opencontainers/runc v1.1.12/go.mod h1:S+lQwSfncpBha7XTy/5lBwWgm5+y5Ma/O44Ekby9FK8= +github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= +github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/client_golang v1.11.1 h1:+4eQaD7vAZ6DsfsxB15hbE0odUjGI5ARs9yskGu1v4s= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -307,8 +294,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4dN7GR16kFc5fp3d1RIYzJW5onx8Ybykw2YQFA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= @@ -319,36 +306,36 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= -go.etcd.io/etcd/api/v3 v3.5.15 h1:3KpLJir1ZEBrYuV2v+Twaa/e2MdDCEZ/70H+lzEiwsk= -go.etcd.io/etcd/api/v3 v3.5.15/go.mod h1:N9EhGzXq58WuMllgH9ZvnEr7SI9pS0k0+DHZezGp7jM= -go.etcd.io/etcd/client/pkg/v3 v3.5.15 h1:fo0HpWz/KlHGMCC+YejpiCmyWDEuIpnTDzpJLB5fWlA= -go.etcd.io/etcd/client/pkg/v3 v3.5.15/go.mod h1:mXDI4NAOwEiszrHCb0aqfAYNCrZP4e9hRca3d1YK8EU= +go.etcd.io/etcd/api/v3 v3.5.7 h1:sbcmosSVesNrWOJ58ZQFitHMdncusIifYcrBfwrlJSY= +go.etcd.io/etcd/api/v3 v3.5.7/go.mod h1:9qew1gCdDDLu+VwmeG+iFpL+QlpHTo7iubavdVDgCAA= +go.etcd.io/etcd/client/pkg/v3 v3.5.7 h1:y3kf5Gbp4e4q7egZdn5T7W9TSHUvkClN6u+Rq9mEOmg= +go.etcd.io/etcd/client/pkg/v3 v3.5.7/go.mod h1:o0Abi1MK86iad3YrWhgUsbGx1pmTS+hrORWc2CamuhY= go.etcd.io/etcd/client/v2 v2.305.7 h1:AELPkjNR3/igjbO7CjyF1fPuVPjrblliiKj+Y6xSGOU= go.etcd.io/etcd/client/v2 v2.305.7/go.mod h1:GQGT5Z3TBuAQGvgPfhR7VPySu/SudxmEkRq9BgzFU6s= -go.etcd.io/etcd/client/v3 v3.5.15 h1:23M0eY4Fd/inNv1ZfU3AxrbbOdW79r9V9Rl62Nm6ip4= -go.etcd.io/etcd/client/v3 v3.5.15/go.mod h1:CLSJxrYjvLtHsrPKsy7LmZEE+DK2ktfd2bN4RhBMwlU= +go.etcd.io/etcd/client/v3 v3.5.7 h1:u/OhpiuCgYY8awOHlhIhmGIGpxfBU/GZBUP3m/3/Iz4= +go.etcd.io/etcd/client/v3 v3.5.7/go.mod h1:sOWmj9DZUMyAngS7QQwCyAXXAL6WhgTOPLNS/NabQgw= go.etcd.io/etcd/pkg/v3 v3.5.7 h1:obOzeVwerFwZ9trMWapU/VjDcYUJb5OfgC1zqEGWO/0= go.etcd.io/etcd/pkg/v3 v3.5.7/go.mod h1:kcOfWt3Ov9zgYdOiJ/o1Y9zFfLhQjylTgL4Lru8opRo= go.etcd.io/etcd/raft/v3 v3.5.7 h1:aN79qxLmV3SvIq84aNTliYGmjwsW6NqJSnqmI1HLJKc= go.etcd.io/etcd/raft/v3 v3.5.7/go.mod h1:TflkAb/8Uy6JFBxcRaH2Fr6Slm9mCPVdI2efzxY96yU= -go.etcd.io/etcd/server/v3 v3.5.15 h1:x35jrWnZgsRwMsFsUJIUdT1bvzIz1B+29HjMfRYVN/E= -go.etcd.io/etcd/server/v3 v3.5.15/go.mod h1:l9jX9oa/iuArjqz0RNX/TDbc70dLXxRZo/nmPucrpFo= +go.etcd.io/etcd/server/v3 v3.5.7 h1:BTBD8IJUV7YFgsczZMHhMTS67XuA4KpRquL0MFOJGRk= +go.etcd.io/etcd/server/v3 v3.5.7/go.mod h1:gxBgT84issUVBRpZ3XkW1T55NjOb4vZZRI4wVvNhf4A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0 h1:Wx7nFnvCaissIUZxPkBqDz2963Z+Cl+PkYbDKzTxDqQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0/go.mod h1:E5NNboN0UqSAki0Atn9kVwaN7I+l25gGxDqBueo/74E= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1 h1:ofMbch7i29qIUf7VtF+r0HRF6ac0SBaPSziSsKp7wkk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1/go.mod h1:Kv8liBeVNFkkkbilbgWRpV+wWuu+H5xdOT6HAgd30iw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1 h1:CFMFNoz+CGprjFAFy+RJFrfEe4GBia3RRm2a4fREvCA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1/go.mod h1:xOvWoTOrQjxjW61xtOmD/WKGRYb/P4NzRo3bs65U6Rk= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -359,12 +346,12 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v0.9.0 h1:C0g6TWmQYvjKRnljRULLWUVJGy8Uvu0NEL/5frY2/t4= go.opentelemetry.io/proto/otlp v0.9.0/go.mod h1:1vKfU9rv61e9EVGthD1zNvUbiwPcimSsOPU9brfSHJg= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -403,8 +390,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= @@ -432,8 +419,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -444,21 +431,19 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= -lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= -modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M= -modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.59.3 h1:A4QAp1lRSn2/b4aU+wBtq+yeKgq/2BUevrj0p1ZNy2M= -modernc.org/libc v1.59.3/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= -modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/sqlite v1.29.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8= +modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/pkg/ark-cli/main.go b/pkg/ark-cli/main.go index 694a72c9a..01465b10f 100644 --- a/pkg/ark-cli/main.go +++ b/pkg/ark-cli/main.go @@ -12,9 +12,10 @@ import ( arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/arkade-os/arkd/pkg/ark-lib/asset" - wallet "github.com/arkade-os/arkd/pkg/client-lib" - "github.com/arkade-os/arkd/pkg/client-lib/store" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + wallet "github.com/arkade-os/arkd/pkg/client-wallet" + "github.com/arkade-os/arkd/pkg/client-wallet/store" + "github.com/arkade-os/arkd/pkg/client-wallet/types" "github.com/urfave/cli/v2" "golang.org/x/term" ) @@ -24,8 +25,8 @@ const ( ) var ( - Version string - arkSdkClient wallet.Wallet + Version string + client wallet.Wallet ) func main() { @@ -57,7 +58,7 @@ func main() { if err != nil { return fmt.Errorf("error initializing ark sdk client: %v", err) } - arkSdkClient = sdk + client = sdk return nil } @@ -304,7 +305,7 @@ func initArkSdk(ctx *cli.Context) error { return err } - return arkSdkClient.Init( + return client.Init( ctx.Context, wallet.InitArgs{ ServerUrl: ctx.String(urlFlag.Name), Seed: ctx.String(privateKeyFlag.Name), @@ -315,7 +316,7 @@ func initArkSdk(ctx *cli.Context) error { } func config(ctx *cli.Context) error { - cfgData, err := arkSdkClient.GetConfigData(ctx.Context) + cfgData, err := client.GetConfigData(ctx.Context) if err != nil { return err } @@ -343,11 +344,11 @@ func dumpPrivKey(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } - privateKey, err := arkSdkClient.Dump(ctx.Context) + privateKey, err := client.Dump(ctx.Context) if err != nil { return err } @@ -358,7 +359,7 @@ func dumpPrivKey(ctx *cli.Context) error { } func receive(ctx *cli.Context) error { - onchainAddr, offchainAddr, boardingAddr, err := arkSdkClient.Receive(ctx.Context) + onchainAddr, offchainAddr, boardingAddr, err := client.Receive(ctx.Context) if err != nil { return err } @@ -374,11 +375,11 @@ func settle(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } - res, err := arkSdkClient.Settle(ctx.Context) + res, err := client.Settle(ctx.Context) if err != nil { return err } @@ -396,7 +397,7 @@ func send(ctx *cli.Context) error { return fmt.Errorf("missing destination, use --to and --amount or --receivers") } - var receivers []types.Receiver + var receivers []clientlib.Receiver var err error if receiversJSON != "" { // set of receivers from JSON @@ -407,17 +408,17 @@ func send(ctx *cli.Context) error { } else { // if assetId is provided we send dust+1 with the asset if len(assetId) > 0 { - cfg, err := arkSdkClient.GetConfigData(ctx.Context) + cfg, err := client.GetConfigData(ctx.Context) if err != nil { return err } - receivers = []types.Receiver{{ + receivers = []clientlib.Receiver{{ To: to, Amount: cfg.Dust + 1, - Assets: []types.Asset{{AssetId: assetId, Amount: amount}}, + Assets: []clientlib.Asset{{AssetId: assetId, Amount: amount}}, }} } else { // otherwise, we treat the amount as a bitcoin amount - receivers = []types.Receiver{{To: to, Amount: amount}} + receivers = []clientlib.Receiver{{To: to, Amount: amount}} } } @@ -425,7 +426,7 @@ func send(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } @@ -433,7 +434,7 @@ func send(ctx *cli.Context) error { } func balance(ctx *cli.Context) error { - bal, err := arkSdkClient.Balance(ctx.Context) + bal, err := client.Balance(ctx.Context) if err != nil { return err } @@ -445,7 +446,7 @@ func redeem(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } @@ -459,24 +460,28 @@ func redeem(ctx *cli.Context) error { } if force { - _, err := arkSdkClient.Unroll(ctx.Context) + _, err := client.Unroll(ctx.Context) return err } if complete { - txID, err := arkSdkClient.CompleteUnroll(ctx.Context, address) + var opts []wallet.UnrollOption + if address != "" { + opts = append(opts, wallet.WithReceiver(address)) + } + txid, err := client.CompleteUnroll(ctx.Context, opts...) if err != nil { return err } return printJSON(map[string]interface{}{ - "txid": txID, + "txid": txid, }) } if amount == 0 { return fmt.Errorf("missing amount") } - res, err := arkSdkClient.CollaborativeExit( + res, err := client.CollaborativeExit( ctx.Context, address, amount, ) if err != nil { @@ -492,11 +497,11 @@ func recoverVtxos(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } - res, err := arkSdkClient.Settle(ctx.Context) + res, err := client.Settle(ctx.Context) if err != nil { return err } @@ -512,11 +517,11 @@ func redeemNotes(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } - res, err := arkSdkClient.RedeemNotes(ctx.Context, notes) + res, err := client.RedeemNotes(ctx.Context, notes) if err != nil { return err } @@ -534,9 +539,9 @@ func issue(ctx *cli.Context) error { if amount == 0 { return errors.New("amount must be greater than zero") } - if controlAssetAmount == 0 && controlAssetId == "" { - return errors.New("missing control-asset-amount or control-asset-id") - } + // if controlAssetAmount == 0 && controlAssetId == "" { + // return errors.New("missing control-asset-amount or control-asset-id") + // } if controlAssetAmount > 0 && controlAssetId != "" { return errors.New("only one of control-asset-amount and control-asset-id can be set") } @@ -565,17 +570,20 @@ func issue(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } - controlAssetPolicy := types.ControlAsset(types.ExistingControlAsset{ID: controlAssetId}) - if controlAssetAmount > 0 { - controlAssetPolicy = types.NewControlAsset{Amount: controlAssetAmount} + var controlAsset clientlib.ControlAsset + if controlAssetAmount > 0 || len(controlAssetId) > 0 { + controlAsset = clientlib.ControlAsset(clientlib.ExistingControlAsset{Id: controlAssetId}) + if controlAssetAmount > 0 { + controlAsset = clientlib.NewControlAsset{Amount: controlAssetAmount} + } } - res, err := arkSdkClient.IssueAsset( - ctx.Context, amount, controlAssetPolicy, metadataList, + res, err := client.IssueAsset( + ctx.Context, amount, controlAsset, metadataList, ) if err != nil { return err @@ -609,11 +617,11 @@ func reissue(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } - res, err := arkSdkClient.ReissueAsset(ctx.Context, assetId, amount) + res, err := client.ReissueAsset(ctx.Context, assetId, amount) if err != nil { return err } @@ -637,11 +645,11 @@ func burn(ctx *cli.Context) error { if err != nil { return err } - if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil { + if err := client.Unlock(ctx.Context, string(password)); err != nil { return err } - res, err := arkSdkClient.BurnAsset(ctx.Context, assetId, amount) + res, err := client.BurnAsset(ctx.Context, assetId, amount) if err != nil { return err } @@ -651,7 +659,7 @@ func burn(ctx *cli.Context) error { } func listVtxos(ctx *cli.Context) error { - spendable, spent, err := arkSdkClient.ListVtxos(ctx.Context) + spendable, spent, err := client.ListVtxos(ctx.Context) if err != nil { return err } @@ -662,16 +670,13 @@ func listVtxos(ctx *cli.Context) error { } func getArkSdkClient(ctx *cli.Context) (wallet.Wallet, error) { - dataDir := ctx.String(datadirFlag.Name) - sdkRepository, err := store.NewStore(store.Config{ - ConfigStoreType: types.FileStore, - BaseDir: dataDir, - }) + datadir := ctx.String(datadirFlag.Name) + sdkRepository, err := store.NewStore(wallet.FileStore, datadir) if err != nil { return nil, err } - cfgData, err := sdkRepository.ConfigStore().GetData(context.Background()) + cfgData, err := sdkRepository.GetData(context.Background()) if err != nil { return nil, err } @@ -681,7 +686,7 @@ func getArkSdkClient(ctx *cli.Context) (wallet.Wallet, error) { return nil, fmt.Errorf("CLI not initialized, run 'init' cmd to initialize") } - opts := make([]wallet.ServiceOption, 0) + opts := make([]wallet.WalletOption, 0) if ctx.Bool(verboseFlag.Name) { opts = append(opts, wallet.WithVerbose()) } @@ -692,8 +697,8 @@ func getArkSdkClient(ctx *cli.Context) (wallet.Wallet, error) { } func loadOrCreateClient( - loadFunc, newFunc func(types.Store, ...wallet.ServiceOption) (wallet.Wallet, error), - sdkRepository types.Store, opts []wallet.ServiceOption, + loadFunc, newFunc func(types.Store, ...wallet.WalletOption) (wallet.Wallet, error), + sdkRepository types.Store, opts []wallet.WalletOption, ) (wallet.Wallet, error) { client, err := loadFunc(sdkRepository, opts...) if err != nil { @@ -716,30 +721,30 @@ type assetJSON struct { Amount uint64 `json:"amount"` } -func parseReceivers(receveirsJSON string) ([]types.Receiver, error) { +func parseReceivers(receveirsJSON string) ([]clientlib.Receiver, error) { list := make([]receiverJSON, 0) if err := json.Unmarshal([]byte(receveirsJSON), &list); err != nil { return nil, err } - receivers := make([]types.Receiver, 0, len(list)) + receivers := make([]clientlib.Receiver, 0, len(list)) for _, v := range list { - assets := make([]types.Asset, 0, len(v.Assets)) + assets := make([]clientlib.Asset, 0, len(v.Assets)) for _, asset := range v.Assets { - assets = append(assets, types.Asset{ + assets = append(assets, clientlib.Asset{ AssetId: asset.AssetID, Amount: asset.Amount, }) } - receivers = append(receivers, types.Receiver{ + receivers = append(receivers, clientlib.Receiver{ To: v.To, Amount: v.Amount, Assets: assets, }) } return receivers, nil } -func sendOffchain(ctx *cli.Context, receivers []types.Receiver) error { - var onchainReceivers, offchainReceivers []types.Receiver +func sendOffchain(ctx *cli.Context, receivers []clientlib.Receiver) error { + var onchainReceivers, offchainReceivers []clientlib.Receiver for _, receiver := range receivers { if receiver.IsOnchain() { @@ -750,7 +755,7 @@ func sendOffchain(ctx *cli.Context, receivers []types.Receiver) error { } if len(onchainReceivers) > 0 { - res, err := arkSdkClient.CollaborativeExit( + res, err := client.CollaborativeExit( ctx.Context, onchainReceivers[0].To, onchainReceivers[0].Amount, ) if err != nil { @@ -759,7 +764,7 @@ func sendOffchain(ctx *cli.Context, receivers []types.Receiver) error { return printJSON(map[string]string{"txid": res.CommitmentTxid}) } - res, err := arkSdkClient.SendOffChain(ctx.Context, offchainReceivers) + res, err := client.SendOffChain(ctx.Context, offchainReceivers) if err != nil { return err } diff --git a/pkg/ark-lib/tree/signer.go b/pkg/ark-lib/tree/signer.go new file mode 100644 index 000000000..77760d830 --- /dev/null +++ b/pkg/ark-lib/tree/signer.go @@ -0,0 +1,11 @@ +package tree + +import "github.com/btcsuite/btcd/btcec/v2" + +func NewVtxoTreeSigner() (SignerSession, error) { + key, err := btcec.NewPrivateKey() + if err != nil { + return nil, err + } + return NewTreeSignerSession(key), nil +} diff --git a/pkg/client-lib/asset.go b/pkg/client-lib/asset.go deleted file mode 100644 index 0659d8b86..000000000 --- a/pkg/client-lib/asset.go +++ /dev/null @@ -1,543 +0,0 @@ -package wallet - -import ( - "context" - "fmt" - "strings" - - "github.com/arkade-os/arkd/pkg/ark-lib/asset" - "github.com/arkade-os/arkd/pkg/ark-lib/extension" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcutil/psbt" -) - -func (a *service) IssueAsset( - ctx context.Context, amount uint64, controlAsset types.ControlAsset, - metadata []asset.Metadata, opts ...SendOption, -) (*IssueAssetRes, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - - o := newDefaultSendOptions() - for _, opt := range opts { - if err := opt.applySend(o); err != nil { - return nil, err - } - } - - if amount == 0 { - return nil, fmt.Errorf("amount must be > 0") - } - - addr, err := a.getReceiver(ctx, o.receiver) - if err != nil { - return nil, err - } - - a.txLock.Lock() - defer a.txLock.Unlock() - - receiverAsset := make([]types.Asset, 0) - if existing, ok := controlAsset.(types.ExistingControlAsset); ok { - // if the control asset is an existing one, we need to coinselect it - // thus we add it to the receiver asset list - receiverAsset = append(receiverAsset, types.Asset{ - AssetId: existing.ID, - Amount: 1, - }) - } - - receiver := types.Receiver{ - To: addr, Amount: a.Dust, - Assets: receiverAsset, - } - - // create an ark tx sending small amount of btc to wallet's address - // we'll attach new asset outputs to this vout - baseArkTx, checkpointTxs, selectedCoins, changeReceiver, err := a.createOffchainTx( - ctx, []types.Receiver{receiver}, o, - ) - if err != nil { - return nil, err - } - - arkPtx, err := psbt.NewFromRawBytes(strings.NewReader(baseArkTx), true) - if err != nil { - return nil, err - } - - assetGroups := make([]asset.AssetGroup, 0) - var assetRef *asset.AssetRef - - packet, err := createAssetPacket( - selectedCoinsToAssetInputs(selectedCoins), - []types.Receiver{receiver}, - changeReceiver, - ) - if err != nil { - return nil, err - } - - switch ca := controlAsset.(type) { - case types.NewControlAsset: - controlAssetOutput, err := asset.NewAssetOutput(0, ca.Amount) - if err != nil { - return nil, err - } - controlAssetGroup, err := asset.NewAssetGroup( - nil, - nil, - make([]asset.AssetInput, 0), - []asset.AssetOutput{*controlAssetOutput}, - metadata, - ) - if err != nil { - return nil, err - } - - assetGroups = append(assetGroups, *controlAssetGroup) - assetRef = &asset.AssetRef{ - Type: asset.AssetRefByGroup, - GroupIndex: 0, - } - case types.ExistingControlAsset: - controlAssetId, err := asset.NewAssetIdFromString(ca.ID) - if err != nil { - return nil, err - } - assetRef = &asset.AssetRef{ - Type: asset.AssetRefByID, - AssetId: *controlAssetId, - } - } - - issuedAssetOutput, err := asset.NewAssetOutput(0, amount) - if err != nil { - return nil, err - } - - issuedAssetGroup, err := asset.NewAssetGroup( - nil, - assetRef, - make([]asset.AssetInput, 0), - []asset.AssetOutput{*issuedAssetOutput}, - metadata, - ) - if err != nil { - return nil, err - } - assetGroups = append(assetGroups, *issuedAssetGroup) - - assetPacket, err := asset.NewPacket(append(assetGroups, packet...)) - if err != nil { - return nil, err - } - - if err := addExtension(arkPtx, assetPacket, o.extraPackets); err != nil { - return nil, err - } - - arkTx, err := arkPtx.B64Encode() - if err != nil { - return nil, err - } - - signedArkTx, err := a.identity.SignTransaction(ctx, arkTx, o.signingKeys) - if err != nil { - return nil, err - } - - arkTxid, signedArkTx, signedCheckpointTxs, err := a.client.SubmitTx( - ctx, signedArkTx, checkpointTxs, - ) - if err != nil { - return nil, err - } - - // validate and verify transactions returned by the server - if err := verifySignedArk(arkTx, signedArkTx, a.SignerPubKey); err != nil { - return nil, err - } - - if err := verifySignedCheckpoints(checkpointTxs, signedCheckpointTxs, a.SignerPubKey); err != nil { - return nil, err - } - - txid, checkpointTxs, err := a.finalizeTx(ctx, client.AcceptedOffchainTx{ - Txid: arkTxid, - FinalArkTx: signedArkTx, - SignedCheckpointTxs: signedCheckpointTxs, - }, o.signingKeys) - if err != nil { - return nil, err - } - - assetIds := make([]asset.AssetId, 0) - groupIdx := uint16(0) - if _, ok := controlAsset.(types.NewControlAsset); ok { - assetId, err := asset.NewAssetId(txid, groupIdx) - if err != nil { - return nil, err - } - assetIds = append(assetIds, *assetId) - groupIdx++ - } - - assetId, err := asset.NewAssetId(txid, groupIdx) - if err != nil { - return nil, err - } - assetIds = append(assetIds, *assetId) - - // Add assets info to receiver and returns as outputs together with the optional change - for groupIndex, assetGroup := range assetGroups { - // we know there's only one output per asset - output := assetGroup.Outputs[0] - //nolint - assetId, _ := asset.NewAssetId(txid, uint16(groupIndex)) - - receiver.Assets = append(receiver.Assets, types.Asset{ - AssetId: assetId.String(), - Amount: output.Amount, - }) - } - - ins := make([]types.Vtxo, 0, len(selectedCoins)) - for _, c := range selectedCoins { - ins = append(ins, c.Vtxo) - } - - outs := make([]types.Receiver, 0) - outs = append(outs, receiver) - if changeReceiver != nil { - outs = append(outs, *changeReceiver) - } - - ext := append(extension.Extension{assetPacket}, o.extraPackets...) - - return &IssueAssetRes{ - OffchainTxRes: OffchainTxRes{ - Txid: txid, - Tx: signedArkTx, - Checkpoints: checkpointTxs, - Inputs: ins, - Outputs: outs, - Extension: ext, - }, - IssuedAssets: assetIds, - }, nil -} - -func (a *service) ReissueAsset( - ctx context.Context, assetId string, amount uint64, opts ...SendOption, -) (*ReissueAssetRes, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - - o := newDefaultSendOptions() - for _, opt := range opts { - if err := opt.applySend(o); err != nil { - return nil, err - } - } - - if amount == 0 { - return nil, fmt.Errorf("amount must be > 0") - } - - controlAssetId, err := a.getControlAssetId(ctx, assetId) - if err != nil { - return nil, fmt.Errorf("failed to get control asset: %w", err) - } - - if len(controlAssetId) == 0 { - return nil, fmt.Errorf("%s can't be reissued, no control asset", assetId) - } - - addr, err := a.getReceiver(ctx, o.receiver) - if err != nil { - return nil, err - } - - a.txLock.Lock() - defer a.txLock.Unlock() - - receiver := types.Receiver{ - To: addr, Amount: a.Dust, - Assets: []types.Asset{{ - AssetId: controlAssetId, - Amount: 1, // TODO: should send all denominated amount of the asset vtxo - }}, - } - - receivers := []types.Receiver{receiver} - - // create an ark tx sending small amount of btc to wallet's address - // we'll attach new asset outputs to this vout - baseArkTx, checkpointTxs, selectedCoins, changeReceiver, err := a.createOffchainTx( - ctx, receivers, o, - ) - if err != nil { - return nil, err - } - - arkPtx, err := psbt.NewFromRawBytes(strings.NewReader(baseArkTx), true) - if err != nil { - return nil, err - } - - // create the asset packet for the local control asset inputs and receiver - assetPacket, err := createAssetPacket( - selectedCoinsToAssetInputs(selectedCoins), receivers, changeReceiver, - ) - if err != nil { - return nil, err - } - - if len(assetPacket) == 0 { - return nil, fmt.Errorf("failed to create asset packet") - } - - // add the reissued asset output to the asset packet - issuedAssetOutput, err := asset.NewAssetOutput(0, amount) - if err != nil { - return nil, err - } - - // it may be possible some assetId are already in the tx, - // thus we just need to add a new output without creating a new asset group - groupIndex := -1 - for i, g := range assetPacket { - if g.AssetId == nil { - // skip issued asset group - continue - } - - if g.AssetId.String() == assetId { - groupIndex = i - } - } - - // if group not found: add a new one - if groupIndex == -1 { - reissueAssetId, err := asset.NewAssetIdFromString(assetId) - if err != nil { - return nil, err - } - - issuedAssetGroup, err := asset.NewAssetGroup( - reissueAssetId, nil, nil, []asset.AssetOutput{*issuedAssetOutput}, nil, - ) - if err != nil { - return nil, err - } - assetPacket = append(assetPacket, *issuedAssetGroup) - } else { - // if group found: add a new output to the existing group - assetPacket[groupIndex].Outputs = append(assetPacket[groupIndex].Outputs, *issuedAssetOutput) - } - - if err := addExtension(arkPtx, assetPacket, o.extraPackets); err != nil { - return nil, err - } - - arkTx, err := arkPtx.B64Encode() - if err != nil { - return nil, err - } - - signedArkTx, err := a.identity.SignTransaction(ctx, arkTx, o.signingKeys) - if err != nil { - return nil, err - } - - arkTxid, signedArkTx, signedCheckpointTxs, err := a.client.SubmitTx( - ctx, signedArkTx, checkpointTxs, - ) - if err != nil { - return nil, err - } - - // validate and verify transactions returned by the server - if err := verifySignedArk(arkTx, signedArkTx, a.SignerPubKey); err != nil { - return nil, err - } - - if err := verifySignedCheckpoints(checkpointTxs, signedCheckpointTxs, a.SignerPubKey); err != nil { - return nil, err - } - - txid, checkpointTxs, err := a.finalizeTx(ctx, client.AcceptedOffchainTx{ - Txid: arkTxid, - FinalArkTx: signedArkTx, - SignedCheckpointTxs: signedCheckpointTxs, - }, o.signingKeys) - if err != nil { - return nil, err - } - - ins := make([]types.Vtxo, 0, len(selectedCoins)) - for _, c := range selectedCoins { - ins = append(ins, c.Vtxo) - } - - receiver.Assets = append(receiver.Assets, types.Asset{ - AssetId: assetId, - Amount: amount, - }) - - outs := make([]types.Receiver, 0) - outs = append(outs, receiver) - if changeReceiver != nil { - outs = append(outs, *changeReceiver) - } - - ext := append(extension.Extension{assetPacket}, o.extraPackets...) - - return &ReissueAssetRes{ - Txid: txid, - Tx: signedArkTx, - Checkpoints: checkpointTxs, - Inputs: ins, - Outputs: outs, - Extension: ext, - }, nil -} - -func (a *service) BurnAsset( - ctx context.Context, assetId string, amount uint64, opts ...SendOption, -) (*BurnAssetRes, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - - if amount == 0 { - return nil, fmt.Errorf("amount must be > 0") - } - - o := newDefaultSendOptions() - for _, opt := range opts { - if err := opt.applySend(o); err != nil { - return nil, err - } - } - - addr, err := a.getReceiver(ctx, o.receiver) - if err != nil { - return nil, err - } - - a.txLock.Lock() - defer a.txLock.Unlock() - - burnReceiver := types.Receiver{ - To: addr, - Amount: a.Dust, - Assets: []types.Asset{{ - AssetId: assetId, - Amount: amount, - }}, - } - - receivers := []types.Receiver{burnReceiver} - baseArkTx, checkpointTxs, selectedCoins, changeReceiver, err := a.createOffchainTx( - ctx, receivers, o, - ) - if err != nil { - return nil, err - } - - arkPtx, err := psbt.NewFromRawBytes(strings.NewReader(baseArkTx), true) - if err != nil { - return nil, err - } - - // before creating the packet, remove the asset from the receivers in order to burn it - // replace it by the change receiver assets - if changeReceiver != nil { - receivers[0].Assets = changeReceiver.Assets - receivers[0].Amount += changeReceiver.Amount - } else { - receivers[0].Assets = nil - } - - assetPacket, err := createAssetPacket( - selectedCoinsToAssetInputs(selectedCoins), receivers, nil, - ) - if err != nil { - return nil, err - } - - if err := addExtension(arkPtx, assetPacket, o.extraPackets); err != nil { - return nil, err - } - - arkTx, err := arkPtx.B64Encode() - if err != nil { - return nil, err - } - - signedArkTx, err := a.identity.SignTransaction(ctx, arkTx, o.signingKeys) - if err != nil { - return nil, err - } - - arkTxid, signedArkTx, signedCheckpointTxs, err := a.client.SubmitTx( - ctx, signedArkTx, checkpointTxs, - ) - if err != nil { - return nil, err - } - - // validate and verify transactions returned by the server - if err := verifySignedArk(arkTx, signedArkTx, a.SignerPubKey); err != nil { - return nil, err - } - - if err := verifySignedCheckpoints(checkpointTxs, signedCheckpointTxs, a.SignerPubKey); err != nil { - return nil, err - } - - txid, checkpointTxs, err := a.finalizeTx(ctx, client.AcceptedOffchainTx{ - Txid: arkTxid, - FinalArkTx: signedArkTx, - SignedCheckpointTxs: signedCheckpointTxs, - }, o.signingKeys) - if err != nil { - return nil, err - } - - ins := make([]types.Vtxo, 0, len(selectedCoins)) - for _, c := range selectedCoins { - ins = append(ins, c.Vtxo) - } - outs := []types.Receiver{ - {To: receivers[0].To, Amount: burnReceiver.Amount, Assets: receivers[0].Assets}, - } - if changeReceiver != nil { - outs = append(outs, types.Receiver{To: changeReceiver.To, Amount: changeReceiver.Amount}) - } - - ext := append(extension.Extension{assetPacket}, o.extraPackets...) - - return &BurnAssetRes{ - Txid: txid, - Tx: signedArkTx, - Checkpoints: checkpointTxs, - Inputs: ins, - Outputs: outs, - Extension: ext, - }, nil -} - -func (a *service) getControlAssetId(ctx context.Context, assetId string) (string, error) { - indexerAssetInfo, err := a.indexer.GetAsset(ctx, assetId) - if err != nil { - return "", fmt.Errorf("failed to fetch asset from indexer: %w", err) - } - - return indexerAssetInfo.ControlAssetId, nil -} diff --git a/pkg/client-lib/batch-session/batch_session.go b/pkg/client-lib/batch-session/batch_session.go new file mode 100644 index 000000000..3f51b33d3 --- /dev/null +++ b/pkg/client-lib/batch-session/batch_session.go @@ -0,0 +1,224 @@ +package batchsession + +import ( + "context" + "encoding/hex" + "fmt" + "time" + + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batcheventhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "github.com/btcsuite/btcd/btcutil/psbt" + log "github.com/sirupsen/logrus" +) + +// JoinBatch joins a batch session whose intent has already been registered +// with the server. It subscribes to the batch event stream, drives the +// per-participant signing flow (tree signing, forfeit signing, commitment-tx +// signing), and returns the finalized batch result on success. +// +// Callers normally use Settle, CollaborativeExit, or RedeemNotes — those +// orchestrators take care of registering the intent and retrying on +// transient failures before delegating to JoinBatch. +func JoinBatch(ctx context.Context, args JoinBatchArgs, opts ...Option) (*BatchTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + o := newOptions() + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, err + } + } + + handlerArgs := batcheventhandler.Args{ + Client: args.Client, + ServerInfo: args.ServerInfo, + SignTx: args.SignTx, + IntentId: args.IntentId, + Vtxos: args.Vtxos, + BoardingUtxos: args.BoardingUtxos, + Receivers: args.Outputs, + SignerSessions: args.TreeSigners, + } + + commitmentTxid, commitmentTx, batchExpiry, forfeitTxs, vtxoTree, err := handleBatchEvents( + ctx, o.handler, handlerArgs, args.Notes, o.eventsCh, o.cancelCh, + ) + if err != nil { + return nil, err + } + + // Key offchain outputs by (script, amount) and track counts so that + // receivers sharing the same script (with same or different amounts) + // are each matched to a distinct tree-leaf TxOut. The match loop below + // decrements on each hit and does NOT break, so a leaf carrying multiple + // matching outputs (Ark packs all of a participant's receivers into one + // leaf) contributes one vtxo per matching TxOut. + type outKey struct { + script string + amount int64 + } + utxoOuts := make([]clientlib.Receiver, 0, len(args.Outputs)) + indexedOutputs := make(map[outKey]int) + for _, output := range args.Outputs { + if output.IsOnchain() { + utxoOuts = append(utxoOuts, output) + continue + } + + txOut, _, err := output.ToTxOut() + if err != nil { + return nil, err + } + indexedOutputs[outKey{ + script: hex.EncodeToString(txOut.PkScript), + amount: txOut.Value, + }]++ + } + + var leaves []*psbt.Packet + if vtxoTree != nil { + leaves = vtxoTree.Leaves() + } + + now := time.Now() + vtxoOuts := make([]clientlib.Vtxo, 0, len(args.Outputs)) + for _, leaf := range leaves { + for i, out := range leaf.UnsignedTx.TxOut { + k := outKey{ + script: hex.EncodeToString(out.PkScript), + amount: out.Value, + } + if indexedOutputs[k] <= 0 { + continue + } + indexedOutputs[k]-- + + ext, _ := extension.NewExtensionFromTx(leaf.UnsignedTx) + var assets []clientlib.Asset + if len(ext) > 0 { + packet := ext.GetAssetPacket() + if len(packet) > 0 { + for _, asset := range packet { + for _, assetOut := range asset.Outputs { + if assetOut.Vout == uint16(i) { + assets = append(assets, clientlib.Asset{ + AssetId: asset.AssetId.String(), + Amount: assetOut.Amount, + }) + break + } + } + } + } + } + vtxoOuts = append(vtxoOuts, clientlib.Vtxo{ + Outpoint: clientlib.Outpoint{ + Txid: leaf.UnsignedTx.TxID(), + VOut: uint32(i), + }, + Script: hex.EncodeToString(out.PkScript), + Amount: uint64(out.Value), + CommitmentTxids: []string{commitmentTxid}, + ExpiresAt: now.Add(batchExpiry), + CreatedAt: now, + Assets: assets, + }) + } + } + + return &BatchTxRes{ + CommitmentTxid: commitmentTxid, + CommitmentTx: commitmentTx, + ForfeitTxs: forfeitTxs, + VtxoInputs: args.Vtxos, + UtxoInputs: args.BoardingUtxos, + VtxoOutputs: vtxoOuts, + UtxoOutputs: utxoOuts, + }, nil +} + +func joinBatchWithRetry( + ctx context.Context, args JoinBatchArgs, opts ...Option, +) (*BatchTxRes, error) { + o := newOptions() + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, err + } + } + + signerSessions, signerPubkeys, err := o.treeSigners() + if err != nil { + return nil, err + } + + intentArgs := IntentArgs{ + BaseArgs: args.BaseArgs, + Cosigners: signerPubkeys, + } + + deleteIntent := func() { + proof, message, err := BuildAndSignDeleteIntent(ctx, intentArgs) + if err != nil { + log.WithError(err).Warn("failed to create delete intent proof") + return + } + + err = args.Client.DeleteIntent(ctx, proof, message) + if err != nil { + log.WithError(err).Warn("failed to delete intent") + return + } + } + + maxRetry := 1 + if o.retryNum > 0 { + maxRetry = o.retryNum + } + retryCount := 0 + var batchErr error + for retryCount < maxRetry { + proofTx, message, ext, err := BuildAndSignRegisterIntent(ctx, intentArgs) + if err != nil { + return nil, err + } + + intentId, err := args.Client.RegisterIntent(ctx, proofTx, message) + if err != nil { + return nil, fmt.Errorf("failed to register intent: %w", err) + } + + log.Debugf("registered inputs and outputs with request id: %s", intentId) + + res, err := JoinBatch(ctx, JoinBatchArgs{ + BaseArgs: args.BaseArgs, + TreeSigners: signerSessions, + IntentId: intentId, + Client: args.Client, + ServerInfo: args.ServerInfo, + }, opts...) + if err != nil { + if retryCount < maxRetry-1 { + select { + case <-time.After(100 * time.Millisecond): + case <-ctx.Done(): + return nil, ctx.Err() + } + deleteIntent() + log.WithError(err).Warn("batch failed, retrying...") + } + retryCount++ + batchErr = err + continue + } + res.IntentTx = proofTx + res.Extension = ext + return res, nil + } + + return nil, fmt.Errorf("reached max attempt of retries, last batch error: %s", batchErr) +} diff --git a/pkg/client-lib/batch-session/batch_session_opts.go b/pkg/client-lib/batch-session/batch_session_opts.go new file mode 100644 index 000000000..c1748b553 --- /dev/null +++ b/pkg/client-lib/batch-session/batch_session_opts.go @@ -0,0 +1,157 @@ +package batchsession + +import ( + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" +) + +const ( + defaultExpiryThreshold int64 = 3 * 24 * 60 * 60 // 3 days + maxRetries int = 3 +) + +// Option customizes the behavior of a batch-session operation +// (Settle, CollaborativeExit, RedeemNotes, JoinBatch). Use the With* helpers +// in this package to construct instances. +type Option interface { + apply(*options) error +} + +type optFn func(*options) error + +func (f optFn) apply(o *options) error { return f(o) } + +// name alias, sub-dust vtxos are recoverable vtxos +var WithSubDustVtxos = WithRecoverableVtxos + +// WithRecoverableVtxos opts the session into spending sub-dust (recoverable) +// vtxos as inputs alongside regular vtxos. +func WithRecoverableVtxos() Option { + return optFn(func(o *options) error { + o.withRecoverableVtxos = true + return nil + }) +} + +// WithEventsCh registers a channel that receives a copy of every batch event +// observed by the session handler. Useful for tests or for surfacing +// progress to the caller. Can only be set once per session. +func WithEventsCh(ch chan<- any) Option { + return optFn(func(o *options) error { + if o.eventsCh != nil { + return fmt.Errorf("events channel already set") + } + o.eventsCh = ch + return nil + }) +} + +// WithoutTreeSigner disables the tree signer for the batch session +func WithoutTreeSigner() Option { + return optFn(func(o *options) error { + o.treeSignerDisabled = true + return nil + }) +} + +// WithExtraSigner allows to use a set of custom signer for the vtxo tree signing process +func WithExtraSigner(signerSessions ...tree.SignerSession) Option { + return optFn(func(o *options) error { + if len(signerSessions) == 0 { + return fmt.Errorf("no signer sessions provided") + } + o.extraSignerSessions = signerSessions + return nil + }) +} + +// WithCancelCh allows to cancel the settlement process +func WithCancelCh(ch <-chan struct{}) Option { + return optFn(func(o *options) error { + o.cancelCh = ch + return nil + }) +} + +// WithExpiryThreshold overrides the default vtxo-expiry filter (in seconds): +// vtxos expiring later than the threshold are excluded from coin selection. +func WithExpiryThreshold(threshold int64) Option { + return optFn(func(o *options) error { + o.expiryThreshold = threshold + return nil + }) +} + +// WithRetries sets the maximum number of attempts to join a batch on transient +// failures. Must be in the range [1, maxRetries]. Can only be set once. +func WithRetries(num int) Option { + return optFn(func(o *options) error { + if o.retryNum > 0 { + return fmt.Errorf("retry num already set") + } + if num <= 0 || num > maxRetries { + return fmt.Errorf("retry num must be in range [1, %d]", maxRetries) + } + o.retryNum = num + return nil + }) +} + +// WithHandler allows to make use of a custom batch-event handler in place of the default one. +// Handler cannot be nil and can only be set once per session. +func WithHandler(handler batchsessionhandler.Handler) Option { + return optFn(func(o *options) error { + if handler == nil { + return fmt.Errorf("handler cannot be nil") + } + if o.handler != nil { + return fmt.Errorf("handler already set") + } + o.handler = handler + return nil + }) +} + +// options allows to customize the vtxo signing process +type options struct { + extraSignerSessions []tree.SignerSession + treeSignerDisabled bool + withRecoverableVtxos bool + expiryThreshold int64 // In seconds + retryNum int + handler batchsessionhandler.Handler + + cancelCh <-chan struct{} + eventsCh chan<- any +} + +func newOptions() *options { + return &options{ + expiryThreshold: defaultExpiryThreshold, + } +} + +func (o options) treeSigners() ([]tree.SignerSession, []string, error) { + sessions := make([]tree.SignerSession, 0) + if !o.treeSignerDisabled { + signerSession, err := tree.NewVtxoTreeSigner() + if err != nil { + return nil, nil, err + } + sessions = append(sessions, signerSession) + } + sessions = append(sessions, o.extraSignerSessions...) + + if len(sessions) <= 0 { + return nil, nil, fmt.Errorf("no signer sessions") + } + + signerPubKeys := make([]string, 0) + for _, session := range sessions { + signerPubKeys = append(signerPubKeys, session.GetPublicKey()) + } + + return sessions, signerPubKeys, nil +} diff --git a/pkg/client-lib/batch-session/batch_session_opts_test.go b/pkg/client-lib/batch-session/batch_session_opts_test.go new file mode 100644 index 000000000..7f604f3ca --- /dev/null +++ b/pkg/client-lib/batch-session/batch_session_opts_test.go @@ -0,0 +1,60 @@ +package batchsession + +import ( + "testing" + + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + "github.com/stretchr/testify/require" +) + +func TestWithEventsCh(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + t.Run("applied twice", func(t *testing.T) { + opts := newOptions() + ch := make(chan any, 1) + require.NoError(t, WithEventsCh(ch).apply(opts)) + + err := WithEventsCh(ch).apply(opts) + require.Error(t, err) + require.Contains(t, err.Error(), "events channel already set") + }) + }) +} + +func TestWithExtraSigner(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + t.Run("no signer sessions", func(t *testing.T) { + opts := newOptions() + err := WithExtraSigner([]tree.SignerSession{}...).apply(opts) + require.Error(t, err) + require.Contains(t, err.Error(), "no signer sessions provided") + }) + }) +} + +func TestWithRetries(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + t.Run("applied twice", func(t *testing.T) { + opts := newOptions() + require.NoError(t, WithRetries(1).apply(opts)) + + err := WithRetries(1).apply(opts) + require.Error(t, err) + require.Contains(t, err.Error(), "retry num already set") + }) + + t.Run("zero or negative", func(t *testing.T) { + opts := newOptions() + err := WithRetries(0).apply(opts) + require.Error(t, err) + require.Contains(t, err.Error(), "retry num must be in range [1, 3]") + }) + + t.Run("above max", func(t *testing.T) { + opts := newOptions() + err := WithRetries(4).apply(opts) + require.Error(t, err) + require.Contains(t, err.Error(), "retry num must be in range [1, 3]") + }) + }) +} diff --git a/pkg/client-lib/batch-session/batch_session_test.go b/pkg/client-lib/batch-session/batch_session_test.go new file mode 100644 index 000000000..eb3381a10 --- /dev/null +++ b/pkg/client-lib/batch-session/batch_session_test.go @@ -0,0 +1,84 @@ +package batchsession + +import ( + "context" + "testing" + + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +func TestJoinBatch(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*JoinBatchArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *JoinBatchArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing funds", + mutate: func(a *JoinBatchArgs) { + a.Vtxos = nil + a.BoardingUtxos = nil + a.Notes = nil + }, + errSubstr: "missing funds to join a batch", + }, + { + name: "missing outputs", + mutate: func(a *JoinBatchArgs) { a.Outputs = nil }, + errSubstr: "missing outputs", + }, + { + name: "missing intent id", + mutate: func(a *JoinBatchArgs) { a.IntentId = "" }, + errSubstr: "missing intent id", + }, + { + name: "missing tree signers", + mutate: func(a *JoinBatchArgs) { a.TreeSigners = nil }, + errSubstr: "missing tree signer(s)", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestJoinBatchArgs(t) + tc.mutate(&args) + + _, err := JoinBatch(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +// newTestJoinBatchArgs returns a valid baseline JoinBatchArgs. Tests in this +// file mutate a single field on the returned value to exercise the +// corresponding validation error. +func newTestJoinBatchArgs(t *testing.T) JoinBatchArgs { + t.Helper() + signer, err := tree.NewVtxoTreeSigner() + require.NoError(t, err) + return JoinBatchArgs{ + BaseArgs: BaseArgs{ + Vtxos: []clientlib.Vtxo{{ + Outpoint: clientlib.Outpoint{Txid: "deadbeef", VOut: 0}, + Amount: 10000, + }}, + Outputs: []clientlib.Receiver{{To: "tark1qexample", Amount: 10000}}, + SignTx: clientlib.SignFn(mockSignTx), + }, + Client: mockClient{}, + ServerInfo: clientlib.Info{Network: "regtest", Dust: 1000}, + IntentId: "test-intent-id", + TreeSigners: []tree.SignerSession{signer}, + } +} diff --git a/pkg/client-lib/batch-session/batch_session_types.go b/pkg/client-lib/batch-session/batch_session_types.go new file mode 100644 index 000000000..6dadbfa56 --- /dev/null +++ b/pkg/client-lib/batch-session/batch_session_types.go @@ -0,0 +1,130 @@ +package batchsession + +import ( + "fmt" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + "github.com/arkade-os/arkd/pkg/ark-lib/intent" + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcutil/psbt" +) + +// BatchTxRes is the result of a completed batch session. CommitmentTxid and +// CommitmentTx identify the on-chain commitment transaction the server +// broadcasts; IntentTx is the signed intent proof PSBT that registered this +// participant; ForfeitTxs are the signed forfeit transactions for spent vtxos; +// VtxoInputs / UtxoInputs record the consumed inputs and VtxoOutputs / +// UtxoOutputs the produced offchain vtxos and on-chain receivers. Extension +// carries any asset packet attached to the batch. +type BatchTxRes struct { + CommitmentTxid string + CommitmentTx string + IntentTx string + ForfeitTxs []string + VtxoInputs []clientlib.Vtxo + UtxoInputs []clientlib.Utxo + VtxoOutputs []clientlib.Vtxo + UtxoOutputs []clientlib.Receiver + Extension extension.Extension +} + +// JoinBatchArgs configures a JoinBatch call: the funds to consume +// (Notes/Vtxos/BoardingUtxos from BaseArgs), the desired Outputs, the SignTx +// callback used to sign the intent proof and ark-side artifacts, plus the +// Client used to talk to the server and the cached ServerInfo. +type JoinBatchArgs struct { + BaseArgs + TreeSigners []tree.SignerSession + IntentId string + Client clientlib.Client + ServerInfo clientlib.Info +} + +func (a JoinBatchArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + if len(a.Notes) <= 0 && len(a.Vtxos) <= 0 && len(a.BoardingUtxos) <= 0 { + return fmt.Errorf("missing funds to join a batch") + } + if len(a.Outputs) <= 0 { + return fmt.Errorf("missing outputs") + } + if a.IntentId == "" { + return fmt.Errorf("missing intent id") + } + if a.signingRequired() && len(a.TreeSigners) <= 0 { + return fmt.Errorf("missing tree signer(s)") + } + return nil +} + +// IntentArgs configures the BuildAndSign*Intent primitives (Register, Delete, +// GetPendingTx). Cosigners holds the public keys of vtxo-tree signer sessions +// and is only required when registering an intent that will participate in +// tree signing. +type IntentArgs struct { + BaseArgs + Cosigners []string +} + +func (a IntentArgs) validateForRegister() error { + if len(a.Vtxos)+len(a.BoardingUtxos)+len(a.Notes) <= 0 { + return fmt.Errorf("missing funds") + } + if len(a.Outputs) <= 0 { + return fmt.Errorf("missing outputs") + } + if len(a.Cosigners) <= 0 { + return fmt.Errorf("missing cosigners") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx") + } + return nil +} + +func (a IntentArgs) validateForDelete() error { + if len(a.Vtxos)+len(a.BoardingUtxos)+len(a.Notes) <= 0 { + return fmt.Errorf("missing funds") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx") + } + return nil +} + +func (a IntentArgs) validateForGetPendingTx() error { + if len(a.Vtxos) <= 0 { + return fmt.Errorf("missing funds") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx") + } + return nil +} + +func (a IntentArgs) intentInputs() ( + intentInputs []intent.Input, assetInputs map[int][]clientlib.Asset, + leafProofs []*arklib.TaprootMerkleProof, psbtFields [][]*psbt.Unknown, err error, +) { + return toIntentInputs(a.BoardingUtxos, a.Vtxos, a.Notes) +} + +// BaseArgs groups the inputs and outputs common to all batch-session operations +// (Settle, CollaborativeExit, RedeemNotes, JoinBatch) along with the SignTx +// callback used to sign the intent proof PSBT and any forfeit / commitment +// artifacts produced during the batch. +type BaseArgs struct { + Notes []string + Vtxos []clientlib.Vtxo + BoardingUtxos []clientlib.Utxo + Outputs []clientlib.Receiver + SignTx clientlib.SignFn +} + +func (a BaseArgs) signingRequired() bool { + return len(a.Vtxos)+len(a.BoardingUtxos) > 0 +} diff --git a/pkg/client-lib/batch-session/collaborative_exit.go b/pkg/client-lib/batch-session/collaborative_exit.go new file mode 100644 index 000000000..86926f06d --- /dev/null +++ b/pkg/client-lib/batch-session/collaborative_exit.go @@ -0,0 +1,94 @@ +package batchsession + +import ( + "context" + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcutil" +) + +// CollaborativeExitArgs configures a CollaborativeExit call: the Vtxos to spend +// and the on-chain Receiver to credit. FeeEstimator is used to size the on-chain +// output, SignTx signs the intent proof, and ServerInfo/Client are used to talk +// to the server. +type CollaborativeExitArgs struct { + Client clientlib.Client + ServerInfo clientlib.Info + SignTx clientlib.SignFn + Vtxos []clientlib.Vtxo + Receiver clientlib.Receiver + ChangeAddr string +} + +func (a CollaborativeExitArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx function") + } + if len(a.Vtxos) <= 0 { + return fmt.Errorf("missing funds for collaborative exit") + } + if len(a.Receiver.To) <= 0 { + return fmt.Errorf("missing receiver address") + } + if len(a.ServerInfo.Network) <= 0 || a.ServerInfo.Dust == 0 { + return fmt.Errorf("missing server info") + } + if a.Receiver.Amount < a.ServerInfo.Dust { + return fmt.Errorf("invalid receiver amount, must be at least %d", a.ServerInfo.Dust) + } + netParams := clientlib.ToBitcoinNetwork(clientlib.NetworkFromString(a.ServerInfo.Network)) + if _, err := btcutil.DecodeAddress(a.Receiver.To, &netParams); err != nil { + return fmt.Errorf("invalid receiver address") + } + if len(a.ChangeAddr) <= 0 { + return fmt.Errorf("missing change address") + } + return nil +} + +// CollaborativeExit performs the full lifecycle of an on-chain exit through a +// batch session: selects vtxos to fund the on-chain output, then builds, +// signs, submits, handles batch events, and finalizes the resulting commitment +// transaction via JoinBatch. +func CollaborativeExit( + ctx context.Context, args CollaborativeExitArgs, opts ...Option, +) (*BatchTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + o := newOptions() + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, err + } + } + + feeEstimator, err := arkfee.New(args.ServerInfo.Fees.IntentFees) + if err != nil { + return nil, err + } + + vtxos, _, outputs, err := selectFunds( + ctx, feeEstimator, args.Vtxos, nil, []clientlib.Receiver{args.Receiver}, + args.ChangeAddr, o.expiryThreshold, args.ServerInfo.Dust, + ) + if err != nil { + return nil, err + } + + return joinBatchWithRetry(ctx, JoinBatchArgs{ + BaseArgs: BaseArgs{ + Vtxos: vtxos, + Outputs: outputs, + SignTx: args.SignTx, + }, + Client: args.Client, + ServerInfo: args.ServerInfo, + }, opts...) +} diff --git a/pkg/client-lib/batch-session/collaborative_exit_test.go b/pkg/client-lib/batch-session/collaborative_exit_test.go new file mode 100644 index 000000000..64dcbd5fa --- /dev/null +++ b/pkg/client-lib/batch-session/collaborative_exit_test.go @@ -0,0 +1,79 @@ +package batchsession + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +func TestCollaborativeExit(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*CollaborativeExitArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *CollaborativeExitArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing sign tx", + mutate: func(a *CollaborativeExitArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx function", + }, + { + name: "missing funds", + mutate: func(a *CollaborativeExitArgs) { a.Vtxos = nil }, + errSubstr: "missing funds for collaborative exit", + }, + { + name: "missing receiver address", + mutate: func(a *CollaborativeExitArgs) { a.Receiver.To = "" }, + errSubstr: "missing receiver address", + }, + { + name: "missing server info", + mutate: func(a *CollaborativeExitArgs) { a.ServerInfo.Network = "" }, + errSubstr: "missing server info", + }, + { + name: "invalid receiver address", + mutate: func(a *CollaborativeExitArgs) { a.Receiver.To = "not-a-real-address" }, + errSubstr: "invalid receiver address", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestCollaborativeExitArgs(t) + tc.mutate(&args) + + _, err := CollaborativeExit(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +// newTestCollaborativeExitArgs returns a valid baseline CollaborativeExitArgs. +// Tests in this file mutate a single field on the returned value to exercise +// the corresponding validation error. +func newTestCollaborativeExitArgs(t *testing.T) CollaborativeExitArgs { + t.Helper() + + return CollaborativeExitArgs{ + Client: mockClient{}, + ServerInfo: clientlib.Info{Dust: 1000, Network: "regtest"}, + SignTx: clientlib.SignFn(mockSignTx), + Vtxos: []clientlib.Vtxo{{ + Outpoint: clientlib.Outpoint{Txid: "deadbeef", VOut: 0}, + Amount: 10000, + }}, + Receiver: clientlib.Receiver{To: testAddr, Amount: 10000}, + } +} diff --git a/pkg/client-lib/batch-session/handler/default_handler.go b/pkg/client-lib/batch-session/handler/default_handler.go new file mode 100644 index 000000000..488d3e096 --- /dev/null +++ b/pkg/client-lib/batch-session/handler/default_handler.go @@ -0,0 +1,645 @@ +package batchsessionhandler + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "slices" + "strings" + "sync" + "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/ark-lib/tree" + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + log "github.com/sirupsen/logrus" +) + +type Args struct { + Client clientlib.Client + ServerInfo clientlib.Info + SignTx clientlib.SignFn + + IntentId string + Vtxos []clientlib.Vtxo + BoardingUtxos []clientlib.Utxo + Receivers []clientlib.Receiver + SignerSessions []tree.SignerSession + + vtxosToSign []clientlib.Vtxo + forfeitPubkey *btcec.PublicKey + forfeitAddress string + network arklib.Network +} + +func (a *Args) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx function") + } + if len(a.ServerInfo.Network) <= 0 || len(a.ServerInfo.ForfeitPubKey) <= 0 || + len(a.ServerInfo.ForfeitAddress) <= 0 { + return fmt.Errorf("missing server info") + } + if len(a.IntentId) <= 0 { + return fmt.Errorf("missing intent id") + } + if len(a.Receivers) <= 0 { + return fmt.Errorf("missing receivers") + } + + buf, err := hex.DecodeString(a.ServerInfo.ForfeitPubKey) + if err != nil { + return fmt.Errorf( + "expected hex format for forfeit pubkey, got %s", a.ServerInfo.ForfeitPubKey, + ) + } + pubkey, err := btcec.ParsePubKey(buf) + if err != nil { + return fmt.Errorf("failed to parse forfeit pubkey: %w", err) + } + + a.forfeitPubkey = pubkey + + vtxosToSign := make([]clientlib.Vtxo, 0, len(a.Vtxos)) + for _, vtxo := range a.Vtxos { + // exclude recoverable vtxos as they don't need any signing step + if vtxo.IsRecoverable() { + continue + } + vtxosToSign = append(vtxosToSign, vtxo) + } + a.vtxosToSign = vtxosToSign + a.network = clientlib.NetworkFromString(a.ServerInfo.Network) + + return nil +} + +type defaultHandler struct { + Args + + batchSessionId string + batchExpiry arklib.RelativeLocktime + // internal count to handle TreeNoncesEvent + countSigningDone int +} + +func NewDefaultHandler(args Args) (Handler, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + return &defaultHandler{Args: args}, nil +} + +func (h *defaultHandler) OnStreamStarted( + ctx context.Context, event clientlib.StreamStartedEvent, +) error { + return nil +} + +func (h *defaultHandler) OnBatchStarted( + ctx context.Context, event clientlib.BatchStartedEvent, +) (bool, time.Duration, error) { + buf := sha256.Sum256([]byte(h.IntentId)) + hashedIntentId := hex.EncodeToString(buf[:]) + + for _, hash := range event.HashedIntentIds { + if hash == hashedIntentId { + if err := h.Client.ConfirmRegistration(ctx, h.IntentId); err != nil { + return false, -1, err + } + h.batchSessionId = event.Id + h.batchExpiry = getBatchExpiryLocktime(uint32(event.BatchExpiry)) + expiry := time.Duration(event.BatchExpiry) * time.Second + if h.batchExpiry.Type == arklib.LocktimeTypeBlock { + expiry = time.Duration(event.BatchExpiry*arklib.SECONDS_PER_BLOCK) * time.Second + } + return false, expiry, nil + } + } + log.Debug("intent id not found in batch proposal, waiting for next one...") + return true, -1, nil +} + +func (h *defaultHandler) OnBatchFinalized( + ctx context.Context, event clientlib.BatchFinalizedEvent, +) error { + if event.Id == h.batchSessionId { + log.Debugf("batch completed in commitment tx %s", event.Txid) + } + return nil +} + +func (h *defaultHandler) OnBatchFailed( + ctx context.Context, event clientlib.BatchFailedEvent, +) error { + return fmt.Errorf("batch failed: %s", event.Reason) +} + +func (h *defaultHandler) OnTreeTxEvent( + ctx context.Context, event clientlib.TreeTxEvent, +) error { + return nil +} + +func (h *defaultHandler) OnTreeSignatureEvent( + ctx context.Context, event clientlib.TreeSignatureEvent, +) error { + return nil +} + +func (h *defaultHandler) OnTreeSigningStarted( + ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree, +) (bool, error) { + foundPubkeys := make([]string, 0, len(h.SignerSessions)) + for _, session := range h.SignerSessions { + myPubkey := session.GetPublicKey() + if slices.Contains(event.CosignersPubkeys, myPubkey) { + foundPubkeys = append(foundPubkeys, myPubkey) + } + } + + if len(foundPubkeys) <= 0 { + log.Debug("no signer found in cosigner list, waiting for next one...") + return true, nil + } + + if len(foundPubkeys) != len(h.SignerSessions) { + return false, fmt.Errorf("not all signers found in cosigner list") + } + + sweepClosure := script.CSVMultisigClosure{ + MultisigClosure: script.MultisigClosure{PubKeys: []*btcec.PublicKey{h.forfeitPubkey}}, + Locktime: h.batchExpiry, + } + + script, err := sweepClosure.Script() + if err != nil { + return false, err + } + + commitmentTx, err := psbt.NewFromRawBytes(strings.NewReader(event.UnsignedCommitmentTx), true) + if err != nil { + return false, err + } + + batchOutput := commitmentTx.UnsignedTx.TxOut[0] + batchOutputAmount := batchOutput.Value + + sweepTapLeaf := txscript.NewBaseTapLeaf(script) + sweepTapTree := txscript.AssembleTaprootScriptTree(sweepTapLeaf) + root := sweepTapTree.RootNode.TapHash() + + generateAndSendNonces := func(session tree.SignerSession) error { + if err := session.Init(root.CloneBytes(), batchOutputAmount, vtxoTree); err != nil { + return err + } + + nonces, err := session.GetNonces() + if err != nil { + return err + } + + return h.Client.SubmitTreeNonces(ctx, event.Id, session.GetPublicKey(), nonces) + } + + errChan := make(chan error, len(h.SignerSessions)) + waitGroup := sync.WaitGroup{} + waitGroup.Add(len(h.SignerSessions)) + + for _, session := range h.SignerSessions { + go func(session tree.SignerSession) { + defer waitGroup.Done() + if err := generateAndSendNonces(session); err != nil { + errChan <- err + } + }(session) + } + + waitGroup.Wait() + + close(errChan) + + for err := range errChan { + if err != nil { + return false, err + } + } + + return false, nil +} + +func (h *defaultHandler) OnTreeNonces( + ctx context.Context, event clientlib.TreeNoncesEvent, +) (bool, error) { + log.Debugf("tree nonces event received for tx %s", event.Txid) + if len(h.SignerSessions) <= 0 { + return false, fmt.Errorf("tree signer session not set") + } + + handler := func(session tree.SignerSession) (bool, error) { + hasAllNonces, err := session.AggregateNonces(event.Txid, event.Nonces) + if err != nil { + return false, err + } + + if !hasAllNonces { + return false, nil + } + + log.Debugf("all nonces aggregated, signing...") + sigs, err := session.Sign() + if err != nil { + return false, err + } + + if err := h.Client.SubmitTreeSignatures( + ctx, + event.Id, + session.GetPublicKey(), + sigs, + ); err != nil { + return false, err + } + + return true, nil + } + + type res struct { + signed bool + err error + } + + resChan := make(chan res, len(h.SignerSessions)) + waitGroup := sync.WaitGroup{} + waitGroup.Add(len(h.SignerSessions)) + + for _, session := range h.SignerSessions { + go func(session tree.SignerSession) { + defer waitGroup.Done() + signed, err := handler(session) + resChan <- res{signed, err} + }(session) + } + + waitGroup.Wait() + close(resChan) + + // Drain the full channel and tally the signed count locally; commit the + // increment to h.countSigningDone only if no signer errored. This keeps + // the handler in a consistent state even if the same instance is reused + // across retry attempts (e.g. via WithHandler) — partial successes on a + // failed attempt no longer leak into the next attempt's count. + signedCount := 0 + var firstErr error + for res := range resChan { + if res.err != nil { + if firstErr == nil { + firstErr = res.err + } + continue + } + if res.signed { + signedCount++ + } + } + if firstErr != nil { + return false, firstErr + } + + h.countSigningDone += signedCount + if h.countSigningDone == len(h.SignerSessions) { + return true, nil + } + return false, nil +} + +func (h *defaultHandler) OnTreeNoncesAggregated( + ctx context.Context, event clientlib.TreeNoncesAggregatedEvent, +) (bool, error) { + // ignore TreeNoncesAggregatedEvent as we handle it in OnTreeNoncesEvent + return false, nil +} + +func (h *defaultHandler) OnBatchFinalization( + ctx context.Context, event clientlib.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree, +) ([]string, error) { + log.Debug("vtxo and connector trees fully signed, sending forfeit transactions...") + if err := h.validateVtxoTree(event, vtxoTree, connectorTree); err != nil { + return nil, fmt.Errorf("failed to verify vtxo tree: %s", err) + } + + var forfeitTxs []string + var signedCommitmentTx string + + vtxos := h.vtxosToForfeit() + + // If vtxos are refreshed, we must create and sign forfeit txs. + if len(vtxos) > 0 && connectorTree != nil { + signedForfeitTxs, err := h.createAndSignForfeits( + ctx, vtxos, connectorTree.Leaves(), + ) + if err != nil { + return nil, err + } + + forfeitTxs = signedForfeitTxs + } + + // If boarding utxos are settled, we must sign the commitment transaction. + if len(h.BoardingUtxos) > 0 { + commitmentPtx, err := psbt.NewFromRawBytes(strings.NewReader(event.Tx), true) + if err != nil { + return nil, err + } + + for _, boardingUtxo := range h.BoardingUtxos { + boardingVtxoScript, err := script.ParseVtxoScript(boardingUtxo.Tapscripts) + if err != nil { + return nil, err + } + + forfeitClosures := boardingVtxoScript.ForfeitClosures() + if len(forfeitClosures) <= 0 { + return nil, fmt.Errorf("no forfeit closures found") + } + + forfeitClosure := forfeitClosures[0] + + forfeitScript, err := forfeitClosure.Script() + if err != nil { + return nil, err + } + + _, taprootTree, err := boardingVtxoScript.TapTree() + if err != nil { + return nil, err + } + + forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) + forfeitProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) + if err != nil { + return nil, fmt.Errorf( + "failed to get taproot merkle proof for boarding utxo: %s", err, + ) + } + + tapscript := &psbt.TaprootTapLeafScript{ + ControlBlock: forfeitProof.ControlBlock, + Script: forfeitProof.Script, + LeafVersion: txscript.BaseLeafVersion, + } + + for i := range commitmentPtx.Inputs { + prevout := commitmentPtx.UnsignedTx.TxIn[i].PreviousOutPoint + + if boardingUtxo.Txid == prevout.Hash.String() && + boardingUtxo.VOut == prevout.Index { + commitmentPtx.Inputs[i].TaprootLeafScript = []*psbt.TaprootTapLeafScript{ + tapscript, + } + break + } + } + } + + b64, err := commitmentPtx.B64Encode() + if err != nil { + return nil, err + } + + signedCommitmentTx, err = h.SignTx(ctx, b64) + if err != nil { + return nil, err + } + } + + if len(forfeitTxs) > 0 || len(signedCommitmentTx) > 0 { + if err := h.Client.SubmitSignedForfeitTxs( + ctx, forfeitTxs, signedCommitmentTx, + ); err != nil { + return nil, err + } + } + + return forfeitTxs, nil +} + +func (h *defaultHandler) vtxosToForfeit() []clientlib.Vtxo { + withoutRecoverable := make([]clientlib.Vtxo, 0, len(h.Vtxos)) + for _, vtxo := range h.Vtxos { + if !vtxo.IsRecoverable() { + withoutRecoverable = append(withoutRecoverable, vtxo) + } + } + + return withoutRecoverable +} + +func (h *defaultHandler) validateVtxoTree( + event clientlib.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree, +) error { + commitmentTx := event.Tx + commitmentPtx, err := psbt.NewFromRawBytes(strings.NewReader(commitmentTx), true) + if err != nil { + return err + } + + // validate the vtxo tree is well formed + if !isOnchainOnly(h.Receivers) { + if err := tree.ValidateVtxoTree( + vtxoTree, commitmentPtx, h.forfeitPubkey, h.batchExpiry, + ); err != nil { + return err + } + + rootParentTxid := vtxoTree.Root.UnsignedTx.TxIn[0].PreviousOutPoint.Hash.String() + rootParentVout := vtxoTree.Root.UnsignedTx.TxIn[0].PreviousOutPoint.Index + + if rootParentTxid != commitmentPtx.UnsignedTx.TxID() { + return fmt.Errorf( + "root's parent txid is not the same as the commitment txid: %s != %s", + rootParentTxid, + commitmentPtx.UnsignedTx.TxID(), + ) + } + + if rootParentVout != 0 { + return fmt.Errorf( + "root's parent vout is not the same as the shared output index: %d != %d", + rootParentVout, + 0, + ) + } + } + + // validate it contains our outputs + if err := validateReceivers(h.network, commitmentPtx, h.Receivers, vtxoTree); err != nil { + return err + } + + vtxos := h.vtxosToForfeit() + + if len(vtxos) > 0 { + if connectorTree != nil { + if err := connectorTree.Validate(); err != nil { + return err + } + } + + if connectorTree != nil { + connectorsLeaves := connectorTree.Leaves() + if len(connectorsLeaves) != len(vtxos) { + return fmt.Errorf( + "unexpected num of connectors received: expected %d, got %d", + len(vtxos), + len(connectorsLeaves), + ) + } + } + } + + return nil +} + +func (h *defaultHandler) createAndSignForfeits( + ctx context.Context, vtxosToSign []clientlib.Vtxo, connectorsLeaves []*psbt.Packet, +) ([]string, error) { + network := clientlib.ToBitcoinNetwork(h.network) + parsedForfeitAddr, err := btcutil.DecodeAddress(h.ServerInfo.ForfeitAddress, &network) + if err != nil { + return nil, err + } + + forfeitPkScript, err := txscript.PayToAddrScript(parsedForfeitAddr) + if err != nil { + return nil, err + } + + signedForfeitTxs := make([]string, 0, len(vtxosToSign)) + for i, vtxo := range vtxosToSign { + connectorTx := connectorsLeaves[i] + + var connector *wire.TxOut + var connectorOutpoint *wire.OutPoint + for outIndex, output := range connectorTx.UnsignedTx.TxOut { + if bytes.Equal(txutils.ANCHOR_PKSCRIPT, output.PkScript) { + continue + } + + connector = output + connectorOutpoint = &wire.OutPoint{ + Hash: connectorTx.UnsignedTx.TxHash(), + Index: uint32(outIndex), + } + break + } + + if connector == nil { + return nil, fmt.Errorf("connector not found for vtxo %s", vtxo.Outpoint.String()) + } + + vtxoScript, err := script.ParseVtxoScript(vtxo.Tapscripts) + if err != nil { + return nil, err + } + + vtxoTapKey, vtxoTapTree, err := vtxoScript.TapTree() + if err != nil { + return nil, err + } + + vtxoOutputScript, err := script.P2TRScript(vtxoTapKey) + if err != nil { + return nil, err + } + + vtxoTxHash, err := chainhash.NewHashFromStr(vtxo.Txid) + if err != nil { + return nil, err + } + + vtxoInput := &wire.OutPoint{ + Hash: *vtxoTxHash, + Index: vtxo.VOut, + } + + forfeitClosures := vtxoScript.ForfeitClosures() + if len(forfeitClosures) <= 0 { + return nil, fmt.Errorf("no forfeit closures found") + } + + forfeitClosure := forfeitClosures[0] + + forfeitScript, err := forfeitClosure.Script() + if err != nil { + return nil, err + } + + forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) + leafProof, err := vtxoTapTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) + if err != nil { + return nil, err + } + + tapscript := psbt.TaprootTapLeafScript{ + ControlBlock: leafProof.ControlBlock, + Script: leafProof.Script, + LeafVersion: txscript.BaseLeafVersion, + } + + vtxoLocktime := arklib.AbsoluteLocktime(0) + if cltv, ok := forfeitClosure.(*script.CLTVMultisigClosure); ok { + vtxoLocktime = cltv.Locktime + } + + vtxoPrevout := &wire.TxOut{ + Value: int64(vtxo.Amount), + PkScript: vtxoOutputScript, + } + + vtxoSequence := wire.MaxTxInSequenceNum + if vtxoLocktime != 0 { + vtxoSequence = wire.MaxTxInSequenceNum - 1 + } + + forfeitTx, err := tree.BuildForfeitTx( + []*wire.OutPoint{vtxoInput, connectorOutpoint}, + []uint32{vtxoSequence, wire.MaxTxInSequenceNum}, + []*wire.TxOut{vtxoPrevout, connector}, + forfeitPkScript, + uint32(vtxoLocktime), + ) + if err != nil { + return nil, err + } + + forfeitTx.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{&tapscript} + + b64, err := forfeitTx.B64Encode() + if err != nil { + return nil, err + } + + signedForfeitTx, err := h.SignTx(ctx, b64) + if err != nil { + return nil, err + } + + signedForfeitTxs = append(signedForfeitTxs, signedForfeitTx) + } + + return signedForfeitTxs, nil +} diff --git a/pkg/client-lib/batch-session/handler/handler.go b/pkg/client-lib/batch-session/handler/handler.go new file mode 100644 index 000000000..5927fd31b --- /dev/null +++ b/pkg/client-lib/batch-session/handler/handler.go @@ -0,0 +1,228 @@ +package batchsessionhandler + +import ( + "context" + "fmt" + "time" + + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + log "github.com/sirupsen/logrus" +) + +const ( + start = iota + batchStarted + treeSigningStarted + treeNoncesAggregated + batchFinalization +) + +func JoinBatchSession( + ctx context.Context, eventsCh <-chan clientlib.BatchEventChannel, + eventsHandler Handler, opts ...HandlerOption, +) (string, string, time.Duration, []string, *tree.TxTree, error) { + options := newOptions() + + for _, opt := range opts { + opt(options) + } + + step := start + + // the txs of the tree are received one after the other via TxTreeEvent + // we collect them and then build the tree when necessary. + flatVtxoTree := make([]tree.TxTreeNode, 0) + flatConnectorTree := make([]tree.TxTreeNode, 0) + + var vtxoTree, connectorTree *tree.TxTree + var forfeitTxs []string + var batchExpiry time.Duration + var commitmentTx string + for { + select { + case <-options.cancelCh: + return "", "", -1, nil, nil, fmt.Errorf("canceled") + case <-ctx.Done(): + return "", "", -1, nil, nil, fmt.Errorf("context done %s", ctx.Err()) + case notify, ok := <-eventsCh: + if !ok { + return "", "", -1, nil, nil, fmt.Errorf("event stream closed") + } + if notify.Err != nil { + return "", "", -1, nil, nil, notify.Err + } + if notify.Connection != nil { + continue + } + + if options.replayEventsCh != nil { + select { + case options.replayEventsCh <- notify.Event: + default: + } + } + + switch event := notify.Event; event.(type) { + case clientlib.StreamStartedEvent: + streamStartedEvent := event.(clientlib.StreamStartedEvent) + if err := eventsHandler.OnStreamStarted(ctx, streamStartedEvent); err != nil { + return "", "", -1, nil, nil, err + } + case clientlib.BatchStartedEvent: + e := event.(clientlib.BatchStartedEvent) + skip, expiry, err := eventsHandler.OnBatchStarted(ctx, e) + if err != nil { + return "", "", -1, nil, nil, err + } + if !skip { + step++ + + // if we don't want to sign the vtxo tree, we can skip the tree signing phase + if !options.signVtxoTree { + step = treeNoncesAggregated + } + batchExpiry = expiry + continue + } + case clientlib.BatchFinalizedEvent: + if step != batchFinalization { + continue + } + event := event.(clientlib.BatchFinalizedEvent) + if err := eventsHandler.OnBatchFinalized(ctx, event); err != nil { + return "", "", -1, nil, nil, err + } + return event.Txid, commitmentTx, batchExpiry, forfeitTxs, vtxoTree, nil + // the batch session failed, return error only if we joined. + case clientlib.BatchFailedEvent: + e := event.(clientlib.BatchFailedEvent) + if err := eventsHandler.OnBatchFailed(ctx, e); err != nil { + return "", "", -1, nil, nil, err + } + continue + // we received a tree tx event msg, let's update the vtxo/connector tree. + case clientlib.TreeTxEvent: + if step != batchStarted && step != treeNoncesAggregated { + continue + } + + treeTxEvent := event.(clientlib.TreeTxEvent) + + if err := eventsHandler.OnTreeTxEvent(ctx, treeTxEvent); err != nil { + return "", "", -1, nil, nil, err + } + + if treeTxEvent.BatchIndex == 0 { + flatVtxoTree = append(flatVtxoTree, treeTxEvent.Node) + } else { + flatConnectorTree = append(flatConnectorTree, treeTxEvent.Node) + } + + continue + case clientlib.TreeSignatureEvent: + if step != treeNoncesAggregated { + continue + } + if vtxoTree == nil { + return "", "", -1, nil, nil, fmt.Errorf("vtxo tree not initialized") + } + + event := event.(clientlib.TreeSignatureEvent) + if err := eventsHandler.OnTreeSignatureEvent(ctx, event); err != nil { + return "", "", -1, nil, nil, err + } + + if err := addSignatureToTxTree(event, vtxoTree); err != nil { + return "", "", -1, nil, nil, err + } + continue + // the musig2 session started, let's send our nonces. + case clientlib.TreeSigningStartedEvent: + if step != batchStarted { + continue + } + + var err error + vtxoTree, err = tree.NewTxTree(flatVtxoTree) + if err != nil { + return "", "", -1, nil, nil, fmt.Errorf("failed to create branch of vtxo tree: %s", err) + } + + event := event.(clientlib.TreeSigningStartedEvent) + skip, err := eventsHandler.OnTreeSigningStarted(ctx, event, vtxoTree) + if err != nil { + return "", "", -1, nil, nil, err + } + + if !skip { + step++ + } + continue + // we received the aggregated nonces, let's send our signatures. + case clientlib.TreeNoncesAggregatedEvent: + if step != treeSigningStarted { + continue + } + + event := event.(clientlib.TreeNoncesAggregatedEvent) + signed, err := eventsHandler.OnTreeNoncesAggregated(ctx, event) + if err != nil { + return "", "", -1, nil, nil, err + } + + if signed { + step++ + } + continue + // we received the fully signed vtxo and connector trees, let's send our signed forfeit + // txs and optionally signed boarding utxos included in the commitment tx. + case clientlib.TreeNoncesEvent: + if step != treeSigningStarted { + continue + } + + event := event.(clientlib.TreeNoncesEvent) + signed, err := eventsHandler.OnTreeNonces(ctx, event) + if err != nil { + return "", "", -1, nil, nil, err + } + if signed { + step++ + } + continue + case clientlib.BatchFinalizationEvent: + if step != treeNoncesAggregated { + continue + } + + if options.signVtxoTree && vtxoTree == nil { + return "", "", -1, nil, nil, fmt.Errorf("vtxo tree not initialized") + } + + if len(flatConnectorTree) > 0 { + var err error + connectorTree, err = tree.NewTxTree(flatConnectorTree) + if err != nil { + return "", "", -1, nil, nil, fmt.Errorf("failed to create branch of connector tree: %s", err) + } + } + + event := event.(clientlib.BatchFinalizationEvent) + txs, err := eventsHandler.OnBatchFinalization( + ctx, event, vtxoTree, connectorTree, + ) + if err != nil { + return "", "", -1, nil, nil, err + } + forfeitTxs = txs + commitmentTx = event.Tx + + log.Debug("done.") + log.Debug("waiting for batch finalization...") + step++ + continue + } + } + } +} diff --git a/pkg/client-lib/batch-session/handler/handler_opts.go b/pkg/client-lib/batch-session/handler/handler_opts.go new file mode 100644 index 000000000..cfc3e68f6 --- /dev/null +++ b/pkg/client-lib/batch-session/handler/handler_opts.go @@ -0,0 +1,31 @@ +package batchsessionhandler + +type HandlerOption func(*options) + +func WithSkipVtxoTreeSigning() HandlerOption { + return func(o *options) { + o.signVtxoTree = false + } +} + +func WithReplay(ch chan<- any) HandlerOption { + return func(o *options) { + o.replayEventsCh = ch + } +} + +func WithCancel(cancelCh <-chan struct{}) HandlerOption { + return func(o *options) { + o.cancelCh = cancelCh + } +} + +type options struct { + signVtxoTree bool // default: true + replayEventsCh chan<- any // default: nil + cancelCh <-chan struct{} // default: nil +} + +func newOptions() *options { + return &options{signVtxoTree: true} +} diff --git a/pkg/client-lib/batch-session/handler/types.go b/pkg/client-lib/batch-session/handler/types.go new file mode 100644 index 000000000..78f44c25b --- /dev/null +++ b/pkg/client-lib/batch-session/handler/types.go @@ -0,0 +1,34 @@ +package batchsessionhandler + +import ( + "context" + "time" + + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" +) + +type Handler interface { + OnBatchStarted( + ctx context.Context, event clientlib.BatchStartedEvent, + ) (bool, time.Duration, error) + OnBatchFinalized(ctx context.Context, event clientlib.BatchFinalizedEvent) error + OnBatchFailed(ctx context.Context, event clientlib.BatchFailedEvent) error + OnTreeTxEvent(ctx context.Context, event clientlib.TreeTxEvent) error + OnTreeSignatureEvent(ctx context.Context, event clientlib.TreeSignatureEvent) error + OnTreeSigningStarted( + ctx context.Context, event clientlib.TreeSigningStartedEvent, vtxoTree *tree.TxTree, + ) (bool, error) + OnTreeNoncesAggregated( + ctx context.Context, + event clientlib.TreeNoncesAggregatedEvent, + ) (signed bool, err error) + OnTreeNonces(ctx context.Context, event clientlib.TreeNoncesEvent) (signed bool, err error) + OnBatchFinalization( + ctx context.Context, + event clientlib.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree, + ) ([]string, error) + OnStreamStarted( + ctx context.Context, event clientlib.StreamStartedEvent, + ) error +} diff --git a/pkg/client-lib/batch-session/handler/utils.go b/pkg/client-lib/batch-session/handler/utils.go new file mode 100644 index 000000000..5b8b60da0 --- /dev/null +++ b/pkg/client-lib/batch-session/handler/utils.go @@ -0,0 +1,213 @@ +package batchsessionhandler + +import ( + "bytes" + "encoding/hex" + "fmt" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" +) + +func addSignatureToTxTree( + event clientlib.TreeSignatureEvent, txTree *tree.TxTree, +) error { + if event.BatchIndex != 0 { + return fmt.Errorf("batch index %d is not 0", event.BatchIndex) + } + + decodedSig, err := hex.DecodeString(event.Signature) + if err != nil { + return fmt.Errorf("failed to decode signature: %s", err) + } + + sig, err := schnorr.ParseSignature(decodedSig) + if err != nil { + return fmt.Errorf("failed to parse signature: %s", err) + } + + return txTree.Apply(func(g *tree.TxTree) (bool, error) { + if g.Root.UnsignedTx.TxID() != event.Txid { + return true, nil + } + + g.Root.Inputs[0].TaprootKeySpendSig = sig.Serialize() + return false, nil + }) +} + +func getBatchExpiryLocktime(expiry uint32) arklib.RelativeLocktime { + if expiry >= 512 { + return arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: expiry} + } + return arklib.RelativeLocktime{Type: arklib.LocktimeTypeBlock, Value: expiry} +} + +func validateReceivers( + network arklib.Network, ptx *psbt.Packet, receivers []clientlib.Receiver, vtxoTree *tree.TxTree, +) error { + netParams := clientlib.ToBitcoinNetwork(network) + for _, receiver := range receivers { + isOnChain, onchainScript, err := clientlib.ParseBitcoinAddress(receiver.To, netParams) + if err != nil { + return fmt.Errorf("invalid receiver address: %s err = %s", receiver.To, err) + } + + if isOnChain { + if err := validateOnchainReceiver(ptx, receiver, onchainScript); err != nil { + return err + } + } else { + if err := validateOffchainReceiver(vtxoTree, receiver); err != nil { + return err + } + } + } + return nil +} + +func validateOnchainReceiver( + ptx *psbt.Packet, receiver clientlib.Receiver, onchainScript []byte, +) error { + found := false + for _, output := range ptx.UnsignedTx.TxOut { + if bytes.Equal(output.PkScript, onchainScript) { + if output.Value != int64(receiver.Amount) { + return fmt.Errorf( + "invalid collaborative exit output amount: got %d, want %d", + output.Value, receiver.Amount, + ) + } + found = true + break + } + } + if !found { + return fmt.Errorf("collaborative exit output not found: %s", receiver.To) + } + return nil +} + +func validateOffchainReceiver(vtxoTree *tree.TxTree, receiver clientlib.Receiver) error { + found := false + + rcvAddr, err := arklib.DecodeAddressV0(receiver.To) + if err != nil { + return err + } + + vtxoTapKey := schnorr.SerializePubKey(rcvAddr.VtxoTapKey) + + leaves := vtxoTree.Leaves() + for _, leaf := range leaves { + for outputIndex, output := range leaf.UnsignedTx.TxOut { + if len(output.PkScript) == 0 { + continue + } + + if bytes.Equal(output.PkScript[2:], vtxoTapKey) { + if output.Value != int64(receiver.Amount) { + continue + } + + found = true + if len(receiver.Assets) > 0 { + if err := validateAssetOutputs(leaf.UnsignedTx, outputIndex, receiver); err != nil { + return err + } + } + break + } + } + + if found { + break + } + } + + if !found { + return fmt.Errorf("offchain send output not found: %s", receiver.To) + } + + return nil +} + +func validateAssetOutputs(tx *wire.MsgTx, outputIndex int, receiver clientlib.Receiver) error { + ext, err := extension.NewExtensionFromTx(tx) + if err != nil { + return err + } + assetPacket := ext.GetAssetPacket() + if len(assetPacket) == 0 { + return fmt.Errorf("no asset packet found in transaction") + } + + // For each expected asset, verify the asset group exists and contains the correct output + for _, expectedAsset := range receiver.Assets { + found := false + for _, assetGroup := range assetPacket { + // Skip issuances + if assetGroup.IsIssuance() { + continue + } + + if assetGroup.AssetId.String() == expectedAsset.AssetId { + if err := validateAssetGroupOutput(assetGroup.Outputs, outputIndex, expectedAsset); err != nil { + return err + } + found = true + break + } + } + + if !found { + return fmt.Errorf("asset group not found in batch leaf") + } + } + + return nil +} + +func validateAssetGroupOutput( + outputs []asset.AssetOutput, + outputIndex int, + expectedAsset clientlib.Asset, +) error { + found := false + for _, output := range outputs { + if int(output.Vout) != outputIndex { + continue + } + + if output.Amount != expectedAsset.Amount { + return fmt.Errorf( + "invalid asset output amount: got %d, want %d", + output.Amount, + expectedAsset.Amount, + ) + } + found = true + break + } + + if !found { + return fmt.Errorf("asset output not found in asset group: %s", expectedAsset.AssetId) + } + return nil +} + +func isOnchainOnly(receivers []clientlib.Receiver) bool { + for _, receiver := range receivers { + if !receiver.IsOnchain() { + return false + } + } + + return true +} diff --git a/pkg/client-lib/batch-session/intent.go b/pkg/client-lib/batch-session/intent.go new file mode 100644 index 000000000..5e2a0a9c1 --- /dev/null +++ b/pkg/client-lib/batch-session/intent.go @@ -0,0 +1,96 @@ +package batchsession + +import ( + "context" + "time" + + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + "github.com/arkade-os/arkd/pkg/ark-lib/intent" +) + +// BuildAndSignRegisterIntent builds and signs an intent to be registered for joining a batch +func BuildAndSignRegisterIntent( + ctx context.Context, args IntentArgs, +) (string, string, extension.Extension, error) { + if err := args.validateForRegister(); err != nil { + return "", "", nil, err + } + + inputs, assetInputs, leafProofs, psbtFields, err := args.intentInputs() + if err != nil { + return "", "", nil, err + } + + message, outputsTxOut, ext, err := registerIntentMessage( + assetInputs, args.Outputs, args.Cosigners, + ) + if err != nil { + return "", "", nil, err + } + + proof, message, err := buildAndSignIntent( + ctx, message, inputs, outputsTxOut, leafProofs, + psbtFields, args.signingRequired(), args.SignTx, + ) + if err != nil { + return "", "", nil, err + } + + return proof, message, ext, nil +} + +// BuildAndSignDeleteIntent builds and signs an intent message used to withdraw +// a previously registered intent from the server's pending batch. It does NOT +// submit the request — the caller is responsible for sending it. +func BuildAndSignDeleteIntent(ctx context.Context, args IntentArgs) (string, string, error) { + if err := args.validateForDelete(); err != nil { + return "", "", err + } + + inputs, _, leafProofs, psbtFields, err := args.intentInputs() + if err != nil { + return "", "", err + } + + message, err := intent.DeleteMessage{ + BaseMessage: intent.BaseMessage{ + Type: intent.IntentMessageTypeDelete, + }, + ExpireAt: time.Now().Add(2 * time.Minute).Unix(), + }.Encode() + if err != nil { + return "", "", err + } + + return buildAndSignIntent( + ctx, message, inputs, nil, leafProofs, psbtFields, args.signingRequired(), args.SignTx, + ) +} + +// BuildAndSignGetPendingTxIntent builds and signs an intent message used to +// fetch a pending offchain transaction for the provided vtxos. It does NOT +// submit the request — the caller is responsible for sending it. +func BuildAndSignGetPendingTxIntent(ctx context.Context, args IntentArgs) (string, string, error) { + if err := args.validateForGetPendingTx(); err != nil { + return "", "", err + } + + inputs, _, leafProofs, psbtFields, err := args.intentInputs() + if err != nil { + return "", "", err + } + + message, err := intent.GetPendingTxMessage{ + BaseMessage: intent.BaseMessage{ + Type: intent.IntentMessageTypeGetPendingTx, + }, + ExpireAt: time.Now().Add(10 * time.Minute).Unix(), // valid for 10 minutes + }.Encode() + if err != nil { + return "", "", err + } + + return buildAndSignIntent( + ctx, message, inputs, nil, leafProofs, psbtFields, args.signingRequired(), args.SignTx, + ) +} diff --git a/pkg/client-lib/batch-session/intent_test.go b/pkg/client-lib/batch-session/intent_test.go new file mode 100644 index 000000000..be5236036 --- /dev/null +++ b/pkg/client-lib/batch-session/intent_test.go @@ -0,0 +1,127 @@ +package batchsession + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +func TestBuildAndSignRegisterIntent(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*IntentArgs) + errSubstr string + }{ + { + name: "missing funds", + mutate: func(a *IntentArgs) { + a.Vtxos = nil + a.BoardingUtxos = nil + a.Notes = nil + }, + errSubstr: "missing funds", + }, + { + name: "missing outputs", + mutate: func(a *IntentArgs) { a.Outputs = nil }, + errSubstr: "missing outputs", + }, + { + name: "missing cosigners", + mutate: func(a *IntentArgs) { a.Cosigners = nil }, + errSubstr: "missing cosigners", + }, + { + name: "missing sign tx", + mutate: func(a *IntentArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestIntentArgs() + tc.mutate(&args) + + _, _, _, err := BuildAndSignRegisterIntent(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +func TestBuildAndSignDeleteIntent(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*IntentArgs) + errSubstr string + }{ + {"missing funds", func(a *IntentArgs) { + a.Vtxos = nil + a.BoardingUtxos = nil + a.Notes = nil + }, "missing funds"}, + {"missing sign tx", func(a *IntentArgs) { a.SignTx = nil }, "missing sign tx"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestIntentArgs() + tc.mutate(&args) + + _, _, err := BuildAndSignDeleteIntent(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +func TestBuildAndSignGetPendingTxIntent(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*IntentArgs) + errSubstr string + }{ + {"missing funds", func(a *IntentArgs) { a.Vtxos = nil }, "missing funds"}, + {"missing sign tx", func(a *IntentArgs) { a.SignTx = nil }, "missing sign tx"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestIntentArgs() + tc.mutate(&args) + + _, _, err := BuildAndSignGetPendingTxIntent(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +// newTestIntentArgs returns a valid baseline IntentArgs suitable for +// BuildAndSignRegisterIntent (the strictest validator). Delete and +// GetPendingTx tests override or use a subset of the same baseline because +// their validators are looser. +func newTestIntentArgs() IntentArgs { + return IntentArgs{ + BaseArgs: BaseArgs{ + Vtxos: []clientlib.Vtxo{{ + Outpoint: clientlib.Outpoint{Txid: "deadbeef", VOut: 0}, + Amount: 10000, + }}, + Outputs: []clientlib.Receiver{{To: "tark1qexample", Amount: 10000}}, + SignTx: clientlib.SignFn(mockSignTx), + }, + Cosigners: []string{ + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + }, + } +} diff --git a/pkg/client-lib/batch-session/redeem_notes.go b/pkg/client-lib/batch-session/redeem_notes.go new file mode 100644 index 000000000..57cf2403d --- /dev/null +++ b/pkg/client-lib/batch-session/redeem_notes.go @@ -0,0 +1,87 @@ +package batchsession + +import ( + "context" + "encoding/hex" + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/note" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcec/v2" +) + +// RedeemNotesArgs configures a RedeemNotes call: the Notes to redeem and the +// ReceiverAddr that will receive the resulting vtxo. SignTx signs the intent +// proof, and Client/ServerInfo are used to talk to the server. +type RedeemNotesArgs struct { + Client clientlib.Client + SignTx clientlib.SignFn + ServerInfo clientlib.Info + Notes []string + ReceiverAddr string +} + +func (a RedeemNotesArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx function") + } + if len(a.Notes) <= 0 { + return fmt.Errorf("missing notes to redeem") + } + if len(a.ReceiverAddr) <= 0 { + return fmt.Errorf("missing receiver") + } + info := a.ServerInfo + if len(info.Network) <= 0 || + len(info.ForfeitPubKey) <= 0 || + len(info.ForfeitAddress) <= 0 { + return fmt.Errorf("missing server info") + } + buf, err := hex.DecodeString(info.ForfeitPubKey) + if err != nil { + return fmt.Errorf( + "expected hex format for forfeit pubkey, got %s", info.ForfeitPubKey, + ) + } + if _, err := btcec.ParsePubKey(buf); err != nil { + return fmt.Errorf("failed to parse forfeit pubkey: %w", err) + } + return nil +} + +// RedeemNotes performs the full lifecycle of redeeming one or more notes into +// a fresh vtxo via a batch session: builds, signs, submits the register +// intent, handles batch events, and finalizes the commitment transaction via +// JoinBatch. +func RedeemNotes( + ctx context.Context, args RedeemNotesArgs, opts ...Option, +) (*BatchTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + amount := uint64(0) + for _, noteStr := range args.Notes { + n, err := note.NewNoteFromString(noteStr) + if err != nil { + return nil, err + } + amount += uint64(n.Value) + } + + return joinBatchWithRetry(ctx, JoinBatchArgs{ + BaseArgs: BaseArgs{ + Notes: args.Notes, + SignTx: args.SignTx, + Outputs: []clientlib.Receiver{{ + To: args.ReceiverAddr, + Amount: amount, + }}, + }, + Client: args.Client, + ServerInfo: args.ServerInfo, + }, opts...) +} diff --git a/pkg/client-lib/batch-session/redeem_notes_test.go b/pkg/client-lib/batch-session/redeem_notes_test.go new file mode 100644 index 000000000..4efe587ba --- /dev/null +++ b/pkg/client-lib/batch-session/redeem_notes_test.go @@ -0,0 +1,68 @@ +package batchsession + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +func TestRedeemNotes(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*RedeemNotesArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *RedeemNotesArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing sign tx", + mutate: func(a *RedeemNotesArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx function", + }, + { + name: "missing server info", + mutate: func(a *RedeemNotesArgs) { a.ServerInfo.Network = "" }, + errSubstr: "missing server info", + }, + { + name: "missing receiver", + mutate: func(a *RedeemNotesArgs) { a.ReceiverAddr = "" }, + errSubstr: "missing receiver", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestRedeemNotesArgs() + tc.mutate(&args) + + _, err := RedeemNotes(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +// newTestRedeemNotesArgs returns a valid baseline RedeemNotesArgs. Tests in +// this file mutate a single field on the returned value to exercise the +// corresponding validation error. +func newTestRedeemNotesArgs() RedeemNotesArgs { + return RedeemNotesArgs{ + Client: mockClient{}, + SignTx: clientlib.SignFn(mockSignTx), + ServerInfo: clientlib.Info{ + Network: "regtest", + ForfeitPubKey: testForfeitPubKey, + ForfeitAddress: testAddr, + }, + Notes: []string{"somenote"}, + ReceiverAddr: "tark1qexample", + } +} diff --git a/pkg/client-lib/batch-session/settle.go b/pkg/client-lib/batch-session/settle.go new file mode 100644 index 000000000..c11aba2ad --- /dev/null +++ b/pkg/client-lib/batch-session/settle.go @@ -0,0 +1,169 @@ +package batchsession + +import ( + "context" + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" +) + +// SettleArgs configures a Settle call: the BoardingUtxos and Vtxos to settle +// into a fresh vtxo at ReceiverAddr. ExpiryThreshold (in seconds) filters out +// vtxos expiring later than the threshold. FeeEstimator sizes the change +// output; SignTx signs the intent proof; Client/ServerInfo are used to talk +// to the server. +type SettleArgs struct { + Client clientlib.Client + ServerInfo clientlib.Info + SignTx clientlib.SignFn + BoardingUtxos []clientlib.Utxo + Vtxos []clientlib.Vtxo + ReceiverAddr string +} + +func (a SettleArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx function") + } + if len(a.Vtxos) <= 0 && len(a.BoardingUtxos) <= 0 { + return fmt.Errorf("missing funds to settle") + } + if len(a.ReceiverAddr) <= 0 { + return fmt.Errorf("missing receiver") + } + if a.ServerInfo.Dust == 0 { + return fmt.Errorf("missing server info") + } + return nil +} + +// Settle performs the full lifecycle of refreshing vtxos and/or boarding utxos +// into a new vtxo via a batch session: selects funds, then builds, signs, +// submits the register intent, handles batch events, and finalizes the +// commitment transaction via JoinBatch. +func Settle(ctx context.Context, args SettleArgs, opts ...Option) (*BatchTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + o := newOptions() + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, err + } + } + + feeEstimator, err := arkfee.New(args.ServerInfo.Fees.IntentFees) + if err != nil { + return nil, err + } + + vtxos, boardingUtxos, outputs, err := selectFunds( + ctx, feeEstimator, args.Vtxos, args.BoardingUtxos, + nil, args.ReceiverAddr, o.expiryThreshold, args.ServerInfo.Dust, + ) + if err != nil { + return nil, err + } + + return joinBatchWithRetry(ctx, JoinBatchArgs{ + BaseArgs: BaseArgs{ + Vtxos: vtxos, + BoardingUtxos: boardingUtxos, + Outputs: outputs, + SignTx: args.SignTx, + }, + Client: args.Client, + ServerInfo: args.ServerInfo, + }, opts...) +} + +func selectFunds( + ctx context.Context, feeEstimator *arkfee.Estimator, + vtxos []clientlib.Vtxo, boardingUtxos []clientlib.Utxo, outputs []clientlib.Receiver, + receiverAddr string, expiryThreshold int64, dust uint64, +) ([]clientlib.Vtxo, []clientlib.Utxo, []clientlib.Receiver, error) { + if expiryThreshold > 0 { + vtxos = filterVtxosByExpiry(vtxos, expiryThreshold) + } + + outs := make([]clientlib.Receiver, len(outputs)) + copy(outs, outputs) + + // No outputs means settle vtxos or boarding utxos, therefore we have to create the + // clientlib.Receiver output from the receiver address passed in Settle or RedeemNotes args + if len(outputs) <= 0 { + // Gather all asset balances from inputs to carry them forward + assetBalances := make(map[string]uint64) + for _, vtxo := range vtxos { + for _, a := range vtxo.Assets { + assetBalances[a.AssetId] += a.Amount + } + } + for _, utxo := range boardingUtxos { + for _, a := range utxo.Assets { + assetBalances[a.AssetId] += a.Amount + } + } + + assets := make([]clientlib.Asset, 0, len(assetBalances)) + for assetId, amount := range assetBalances { + assets = append(assets, clientlib.Asset{ + AssetId: assetId, + Amount: amount, + }) + } + + outs = []clientlib.Receiver{{ + To: receiverAddr, + Amount: 0, + Assets: assets, + }} + } + + if len(outs) == 1 && outs[0].Amount <= 0 { + totalAmount, totalFeeAmount := uint64(0), uint64(0) + for _, utxo := range boardingUtxos { + totalAmount += utxo.Amount + fees, err := feeEstimator.EvalOnchainInput(utxo.ToArkFeeInput()) + if err != nil { + return nil, nil, nil, err + } + totalFeeAmount += uint64(fees.ToSatoshis()) + } + + for _, vtxo := range vtxos { + totalAmount += vtxo.Amount + fees, err := feeEstimator.EvalOffchainInput(vtxo.ToArkFeeInput()) + if err != nil { + return nil, nil, nil, err + } + totalFeeAmount += uint64(fees.ToSatoshis()) + } + if totalFeeAmount >= totalAmount { + return nil, nil, nil, fmt.Errorf( + "fees (%d) exceed total amount (%d)", totalFeeAmount, totalAmount, + ) + } + outs[0].Amount = totalAmount - totalFeeAmount + } + + selectedBoardingUtxos, selectedVtxos, changeAmount, err := clientlib.CoinSelect( + boardingUtxos, vtxos, outs, dust, feeEstimator, + ) + if err != nil { + return nil, nil, nil, err + } + + if changeAmount > 0 { + outs = append(outs, clientlib.Receiver{ + To: receiverAddr, + Amount: changeAmount, + }) + } + return selectedVtxos, selectedBoardingUtxos, outs, nil +} diff --git a/pkg/client-lib/batch-session/settle_test.go b/pkg/client-lib/batch-session/settle_test.go new file mode 100644 index 000000000..559a52554 --- /dev/null +++ b/pkg/client-lib/batch-session/settle_test.go @@ -0,0 +1,95 @@ +package batchsession + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +// testAddr is a valid regtest bech32 p2wpkh address taken from the +// arkd builder tests. Reused as a baseline `Receiver.To` whenever a +// parseable on-chain address is required. +const testAddr = "bcrt1qhhq55mut9easvrncy4se8q6vg3crlug7yj4j56" + +// testForfeitPubKey is a real compressed pubkey hex; satisfies +// validateServerInfo's hex-decode + ParsePubKey checks. +const testForfeitPubKey = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + +func TestSettle(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*SettleArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *SettleArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing sign tx", + mutate: func(a *SettleArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx function", + }, + { + name: "missing funds to settle", + mutate: func(a *SettleArgs) { + a.Vtxos = nil + a.BoardingUtxos = nil + }, + errSubstr: "missing funds to settle", + }, + { + name: "missing receiver", + mutate: func(a *SettleArgs) { a.ReceiverAddr = "" }, + errSubstr: "missing receiver", + }, + { + name: "missing server info", + mutate: func(a *SettleArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestSettleArgs(t) + tc.mutate(&args) + + _, err := Settle(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +// mockClient is the smallest non-nil clientlib.Client that satisfies validation. +// Validation rejects requests before any method on Client is invoked, so the +// embedded nil interface is sufficient. +type mockClient struct{ clientlib.Client } + +// mockSignTx is a valid batch-session SignFn baseline used everywhere a +// non-nil signer is required without exercising the signing path. +func mockSignTx(context.Context, string) (string, error) { return "", nil } + +// newTestSettleArgs returns a valid baseline SettleArgs. Tests in this file +// mutate a single field on the returned value to exercise the corresponding +// validation error from Settle's validator. +func newTestSettleArgs(t *testing.T) SettleArgs { + t.Helper() + + return SettleArgs{ + Client: mockClient{}, + ServerInfo: clientlib.Info{Dust: 1000, Network: "regtest"}, + SignTx: clientlib.SignFn(mockSignTx), + Vtxos: []clientlib.Vtxo{{ + Outpoint: clientlib.Outpoint{Txid: "deadbeef", VOut: 0}, + Amount: 10000, + }}, + ReceiverAddr: "tark1qexample", + } +} diff --git a/pkg/client-lib/batch-session/utils.go b/pkg/client-lib/batch-session/utils.go new file mode 100644 index 000000000..25cb2eedf --- /dev/null +++ b/pkg/client-lib/batch-session/utils.go @@ -0,0 +1,426 @@ +package batchsession + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + "github.com/arkade-os/arkd/pkg/ark-lib/intent" + "github.com/arkade-os/arkd/pkg/ark-lib/note" + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +func handleBatchEvents( + ctx context.Context, customHandler batchsessionhandler.Handler, + args batchsessionhandler.Args, notes []string, + replayEventsCh chan<- any, cancelCh <-chan struct{}, +) (string, string, time.Duration, []string, *tree.TxTree, error) { + topics := make([]string, 0) + for _, n := range notes { + parsedNote, err := note.NewNoteFromString(n) + if err != nil { + return "", "", -1, nil, nil, err + } + outpoint, _, err := parsedNote.IntentProofInput() + if err != nil { + return "", "", -1, nil, nil, err + } + topics = append(topics, outpoint.String()) + } + + for _, boardingUtxo := range args.BoardingUtxos { + topics = append(topics, boardingUtxo.String()) + } + for _, vtxo := range args.Vtxos { + topics = append(topics, vtxo.Outpoint.String()) + } + for _, signer := range args.SignerSessions { + topics = append(topics, signer.GetPublicKey()) + } + + // Skip signing only if there are no offchain outputs + skipVtxoTreeSigning := true + for _, receiver := range args.Receivers { + if _, err := arklib.DecodeAddressV0(receiver.To); err == nil { + skipVtxoTreeSigning = false + break + } + } + + options := []batchsessionhandler.HandlerOption{batchsessionhandler.WithCancel(cancelCh)} + + if skipVtxoTreeSigning { + options = append(options, batchsessionhandler.WithSkipVtxoTreeSigning()) + } + + if replayEventsCh != nil { + options = append(options, batchsessionhandler.WithReplay(replayEventsCh)) + } + + eventsCh, close, err := args.Client.GetEventStream(ctx, topics) + if err != nil { + if errors.Is(err, io.EOF) { + return "", "", -1, nil, nil, clientlib.ErrConnectionClosedByServer + } + return "", "", -1, nil, nil, err + } + defer close() + + batchEventsHandler := customHandler + if batchEventsHandler == nil { + batchEventsHandler, err = batchsessionhandler.NewDefaultHandler(args) + if err != nil { + return "", "", -1, nil, nil, err + } + } + + return batchsessionhandler.JoinBatchSession(ctx, eventsCh, batchEventsHandler, options...) +} + +// toIntentInputs converts funds (boarding utxos, vtxos, or notes) into intent +// proof inputs and returns the auxiliary data needed to sign the proof PSBT. +func toIntentInputs( + boardingUtxos []clientlib.Utxo, vtxos []clientlib.Vtxo, notes []string, +) ([]intent.Input, map[int][]clientlib.Asset, []*arklib.TaprootMerkleProof, [][]*psbt.Unknown, error) { + inputs := make([]intent.Input, 0, len(boardingUtxos)+len(vtxos)) + signingLeaves := make([]*arklib.TaprootMerkleProof, 0, len(boardingUtxos)+len(vtxos)) + psbtFields := make([][]*psbt.Unknown, 0, len(boardingUtxos)+len(vtxos)) + assetInputs := make(map[int][]clientlib.Asset) + + for inputIndex, coin := range vtxos { + hash, err := chainhash.NewHashFromStr(coin.Txid) + if err != nil { + return nil, nil, nil, nil, err + } + outpoint := wire.NewOutPoint(hash, coin.VOut) + + pkScript, leafProof, err := coin.ParseClosure() + if err != nil { + return nil, nil, nil, nil, err + } + + signingLeaves = append(signingLeaves, leafProof) + + inputs = append(inputs, intent.Input{ + OutPoint: outpoint, + Sequence: wire.MaxTxInSequenceNum, + WitnessUtxo: &wire.TxOut{ + Value: int64(coin.Amount), + PkScript: pkScript, + }, + }) + + if len(coin.Assets) > 0 { + // in context of intent transaction, there is a "fake" input at index 0 + // that's why from the asset packet point of view, the index must be i+1 + assetInputs[inputIndex+1] = coin.Assets + } + + taptreeField, err := txutils.VtxoTaprootTreeField.Encode(coin.Tapscripts) + if err != nil { + return nil, nil, nil, nil, err + } + + psbtFields = append(psbtFields, []*psbt.Unknown{taptreeField}) + } + + for boardingIndex, coin := range boardingUtxos { + hash, err := chainhash.NewHashFromStr(coin.Txid) + if err != nil { + return nil, nil, nil, nil, err + } + outpoint := wire.NewOutPoint(hash, coin.VOut) + + pkScript, leafProof, err := coin.ParseClosure() + if err != nil { + return nil, nil, nil, nil, err + } + + signingLeaves = append(signingLeaves, leafProof) + + inputs = append(inputs, intent.Input{ + OutPoint: outpoint, + Sequence: wire.MaxTxInSequenceNum, + WitnessUtxo: &wire.TxOut{ + Value: int64(coin.Amount), + PkScript: pkScript, + }, + }) + + if len(coin.Assets) > 0 { + // boarding utxos sit after vtxos in the proof PSBT, and the +1 + // accounts for the fake intent input at index 0. + assetInputs[len(vtxos)+boardingIndex+1] = coin.Assets + } + + taptreeField, err := txutils.VtxoTaprootTreeField.Encode(coin.Tapscripts) + if err != nil { + return nil, nil, nil, nil, err + } + psbtFields = append(psbtFields, []*psbt.Unknown{taptreeField}) + } + + nextInputIndex := len(inputs) + if nextInputIndex > 0 { + // if there is non-notes inputs, count the extra intent proof input + nextInputIndex++ + } + + for _, n := range notes { + parsedNote, err := note.NewNoteFromString(n) + if err != nil { + return nil, nil, nil, nil, err + } + + outpoint, input, err := parsedNote.IntentProofInput() + if err != nil { + return nil, nil, nil, nil, err + } + + inputs = append(inputs, intent.Input{ + OutPoint: outpoint, + Sequence: wire.MaxTxInSequenceNum, + WitnessUtxo: &wire.TxOut{ + Value: input.WitnessUtxo.Value, + PkScript: input.WitnessUtxo.PkScript, + }, + }) + + vtxoScript := parsedNote.VtxoScript() + + _, taprootTree, err := vtxoScript.TapTree() + if err != nil { + return nil, nil, nil, nil, err + } + + forfeitScript, err := vtxoScript.Closures[0].Script() + if err != nil { + return nil, nil, nil, nil, err + } + + forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) + leafProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to get taproot merkle proof: %s", err) + } + + nextInputIndex++ + // if the note vtxo is the first input, it will be used twice + if nextInputIndex == 1 { + nextInputIndex++ + } + + signingLeaves = append(signingLeaves, leafProof) + psbtFields = append(psbtFields, input.Unknowns) + } + + return inputs, assetInputs, signingLeaves, psbtFields, nil +} + +// buildAndSignIntent build and signs an intent tx from the given args. +func buildAndSignIntent( + ctx context.Context, + message string, inputs []intent.Input, outputsTxOut []*wire.TxOut, + leafProofs []*arklib.TaprootMerkleProof, arkFields [][]*psbt.Unknown, + signingRequired bool, signTx func(context.Context, string) (string, error), +) (string, string, error) { + proof, err := intent.New(message, inputs, outputsTxOut) + if err != nil { + return "", "", err + } + + for i, input := range proof.Inputs { + // intent proof tx has an additional input using the first vtxo script + // so we need to use the previous leaf proof for the current input except for the first input + var leafProof *arklib.TaprootMerkleProof + if i == 0 { + leafProof = leafProofs[0] + } else { + leafProof = leafProofs[i-1] + input.Unknowns = arkFields[i-1] + } + input.TaprootLeafScript = []*psbt.TaprootTapLeafScript{ + { + ControlBlock: leafProof.ControlBlock, + Script: leafProof.Script, + LeafVersion: txscript.BaseLeafVersion, + }, + } + + proof.Inputs[i] = input + } + + unsignedProofTx, err := proof.B64Encode() + if err != nil { + return "", "", err + } + + if !signingRequired { + return unsignedProofTx, message, nil + } + + signedTx, err := signTx(ctx, unsignedProofTx) + if err != nil { + return "", "", err + } + + return signedTx, message, nil +} + +// registerIntentMessage creates the message for registring for a batch session. +func registerIntentMessage( + assetInputs map[int][]clientlib.Asset, outputs []clientlib.Receiver, cosignersPublicKeys []string, +) (string, []*wire.TxOut, extension.Extension, error) { + outputsTxOut := make([]*wire.TxOut, 0) + onchainOutputsIndexes := make([]int, 0) + + for i, output := range outputs { + txOut, isOnchain, err := output.ToTxOut() + if err != nil { + return "", nil, nil, err + } + + if isOnchain { + onchainOutputsIndexes = append(onchainOutputsIndexes, i) + } + + outputsTxOut = append(outputsTxOut, txOut) + } + + var ext extension.Extension + if len(assetInputs) > 0 { + assetPacket, err := createAssetPacket(assetInputs, outputs, nil) + if err != nil { + return "", nil, nil, err + } + + ext = extension.Extension{assetPacket} + assetPacketOutput, err := ext.TxOut() + if err != nil { + return "", nil, nil, err + } + outputsTxOut = append(outputsTxOut, assetPacketOutput) + } + + message, err := intent.RegisterMessage{ + BaseMessage: intent.BaseMessage{ + Type: intent.IntentMessageTypeRegister, + }, + OnchainOutputIndexes: onchainOutputsIndexes, + CosignersPublicKeys: cosignersPublicKeys, + }.Encode() + if err != nil { + return "", nil, nil, err + } + + return message, outputsTxOut, ext, nil +} + +// createAssetPacket computes the right packet for the given asset inputs and receivers +func createAssetPacket( + assetInputs map[int][]clientlib.Asset, + receivers []clientlib.Receiver, changeReceiver *clientlib.Receiver, +) (asset.Packet, error) { + if changeReceiver != nil { + receivers = append(receivers, *changeReceiver) + } + + type assetTransfer struct { + inputs []asset.AssetInput + outputs []asset.AssetOutput + } + + assetTransfers := make(map[string]*assetTransfer) + for inputIndex, assets := range assetInputs { + for _, a := range assets { + if _, exists := assetTransfers[a.AssetId]; !exists { + assetTransfers[a.AssetId] = &assetTransfer{ + inputs: make([]asset.AssetInput, 0), + outputs: make([]asset.AssetOutput, 0), + } + } + + input, err := asset.NewAssetInput(uint16(inputIndex), a.Amount) + if err != nil { + return nil, err + } + assetTransfers[a.AssetId].inputs = append( + assetTransfers[a.AssetId].inputs, + *input, + ) + } + } + + for receiverIndex, receiver := range receivers { + if len(receiver.Assets) == 0 { + continue + } + + for _, ass := range receiver.Assets { + if _, exists := assetTransfers[ass.AssetId]; !exists { + return nil, fmt.Errorf("asset %s not found", ass.AssetId) + } + + output, err := asset.NewAssetOutput(uint16(receiverIndex), ass.Amount) + if err != nil { + return nil, err + } + assetTransfers[ass.AssetId].outputs = append( + assetTransfers[ass.AssetId].outputs, *output, + ) + } + } + + assetGroups := make([]asset.AssetGroup, 0) + for assetId, inputsOutputs := range assetTransfers { + assetId, err := asset.NewAssetIdFromString(assetId) + if err != nil { + return nil, err + } + + assetGroup, err := asset.NewAssetGroup( + assetId, nil, inputsOutputs.inputs, inputsOutputs.outputs, nil, + ) + if err != nil { + return nil, err + } + assetGroups = append(assetGroups, *assetGroup) + } + + if len(assetGroups) == 0 { + return nil, nil + } + + return asset.NewPacket(assetGroups) +} + +// filterVtxosByExpiry returns vtxos that have expiry equal or below the given threshold +func filterVtxosByExpiry(vtxos []clientlib.Vtxo, expiryThreshold int64) []clientlib.Vtxo { + now := time.Now() + threshold := time.Duration(expiryThreshold) * time.Second + + nearExpiry := make([]clientlib.Vtxo, 0, len(vtxos)) + for _, vtxo := range vtxos { + // Time until expiry + timeLeft := vtxo.ExpiresAt.Sub(now) + + // If already expired or within threshold + if timeLeft <= threshold { + nearExpiry = append(nearExpiry, vtxo) + } + } + + return nearExpiry +} diff --git a/pkg/client-lib/batch-session/utils_test.go b/pkg/client-lib/batch-session/utils_test.go new file mode 100644 index 000000000..69df8e192 --- /dev/null +++ b/pkg/client-lib/batch-session/utils_test.go @@ -0,0 +1,64 @@ +package batchsession + +import ( + "testing" + "time" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +func TestFilterVtxosByExpiry(t *testing.T) { + now := time.Now() + + const threshold int64 = 3 * 24 * 60 * 60 // 3 days in seconds + + vtxoExpiring1Day := clientlib.Vtxo{ExpiresAt: now.Add(24 * time.Hour)} + vtxoExpiring3Days := clientlib.Vtxo{ExpiresAt: now.Add(time.Duration(threshold) * time.Second)} + vtxoExpiring5Days := clientlib.Vtxo{ExpiresAt: now.Add(5 * 24 * time.Hour)} + vtxoAlreadyExpired := clientlib.Vtxo{ExpiresAt: now.Add(-1 * time.Hour)} + + testCases := []struct { + name string + vtxos []clientlib.Vtxo + expected []clientlib.Vtxo + }{ + { + name: "vtxo expiring within threshold is kept", + vtxos: []clientlib.Vtxo{vtxoExpiring1Day}, + expected: []clientlib.Vtxo{vtxoExpiring1Day}, + }, + { + name: "vtxo expiring at exactly threshold boundary is kept", + vtxos: []clientlib.Vtxo{vtxoExpiring3Days}, + expected: []clientlib.Vtxo{vtxoExpiring3Days}, + }, + { + name: "vtxo expiring beyond threshold is excluded", + vtxos: []clientlib.Vtxo{vtxoExpiring5Days}, + expected: []clientlib.Vtxo{}, + }, + { + name: "already expired vtxo is kept", + vtxos: []clientlib.Vtxo{vtxoAlreadyExpired}, + expected: []clientlib.Vtxo{vtxoAlreadyExpired}, + }, + { + name: "mixed vtxos: only within-threshold ones are kept", + vtxos: []clientlib.Vtxo{vtxoExpiring1Day, vtxoExpiring5Days, vtxoAlreadyExpired}, + expected: []clientlib.Vtxo{vtxoExpiring1Day, vtxoAlreadyExpired}, + }, + { + name: "empty input returns empty result", + vtxos: []clientlib.Vtxo{}, + expected: []clientlib.Vtxo{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := filterVtxosByExpiry(tc.vtxos, threshold) + require.Equal(t, tc.expected, got) + }) + } +} diff --git a/pkg/client-lib/batch_session.go b/pkg/client-lib/batch_session.go deleted file mode 100644 index 5c479dd00..000000000 --- a/pkg/client-lib/batch_session.go +++ /dev/null @@ -1,774 +0,0 @@ -package wallet - -import ( - "bytes" - "context" - "encoding/hex" - "errors" - "fmt" - "io" - "time" - - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" - "github.com/arkade-os/arkd/pkg/ark-lib/extension" - "github.com/arkade-os/arkd/pkg/ark-lib/intent" - "github.com/arkade-os/arkd/pkg/ark-lib/note" - "github.com/arkade-os/arkd/pkg/ark-lib/script" - "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - log "github.com/sirupsen/logrus" -) - -func (a *service) Settle(ctx context.Context, opts ...BatchSessionOption) (*SettleRes, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - - options := newDefaultSettleOptions() - for _, opt := range opts { - if err := opt.applyBatch(options); err != nil { - return nil, err - } - } - if options.expiryThreshold <= 0 { - options.expiryThreshold = defaultExpiryThreshold - } - - a.txLock.Lock() - defer a.txLock.Unlock() - - info, err := a.client.GetInfo(ctx) - if err != nil { - return nil, err - } - - feeEstimator, err := arkfee.New(info.Fees.IntentFees) - if err != nil { - return nil, err - } - - // coinselect all available boarding utxos and vtxos - boardingUtxos, vtxos, outputs, err := a.getFundsToSettle( - ctx, nil, feeEstimator, getVtxosFilter{ - withRecoverableVtxos: options.withRecoverableVtxos, - expiryThreshold: options.expiryThreshold, - vtxos: options.vtxos, - utxos: options.boardingUtxos, - }, - options.receiver, - ) - if err != nil { - return nil, err - } - - return a.joinBatchWithRetry(ctx, nil, outputs, *options, vtxos, boardingUtxos) -} - -func (a *service) RedeemNotes( - ctx context.Context, notes []string, opts ...BatchSessionOption, -) (*RedeemNotesRes, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - - amount := uint64(0) - - options := newDefaultSettleOptions() - for _, opt := range opts { - if err := opt.applyBatch(options); err != nil { - return nil, err - } - } - - for _, vStr := range notes { - v, err := note.NewNoteFromString(vStr) - if err != nil { - return nil, err - } - amount += uint64(v.Value) - } - - addr, err := a.getReceiver(ctx, options.receiver) - if err != nil { - return nil, err - } - - receiversOutput := []types.Receiver{{ - To: addr, - Amount: amount, - }} - - return a.joinBatchWithRetry(ctx, notes, receiversOutput, *options, nil, nil) -} - -func (a *service) CollaborativeExit( - ctx context.Context, addr string, amount uint64, opts ...BatchSessionOption, -) (*CollaborativeExitRes, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - - if a.UtxoMaxAmount == 0 { - return nil, fmt.Errorf("operation not allowed by the server") - } - - options := newDefaultSettleOptions() - for _, opt := range opts { - if err := opt.applyBatch(options); err != nil { - return nil, err - } - } - if options.expiryThreshold <= 0 { - options.expiryThreshold = defaultExpiryThreshold - } - - netParams := utils.ToBitcoinNetwork(a.Network) - if _, err := btcutil.DecodeAddress(addr, &netParams); err != nil { - return nil, fmt.Errorf("invalid onchain address") - } - - a.txLock.Lock() - defer a.txLock.Unlock() - - // send all case: substract fees from exited amount - info, err := a.client.GetInfo(ctx) - if err != nil { - return nil, err - } - - feeEstimator, err := arkfee.New(info.Fees.IntentFees) - if err != nil { - return nil, err - } - - receivers := []types.Receiver{{To: addr, Amount: amount}} - boardingUtxos, vtxos, outputs, err := a.getFundsToSettle( - ctx, receivers, feeEstimator, getVtxosFilter{ - withRecoverableVtxos: options.withRecoverableVtxos, - expiryThreshold: options.expiryThreshold, - vtxos: options.vtxos, - utxos: options.boardingUtxos, - excludeAssetVtxos: true, - }, - options.receiver, - ) - if err != nil { - return nil, err - } - - return a.joinBatchWithRetry(ctx, nil, outputs, *options, vtxos, boardingUtxos) -} - -func (a *service) RegisterIntent( - ctx context.Context, vtxos []types.Vtxo, boardingUtxos []types.Utxo, notes []string, - outputs []types.Receiver, cosignersPublicKeys []string, opts ...SignOption, -) (string, error) { - if err := a.safeCheck(); err != nil { - return "", err - } - - options := newDefaultSettleOptions() - for _, opt := range opts { - if err := opt.applyBatch(options); err != nil { - return "", err - } - } - - vtxosWithTapscripts, err := a.populateVtxosWithTapscripts(ctx, vtxos) - if err != nil { - return "", err - } - - inputs, tapLeaves, arkFields, assetInputs, err := toIntentInputs( - boardingUtxos, vtxosWithTapscripts, notes, - ) - if err != nil { - return "", err - } - - signingRequired := len(boardingUtxos)+len(vtxos) > 0 - proofTx, message, _, err := a.makeRegisterIntent( - inputs, assetInputs, tapLeaves, outputs, - cosignersPublicKeys, arkFields, signingRequired, options.keyIdsByScript, - ) - if err != nil { - return "", err - } - - return a.client.RegisterIntent(ctx, proofTx, message) -} - -func (a *service) DeleteIntent( - ctx context.Context, vtxos []types.Vtxo, boardingUtxos []types.Utxo, notes []string, - opts ...SignOption, -) error { - if err := a.safeCheck(); err != nil { - return err - } - - options := newDefaultSettleOptions() - for _, opt := range opts { - if err := opt.applyBatch(options); err != nil { - return err - } - } - - vtxosWithTapscripts, err := a.populateVtxosWithTapscripts(ctx, vtxos) - if err != nil { - return err - } - - inputs, exitLeaves, arkFields, _, err := toIntentInputs( - boardingUtxos, vtxosWithTapscripts, notes, - ) - if err != nil { - return err - } - - signingRequired := len(boardingUtxos)+len(vtxos) > 0 - proofTx, message, err := a.makeDeleteIntent( - inputs, exitLeaves, arkFields, signingRequired, options.keyIdsByScript, - ) - if err != nil { - return err - } - - return a.client.DeleteIntent(ctx, proofTx, message) -} - -func (a *service) getFundsToSettle( - ctx context.Context, - outputs []types.Receiver, feeEstimator *arkfee.Estimator, opts getVtxosFilter, - receiver string, -) ([]types.Utxo, []types.VtxoWithTapTree, []types.Receiver, error) { - vtxos := opts.vtxos - boardingUtxos := opts.utxos - if len(opts.vtxos) <= 0 && len(opts.utxos) <= 0 { - _, offchainAddrs, boardingAddrs, _, err := a.getAddresses(ctx) - if err != nil { - return nil, nil, nil, err - } - if len(offchainAddrs) <= 0 { - return nil, nil, nil, fmt.Errorf("no offchain addresses found") - } - - spendableVtxos, err := a.getSpendableVtxos(ctx, &opts) - if err != nil { - return nil, nil, nil, err - } - - for _, offchainAddr := range offchainAddrs { - for _, v := range spendableVtxos { - vtxoAddr, err := v.Address(a.SignerPubKey, a.Network) - if err != nil { - return nil, nil, nil, err - } - - if vtxoAddr == offchainAddr.Address { - vtxos = append(vtxos, types.VtxoWithTapTree{ - Vtxo: v, - Tapscripts: offchainAddr.Tapscripts, - }) - } - } - } - - boardingUtxos, err = a.getClaimableBoardingUtxos(ctx, boardingAddrs, nil) - if err != nil { - return nil, nil, nil, err - } - } - - addr, err := a.getReceiver(ctx, receiver) - if err != nil { - return nil, nil, nil, err - } - - if opts.expiryThreshold > 0 { - vtxos = utils.FilterVtxosByExpiry(vtxos, opts.expiryThreshold) - } - - if len(outputs) <= 0 { - // gather all asset balances from inputs to carry them forward - assetBalances := make(map[string]uint64) - for _, vtxo := range vtxos { - for _, a := range vtxo.Assets { - assetBalances[a.AssetId] += a.Amount - } - } - for _, utxo := range boardingUtxos { - for _, a := range utxo.Assets { - assetBalances[a.AssetId] += a.Amount - } - } - - assets := make([]types.Asset, 0, len(assetBalances)) - for assetId, amount := range assetBalances { - assets = append(assets, types.Asset{ - AssetId: assetId, - Amount: amount, - }) - } - - outputs = []types.Receiver{{ - To: addr, - Amount: 0, - Assets: assets, - }} - } - if len(outputs) == 1 && outputs[0].Amount <= 0 { - totalAmount, totalFeeAmount := uint64(0), uint64(0) - for _, utxo := range boardingUtxos { - totalAmount += utxo.Amount - fees, err := feeEstimator.EvalOnchainInput(utxo.ToArkFeeInput()) - if err != nil { - return nil, nil, nil, err - } - totalFeeAmount += uint64(fees.ToSatoshis()) - } - - for _, vtxo := range vtxos { - totalAmount += vtxo.Amount - fees, err := feeEstimator.EvalOffchainInput(vtxo.ToArkFeeInput()) - if err != nil { - return nil, nil, nil, err - } - totalFeeAmount += uint64(fees.ToSatoshis()) - } - if totalFeeAmount >= totalAmount { - return nil, nil, nil, fmt.Errorf( - "fees (%d) exceed total amount (%d)", totalFeeAmount, totalAmount, - ) - } - outputs[0].Amount = totalAmount - totalFeeAmount - } - - selectedBoardingUtxos, selectedVtxos, changeAmount, err := utils.CoinSelect( - boardingUtxos, vtxos, outputs, a.Dust, opts.withoutExpirySorting, feeEstimator, - ) - if err != nil { - return nil, nil, nil, err - } - - if changeAmount > 0 { - outputs = append(outputs, types.Receiver{ - To: addr, - Amount: changeAmount, - }) - } - return selectedBoardingUtxos, selectedVtxos, outputs, nil -} - -func (a *service) getClaimableBoardingUtxos( - _ context.Context, boardingAddrs []types.Address, opts *getVtxosFilter, -) ([]types.Utxo, error) { - claimable := make([]types.Utxo, 0) - for _, addr := range boardingAddrs { - boardingScript, err := script.ParseVtxoScript(addr.Tapscripts) - if err != nil { - return nil, err - } - - boardingTimeout, err := boardingScript.SmallestExitDelay() - if err != nil { - return nil, err - } - - boardingUtxos, err := a.explorer.GetUtxos([]string{addr.Address}) - if err != nil { - return nil, err - } - - now := time.Now() - - for _, utxo := range boardingUtxos { - if opts != nil && len(opts.outpoints) > 0 { - utxoOutpoint := types.Outpoint{ - Txid: utxo.Txid, - VOut: utxo.Vout, - } - found := false - for _, outpoint := range opts.outpoints { - if outpoint == utxoOutpoint { - found = true - break - } - } - - if !found { - continue - } - } - - u := utxo.ToUtxo(*boardingTimeout, addr.Tapscripts) - if u.SpendableAt.Before(now) { - continue - } - - claimable = append(claimable, u) - } - } - - return claimable, nil -} - -func (a *service) joinBatchWithRetry( - ctx context.Context, notes []string, outputs []types.Receiver, options batchSessionOptions, - selectedCoins []types.VtxoWithTapTree, selectedBoardingCoins []types.Utxo, -) (*BatchTxRes, error) { - inputs, exitLeaves, arkFields, assetInputs, err := toIntentInputs( - selectedBoardingCoins, selectedCoins, notes, - ) - if err != nil { - return nil, err - } - signingRequired := len(selectedCoins)+len(selectedBoardingCoins) > 0 - - signerSessions, signerPubKeys, err := a.handleOptions(options, inputs, notes) - if err != nil { - return nil, err - } - - deleteIntent := func() { - proof, message, err := a.makeDeleteIntent( - inputs, exitLeaves, arkFields, signingRequired, options.keyIdsByScript, - ) - if err != nil { - log.WithError(err).Warn("failed to create delete intent proof") - return - } - - err = a.client.DeleteIntent(ctx, proof, message) - if err != nil { - log.WithError(err).Warn("failed to delete intent") - return - } - } - - maxRetry := 1 - if options.retryNum > 0 { - maxRetry = options.retryNum - } - retryCount := 0 - var batchErr error - for retryCount < maxRetry { - proofTx, message, ext, err := a.makeRegisterIntent( - inputs, assetInputs, exitLeaves, outputs, signerPubKeys, - arkFields, signingRequired, options.keyIdsByScript, - ) - if err != nil { - return nil, err - } - - intentID, err := a.client.RegisterIntent(ctx, proofTx, message) - if err != nil { - return nil, fmt.Errorf("failed to register intent: %w", err) - } - - log.Debugf("registered inputs and outputs with request id: %s", intentID) - - commitmentTxid, commitmentTx, batchExpiry, forfeitTxs, vtxoTree, err := a.handleBatchEvents( - ctx, intentID, selectedCoins, notes, selectedBoardingCoins, outputs, signerSessions, - options.eventsCh, options.cancelCh, options.keyIdsByScript, - ) - if err != nil { - if retryCount < maxRetry-1 { - time.Sleep(100 * time.Millisecond) - deleteIntent() - log.WithError(err).Warn("batch failed, retrying...") - } - retryCount++ - batchErr = err - continue - } - - ins := make([]types.Vtxo, 0, len(selectedCoins)) - for _, c := range selectedCoins { - ins = append(ins, c.Vtxo) - } - vtxoOuts := make([]types.Vtxo, 0, len(outputs)) - utxoOuts := make([]types.Receiver, 0, len(outputs)) - - now := time.Now() - var leaves []*psbt.Packet - if vtxoTree != nil { - leaves = vtxoTree.Leaves() - } - for _, output := range outputs { - if output.IsOnchain() { - utxoOuts = append(utxoOuts, output) - continue - } - - for _, leaf := range leaves { - txOut, _, err := output.ToTxOut() - if err != nil { - return nil, err - } - for i, out := range leaf.UnsignedTx.TxOut { - if bytes.Equal(txOut.PkScript, out.PkScript) { - ext, _ := extension.NewExtensionFromTx(leaf.UnsignedTx) - var assets []types.Asset - if len(ext) > 0 { - packet := ext.GetAssetPacket() - if len(packet) > 0 { - for _, asset := range packet { - for _, assetOut := range asset.Outputs { - if assetOut.Vout == uint16(i) { - assets = append(assets, types.Asset{ - AssetId: asset.AssetId.String(), - Amount: assetOut.Amount, - }) - break - } - } - } - } - } - vtxoOuts = append(vtxoOuts, types.Vtxo{ - Outpoint: types.Outpoint{ - Txid: leaf.UnsignedTx.TxID(), - VOut: uint32(i), - }, - Script: hex.EncodeToString(out.PkScript), - Amount: uint64(out.Value), - CommitmentTxids: []string{commitmentTxid}, - ExpiresAt: now.Add(batchExpiry), - CreatedAt: now, - Assets: assets, - }) - break - } - } - } - } - - return &BatchTxRes{ - CommitmentTxid: commitmentTxid, - CommitmentTx: commitmentTx, - IntentTx: proofTx, - ForfeitTxs: forfeitTxs, - VtxoInputs: ins, - UtxoInputs: selectedBoardingCoins, - VtxoOutputs: vtxoOuts, - UtxoOutputs: utxoOuts, - Extension: ext, - }, nil - } - - return nil, fmt.Errorf("reached max attempt of retries, last batch error: %s", batchErr) -} - -func (a *service) handleOptions( - options batchSessionOptions, inputs []intent.Input, notesInputs []string, -) ([]tree.SignerSession, []string, error) { - sessions := make([]tree.SignerSession, 0) - sessions = append(sessions, options.extraSignerSessions...) - - if !options.treeSignerDisabled { - outpoints := make([]types.Outpoint, 0, len(inputs)) - for _, input := range inputs { - outpoints = append(outpoints, types.Outpoint{ - Txid: input.OutPoint.Hash.String(), - VOut: uint32(input.OutPoint.Index), - }) - } - - signerSession, err := a.identity.NewVtxoTreeSigner(context.Background()) - if err != nil { - return nil, nil, err - } - sessions = append(sessions, signerSession) - } - - if len(sessions) == 0 { - return nil, nil, fmt.Errorf("no signer sessions") - } - - signerPubKeys := make([]string, 0) - for _, session := range sessions { - signerPubKeys = append(signerPubKeys, session.GetPublicKey()) - } - - return sessions, signerPubKeys, nil -} - -func (a *service) handleBatchEvents( - ctx context.Context, - intentId string, vtxos []types.VtxoWithTapTree, notes []string, boardingUtxos []types.Utxo, - receivers []types.Receiver, signerSessions []tree.SignerSession, - replayEventsCh chan<- any, cancelCh <-chan struct{}, keysByScript map[string]string, -) (string, string, time.Duration, []string, *tree.TxTree, error) { - topics := make([]string, 0) - for _, n := range notes { - parsedNote, err := note.NewNoteFromString(n) - if err != nil { - return "", "", -1, nil, nil, err - } - outpoint, _, err := parsedNote.IntentProofInput() - if err != nil { - return "", "", -1, nil, nil, err - } - topics = append(topics, outpoint.String()) - } - - for _, boardingUtxo := range boardingUtxos { - topics = append(topics, boardingUtxo.String()) - } - for _, vtxo := range vtxos { - topics = append(topics, vtxo.Outpoint.String()) - } - for _, signer := range signerSessions { - topics = append(topics, signer.GetPublicKey()) - } - - // skip only if there is no offchain output - skipVtxoTreeSigning := true - - for _, receiver := range receivers { - if _, err := arklib.DecodeAddressV0(receiver.To); err == nil { - skipVtxoTreeSigning = false - break - } - } - - options := []BatchEventHandlerOption{WithCancel(cancelCh)} - - if skipVtxoTreeSigning { - options = append(options, WithSkipVtxoTreeSigning()) - } - - if replayEventsCh != nil { - options = append(options, WithReplay(replayEventsCh)) - } - - eventsCh, close, err := a.client.GetEventStream(ctx, topics) - if err != nil { - if errors.Is(err, io.EOF) { - return "", "", -1, nil, nil, fmt.Errorf("connection closed by server") - } - return "", "", -1, nil, nil, err - } - defer close() - - batchEventsHandler := newBatchEventsHandler( - a, intentId, vtxos, boardingUtxos, receivers, signerSessions, keysByScript, - ) - - return JoinBatchSession(ctx, eventsCh, batchEventsHandler, options...) -} - -func (a *service) makeRegisterIntent( - inputs []intent.Input, assetInputs map[int][]types.Asset, - leafProofs []*arklib.TaprootMerkleProof, outputs []types.Receiver, - cosignersPublicKeys []string, arkFields [][]*psbt.Unknown, - signingRequired bool, keysByScripts map[string]string, -) (string, string, extension.Extension, error) { - message, outputsTxOut, ext, err := registerIntentMessage( - assetInputs, outputs, cosignersPublicKeys, - ) - if err != nil { - return "", "", nil, err - } - - proof, message, err := a.makeIntent( - message, inputs, outputsTxOut, leafProofs, arkFields, signingRequired, keysByScripts, - ) - if err != nil { - return "", "", nil, err - } - - return proof, message, ext, nil -} - -func (a *service) makeGetPendingTxIntent( - inputs []intent.Input, leafProofs []*arklib.TaprootMerkleProof, - arkFields [][]*psbt.Unknown, signingRequired bool, keysByScripts map[string]string, -) (string, string, error) { - message, err := intent.GetPendingTxMessage{ - BaseMessage: intent.BaseMessage{ - Type: intent.IntentMessageTypeGetPendingTx, - }, - ExpireAt: time.Now().Add(10 * time.Minute).Unix(), // valid for 10 minutes - }.Encode() - if err != nil { - return "", "", err - } - - return a.makeIntent( - message, inputs, nil, leafProofs, arkFields, signingRequired, keysByScripts, - ) -} - -func (a *service) makeDeleteIntent( - inputs []intent.Input, leafProofs []*arklib.TaprootMerkleProof, - arkFields [][]*psbt.Unknown, signingRequired bool, keysByScripts map[string]string, -) (string, string, error) { - message, err := intent.DeleteMessage{ - BaseMessage: intent.BaseMessage{ - Type: intent.IntentMessageTypeDelete, - }, - ExpireAt: time.Now().Add(2 * time.Minute).Unix(), - }.Encode() - if err != nil { - return "", "", err - } - - return a.makeIntent( - message, inputs, nil, leafProofs, arkFields, signingRequired, keysByScripts, - ) -} - -func (a *service) makeIntent( - message string, inputs []intent.Input, outputsTxOut []*wire.TxOut, - leafProofs []*arklib.TaprootMerkleProof, arkFields [][]*psbt.Unknown, - signingRequired bool, keysByScript map[string]string, -) (string, string, error) { - proof, err := intent.New(message, inputs, outputsTxOut) - if err != nil { - return "", "", err - } - - for i, input := range proof.Inputs { - // intent proof tx has an additional input using the first vtxo script - // so we need to use the previous leaf proof for the current input except for the first input - var leafProof *arklib.TaprootMerkleProof - if i == 0 { - leafProof = leafProofs[0] - } else { - leafProof = leafProofs[i-1] - input.Unknowns = arkFields[i-1] - } - input.TaprootLeafScript = []*psbt.TaprootTapLeafScript{ - { - ControlBlock: leafProof.ControlBlock, - Script: leafProof.Script, - LeafVersion: txscript.BaseLeafVersion, - }, - } - - proof.Inputs[i] = input - } - - unsignedProofTx, err := proof.B64Encode() - if err != nil { - return "", "", err - } - - if !signingRequired { - return unsignedProofTx, message, nil - } - - signedTx, err := a.identity.SignTransaction(context.Background(), unsignedProofTx, keysByScript) - if err != nil { - return "", "", err - } - - return signedTx, message, nil -} diff --git a/pkg/client-lib/batch_session_handler.go b/pkg/client-lib/batch_session_handler.go deleted file mode 100644 index c8aac192e..000000000 --- a/pkg/client-lib/batch_session_handler.go +++ /dev/null @@ -1,922 +0,0 @@ -package wallet - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "slices" - "strings" - "sync" - "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/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/ark-lib/txutils" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - log "github.com/sirupsen/logrus" -) - -const ( - start = iota - batchStarted - treeSigningStarted - treeNoncesAggregated - batchFinalization -) - -func GetEventStreamTopics( - spentOutpoints []types.Outpoint, signerSessions []tree.SignerSession, -) []string { - topics := make([]string, 0, len(spentOutpoints)) - for _, outpoint := range spentOutpoints { - topics = append(topics, outpoint.String()) - } - for _, signer := range signerSessions { - topics = append(topics, signer.GetPublicKey()) - } - return topics -} - -type BatchEventsHandler interface { - OnBatchStarted( - ctx context.Context, event client.BatchStartedEvent, - ) (bool, time.Duration, error) - OnBatchFinalized(ctx context.Context, event client.BatchFinalizedEvent) error - OnBatchFailed(ctx context.Context, event client.BatchFailedEvent) error - OnTreeTxEvent(ctx context.Context, event client.TreeTxEvent) error - OnTreeSignatureEvent(ctx context.Context, event client.TreeSignatureEvent) error - OnTreeSigningStarted( - ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree, - ) (bool, error) - OnTreeNoncesAggregated( - ctx context.Context, - event client.TreeNoncesAggregatedEvent, - ) (signed bool, err error) - OnTreeNonces(ctx context.Context, event client.TreeNoncesEvent) (signed bool, err error) - OnBatchFinalization( - ctx context.Context, - event client.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree, - ) ([]string, error) - OnStreamStarted( - ctx context.Context, event client.StreamStartedEvent, - ) error -} - -type BatchEventHandlerOption func(*options) - -func WithSkipVtxoTreeSigning() BatchEventHandlerOption { - return func(o *options) { - o.signVtxoTree = false - } -} - -func WithReplay(ch chan<- any) BatchEventHandlerOption { - return func(o *options) { - o.replayEventsCh = ch - } -} - -func WithCancel(cancelCh <-chan struct{}) BatchEventHandlerOption { - return func(o *options) { - o.cancelCh = cancelCh - } -} - -func JoinBatchSession( - ctx context.Context, eventsCh <-chan client.BatchEventChannel, - eventsHandler BatchEventsHandler, opts ...BatchEventHandlerOption, -) (string, string, time.Duration, []string, *tree.TxTree, error) { - options := newOptions() - - for _, opt := range opts { - opt(options) - } - - step := start - - // the txs of the tree are received one after the other via TxTreeEvent - // we collect them and then build the tree when necessary. - flatVtxoTree := make([]tree.TxTreeNode, 0) - flatConnectorTree := make([]tree.TxTreeNode, 0) - - var vtxoTree, connectorTree *tree.TxTree - var forfeitTxs []string - var batchExpiry time.Duration - var commitmentTx string - for { - select { - case <-options.cancelCh: - return "", "", -1, nil, nil, fmt.Errorf("canceled") - case <-ctx.Done(): - return "", "", -1, nil, nil, fmt.Errorf("context done %s", ctx.Err()) - case notify, ok := <-eventsCh: - if !ok { - return "", "", -1, nil, nil, fmt.Errorf("event stream closed") - } - if notify.Err != nil { - return "", "", -1, nil, nil, notify.Err - } - if notify.Connection != nil { - continue - } - - if options.replayEventsCh != nil { - go func() { - select { - case options.replayEventsCh <- notify.Event: - default: - } - }() - } - - switch event := notify.Event; event.(type) { - case client.StreamStartedEvent: - streamStartedEvent := event.(client.StreamStartedEvent) - if err := eventsHandler.OnStreamStarted(ctx, streamStartedEvent); err != nil { - return "", "", -1, nil, nil, err - } - case client.BatchStartedEvent: - e := event.(client.BatchStartedEvent) - skip, expiry, err := eventsHandler.OnBatchStarted(ctx, e) - if err != nil { - return "", "", -1, nil, nil, err - } - if !skip { - step++ - - // if we don't want to sign the vtxo tree, we can skip the tree signing phase - if !options.signVtxoTree { - step = treeNoncesAggregated - } - batchExpiry = expiry - continue - } - case client.BatchFinalizedEvent: - if step != batchFinalization { - continue - } - event := event.(client.BatchFinalizedEvent) - if err := eventsHandler.OnBatchFinalized(ctx, event); err != nil { - return "", "", -1, nil, nil, err - } - return event.Txid, commitmentTx, batchExpiry, forfeitTxs, vtxoTree, nil - // the batch session failed, return error only if we joined. - case client.BatchFailedEvent: - e := event.(client.BatchFailedEvent) - if err := eventsHandler.OnBatchFailed(ctx, e); err != nil { - return "", "", -1, nil, nil, err - } - continue - // we received a tree tx event msg, let's update the vtxo/connector tree. - case client.TreeTxEvent: - if step != batchStarted && step != treeNoncesAggregated { - continue - } - - treeTxEvent := event.(client.TreeTxEvent) - - if err := eventsHandler.OnTreeTxEvent(ctx, treeTxEvent); err != nil { - return "", "", -1, nil, nil, err - } - - if treeTxEvent.BatchIndex == 0 { - flatVtxoTree = append(flatVtxoTree, treeTxEvent.Node) - } else { - flatConnectorTree = append(flatConnectorTree, treeTxEvent.Node) - } - - continue - case client.TreeSignatureEvent: - if step != treeNoncesAggregated { - continue - } - if vtxoTree == nil { - return "", "", -1, nil, nil, fmt.Errorf("vtxo tree not initialized") - } - - event := event.(client.TreeSignatureEvent) - if err := eventsHandler.OnTreeSignatureEvent(ctx, event); err != nil { - return "", "", -1, nil, nil, err - } - - if err := addSignatureToTxTree(event, vtxoTree); err != nil { - return "", "", -1, nil, nil, err - } - continue - // the musig2 session started, let's send our nonces. - case client.TreeSigningStartedEvent: - if step != batchStarted { - continue - } - - var err error - vtxoTree, err = tree.NewTxTree(flatVtxoTree) - if err != nil { - return "", "", -1, nil, nil, fmt.Errorf("failed to create branch of vtxo tree: %s", err) - } - - event := event.(client.TreeSigningStartedEvent) - skip, err := eventsHandler.OnTreeSigningStarted(ctx, event, vtxoTree) - if err != nil { - return "", "", -1, nil, nil, err - } - - if !skip { - step++ - } - continue - // we received the aggregated nonces, let's send our signatures. - case client.TreeNoncesAggregatedEvent: - if step != treeSigningStarted { - continue - } - - event := event.(client.TreeNoncesAggregatedEvent) - signed, err := eventsHandler.OnTreeNoncesAggregated(ctx, event) - if err != nil { - return "", "", -1, nil, nil, err - } - - if signed { - step++ - } - continue - // we received the fully signed vtxo and connector trees, let's send our signed forfeit - // txs and optionally signed boarding utxos included in the commitment tx. - case client.TreeNoncesEvent: - if step != treeSigningStarted { - continue - } - - event := event.(client.TreeNoncesEvent) - signed, err := eventsHandler.OnTreeNonces(ctx, event) - if err != nil { - return "", "", -1, nil, nil, err - } - if signed { - step++ - } - continue - case client.BatchFinalizationEvent: - if step != treeNoncesAggregated { - continue - } - - if options.signVtxoTree && vtxoTree == nil { - return "", "", -1, nil, nil, fmt.Errorf("vtxo tree not initialized") - } - - if len(flatConnectorTree) > 0 { - var err error - connectorTree, err = tree.NewTxTree(flatConnectorTree) - if err != nil { - return "", "", -1, nil, nil, fmt.Errorf("failed to create branch of connector tree: %s", err) - } - } - - event := event.(client.BatchFinalizationEvent) - txs, err := eventsHandler.OnBatchFinalization( - ctx, event, vtxoTree, connectorTree, - ) - if err != nil { - return "", "", -1, nil, nil, err - } - forfeitTxs = txs - commitmentTx = event.Tx - - log.Debug("done.") - log.Debug("waiting for batch finalization...") - step++ - continue - } - } - } -} - -func addSignatureToTxTree( - event client.TreeSignatureEvent, txTree *tree.TxTree, -) error { - if event.BatchIndex != 0 { - return fmt.Errorf("batch index %d is not 0", event.BatchIndex) - } - - decodedSig, err := hex.DecodeString(event.Signature) - if err != nil { - return fmt.Errorf("failed to decode signature: %s", err) - } - - sig, err := schnorr.ParseSignature(decodedSig) - if err != nil { - return fmt.Errorf("failed to parse signature: %s", err) - } - - return txTree.Apply(func(g *tree.TxTree) (bool, error) { - if g.Root.UnsignedTx.TxID() != event.Txid { - return true, nil - } - - g.Root.Inputs[0].TaprootKeySpendSig = sig.Serialize() - return false, nil - }) -} - -type options struct { - signVtxoTree bool // default: true - replayEventsCh chan<- any // default: nil - cancelCh <-chan struct{} // default: nil - keysByScript map[string]string -} - -func newOptions() *options { - return &options{ - signVtxoTree: true, - replayEventsCh: nil, - cancelCh: nil, - } -} - -type defaultBatchEventsHandler struct { - *service - - intentId string - vtxos []types.VtxoWithTapTree - boardingUtxos []types.Utxo - receivers []types.Receiver - signerSessions []tree.SignerSession - keysByScript map[string]string - - batchSessionId string - batchExpiry arklib.RelativeLocktime - // internal count to handle TreeNoncesEvent - countSigningDone int -} - -func newBatchEventsHandler( - arkClient *service, - intentId string, - vtxos []types.VtxoWithTapTree, - boardingUtxos []types.Utxo, - receivers []types.Receiver, - signerSessions []tree.SignerSession, - keysByScript map[string]string, -) *defaultBatchEventsHandler { - vtxosToSign := make([]types.VtxoWithTapTree, 0, len(vtxos)) - for _, vtxo := range vtxos { - // exclude recoverable vtxos as they don't need any signing step - if vtxo.IsRecoverable() { - continue - } - vtxosToSign = append(vtxosToSign, vtxo) - } - - return &defaultBatchEventsHandler{ - service: arkClient, - intentId: intentId, - vtxos: vtxosToSign, - boardingUtxos: boardingUtxos, - receivers: receivers, - signerSessions: signerSessions, - keysByScript: keysByScript, - batchSessionId: "", - countSigningDone: 0, - } -} - -func (h *defaultBatchEventsHandler) OnStreamStarted( - ctx context.Context, - event client.StreamStartedEvent, -) error { - return nil -} - -func (h *defaultBatchEventsHandler) OnBatchStarted( - ctx context.Context, event client.BatchStartedEvent, -) (bool, time.Duration, error) { - buf := sha256.Sum256([]byte(h.intentId)) - hashedIntentId := hex.EncodeToString(buf[:]) - - for _, hash := range event.HashedIntentIds { - if hash == hashedIntentId { - if err := h.client.ConfirmRegistration(ctx, h.intentId); err != nil { - return false, -1, err - } - h.batchSessionId = event.Id - h.batchExpiry = getBatchExpiryLocktime(uint32(event.BatchExpiry)) - expiry := time.Duration(event.BatchExpiry) * time.Second - if h.batchExpiry.Type == arklib.LocktimeTypeBlock { - expiry = time.Duration(event.BatchExpiry*arklib.SECONDS_PER_BLOCK) * time.Second - } - return false, expiry, nil - } - } - log.Debug("intent id not found in batch proposal, waiting for next one...") - return true, -1, nil -} - -func (h *defaultBatchEventsHandler) OnBatchFinalized( - ctx context.Context, event client.BatchFinalizedEvent, -) error { - if event.Id == h.batchSessionId { - log.Debugf("batch completed in commitment tx %s", event.Txid) - } - return nil -} - -func (h *defaultBatchEventsHandler) OnBatchFailed( - ctx context.Context, event client.BatchFailedEvent, -) error { - return fmt.Errorf("batch failed: %s", event.Reason) -} - -func (h *defaultBatchEventsHandler) OnTreeTxEvent( - ctx context.Context, event client.TreeTxEvent, -) error { - return nil -} - -func (h *defaultBatchEventsHandler) OnTreeSignatureEvent( - ctx context.Context, event client.TreeSignatureEvent, -) error { - return nil -} - -func (h *defaultBatchEventsHandler) OnTreeSigningStarted( - ctx context.Context, event client.TreeSigningStartedEvent, vtxoTree *tree.TxTree, -) (bool, error) { - foundPubkeys := make([]string, 0, len(h.signerSessions)) - for _, session := range h.signerSessions { - myPubkey := session.GetPublicKey() - if slices.Contains(event.CosignersPubkeys, myPubkey) { - foundPubkeys = append(foundPubkeys, myPubkey) - } - } - - if len(foundPubkeys) <= 0 { - log.Debug("no signer found in cosigner list, waiting for next one...") - return true, nil - } - - if len(foundPubkeys) != len(h.signerSessions) { - return false, fmt.Errorf("not all signers found in cosigner list") - } - - sweepClosure := script.CSVMultisigClosure{ - MultisigClosure: script.MultisigClosure{PubKeys: []*btcec.PublicKey{h.ForfeitPubKey}}, - Locktime: h.batchExpiry, - } - - script, err := sweepClosure.Script() - if err != nil { - return false, err - } - - commitmentTx, err := psbt.NewFromRawBytes(strings.NewReader(event.UnsignedCommitmentTx), true) - if err != nil { - return false, err - } - - batchOutput := commitmentTx.UnsignedTx.TxOut[0] - batchOutputAmount := batchOutput.Value - - sweepTapLeaf := txscript.NewBaseTapLeaf(script) - sweepTapTree := txscript.AssembleTaprootScriptTree(sweepTapLeaf) - root := sweepTapTree.RootNode.TapHash() - - generateAndSendNonces := func(session tree.SignerSession) error { - if err := session.Init(root.CloneBytes(), batchOutputAmount, vtxoTree); err != nil { - return err - } - - nonces, err := session.GetNonces() - if err != nil { - return err - } - - return h.client.SubmitTreeNonces(ctx, event.Id, session.GetPublicKey(), nonces) - } - - errChan := make(chan error, len(h.signerSessions)) - waitGroup := sync.WaitGroup{} - waitGroup.Add(len(h.signerSessions)) - - for _, session := range h.signerSessions { - go func(session tree.SignerSession) { - defer waitGroup.Done() - if err := generateAndSendNonces(session); err != nil { - errChan <- err - } - }(session) - } - - waitGroup.Wait() - - close(errChan) - - for err := range errChan { - if err != nil { - return false, err - } - } - - return false, nil -} - -func (h *defaultBatchEventsHandler) OnTreeNonces( - ctx context.Context, event client.TreeNoncesEvent, -) (bool, error) { - log.Debugf("tree nonces event received for tx %s", event.Txid) - if len(h.signerSessions) <= 0 { - return false, fmt.Errorf("tree signer session not set") - } - - handler := func(session tree.SignerSession) (bool, error) { - hasAllNonces, err := session.AggregateNonces(event.Txid, event.Nonces) - if err != nil { - return false, err - } - - if !hasAllNonces { - return false, nil - } - - log.Debugf("all nonces aggregated, signing...") - sigs, err := session.Sign() - if err != nil { - return false, err - } - - if err := h.client.SubmitTreeSignatures( - ctx, - event.Id, - session.GetPublicKey(), - sigs, - ); err != nil { - return false, err - } - - return true, nil - } - - type res struct { - signed bool - err error - } - - resChan := make(chan res, len(h.signerSessions)) - waitGroup := sync.WaitGroup{} - waitGroup.Add(len(h.signerSessions)) - - for _, session := range h.signerSessions { - go func(session tree.SignerSession) { - defer waitGroup.Done() - signed, err := handler(session) - resChan <- res{signed, err} - }(session) - } - - waitGroup.Wait() - close(resChan) - - for res := range resChan { - if res.err != nil { - return false, res.err - } - if res.signed { - h.countSigningDone++ - if h.countSigningDone == len(h.signerSessions) { - return true, nil - } - } - } - - return false, nil -} - -func (h *defaultBatchEventsHandler) OnTreeNoncesAggregated( - ctx context.Context, event client.TreeNoncesAggregatedEvent, -) (bool, error) { - // ignore TreeNoncesAggregatedEvent as we handle it in OnTreeNoncesEvent - return false, nil -} - -func (h *defaultBatchEventsHandler) OnBatchFinalization( - ctx context.Context, event client.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree, -) ([]string, error) { - log.Debug("vtxo and connector trees fully signed, sending forfeit transactions...") - if err := h.validateVtxoTree(event, vtxoTree, connectorTree); err != nil { - return nil, fmt.Errorf("failed to verify vtxo tree: %s", err) - } - - var forfeits []string - var signedCommitmentTx string - - vtxos := h.vtxosToForfeit() - - // if we spend vtxos, we must create and sign forfeits. - if len(vtxos) > 0 && connectorTree != nil { - signedForfeits, err := h.createAndSignForfeits( - ctx, vtxos, connectorTree.Leaves(), - ) - if err != nil { - return nil, err - } - - forfeits = signedForfeits - } - - // if we spend boarding inputs, we must sign the commitment transaction. - if len(h.boardingUtxos) > 0 { - commitmentPtx, err := psbt.NewFromRawBytes(strings.NewReader(event.Tx), true) - if err != nil { - return nil, err - } - - for _, boardingUtxo := range h.boardingUtxos { - boardingVtxoScript, err := script.ParseVtxoScript(boardingUtxo.Tapscripts) - if err != nil { - return nil, err - } - - forfeitClosures := boardingVtxoScript.ForfeitClosures() - if len(forfeitClosures) <= 0 { - return nil, fmt.Errorf("no forfeit closures found") - } - - forfeitClosure := forfeitClosures[0] - - forfeitScript, err := forfeitClosure.Script() - if err != nil { - return nil, err - } - - _, taprootTree, err := boardingVtxoScript.TapTree() - if err != nil { - return nil, err - } - - forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) - forfeitProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) - if err != nil { - return nil, fmt.Errorf( - "failed to get taproot merkle proof for boarding utxo: %s", err, - ) - } - - tapscript := &psbt.TaprootTapLeafScript{ - ControlBlock: forfeitProof.ControlBlock, - Script: forfeitProof.Script, - LeafVersion: txscript.BaseLeafVersion, - } - - for i := range commitmentPtx.Inputs { - prevout := commitmentPtx.UnsignedTx.TxIn[i].PreviousOutPoint - - if boardingUtxo.Txid == prevout.Hash.String() && - boardingUtxo.VOut == prevout.Index { - commitmentPtx.Inputs[i].TaprootLeafScript = []*psbt.TaprootTapLeafScript{ - tapscript, - } - break - } - } - } - - b64, err := commitmentPtx.B64Encode() - if err != nil { - return nil, err - } - - signedCommitmentTx, err = h.identity.SignTransaction(ctx, b64, h.keysByScript) - if err != nil { - return nil, err - } - } - - if len(forfeits) > 0 || len(signedCommitmentTx) > 0 { - if err := h.client.SubmitSignedForfeitTxs( - ctx, forfeits, signedCommitmentTx, - ); err != nil { - return nil, err - } - } - - return forfeits, nil -} - -func (h *defaultBatchEventsHandler) vtxosToForfeit() []types.VtxoWithTapTree { - withoutRecoverable := make([]types.VtxoWithTapTree, 0, len(h.vtxos)) - for _, vtxo := range h.vtxos { - if !vtxo.IsRecoverable() { - withoutRecoverable = append(withoutRecoverable, vtxo) - } - } - - return withoutRecoverable -} - -func (h *defaultBatchEventsHandler) validateVtxoTree( - event client.BatchFinalizationEvent, vtxoTree, connectorTree *tree.TxTree, -) error { - commitmentTx := event.Tx - commitmentPtx, err := psbt.NewFromRawBytes(strings.NewReader(commitmentTx), true) - if err != nil { - return err - } - - // validate the vtxo tree is well formed - if !utils.IsOnchainOnly(h.receivers) { - if err := tree.ValidateVtxoTree( - vtxoTree, commitmentPtx, h.ForfeitPubKey, h.batchExpiry, - ); err != nil { - return err - } - - rootParentTxid := vtxoTree.Root.UnsignedTx.TxIn[0].PreviousOutPoint.Hash.String() - rootParentVout := vtxoTree.Root.UnsignedTx.TxIn[0].PreviousOutPoint.Index - - if rootParentTxid != commitmentPtx.UnsignedTx.TxID() { - return fmt.Errorf( - "root's parent txid is not the same as the commitment txid: %s != %s", - rootParentTxid, - commitmentPtx.UnsignedTx.TxID(), - ) - } - - if rootParentVout != 0 { - return fmt.Errorf( - "root's parent vout is not the same as the shared output index: %d != %d", - rootParentVout, - 0, - ) - } - } - - // validate it contains our outputs - if err := validateReceivers(h.Network, commitmentPtx, h.receivers, vtxoTree); err != nil { - return err - } - - vtxos := h.vtxosToForfeit() - - if len(vtxos) > 0 { - if connectorTree != nil { - if err := connectorTree.Validate(); err != nil { - return err - } - } - - if connectorTree != nil { - connectorsLeaves := connectorTree.Leaves() - if len(connectorsLeaves) != len(vtxos) { - return fmt.Errorf( - "unexpected num of connectors received: expected %d, got %d", - len(vtxos), - len(connectorsLeaves), - ) - } - } - } - - return nil -} - -func (h *defaultBatchEventsHandler) createAndSignForfeits( - ctx context.Context, vtxosToSign []types.VtxoWithTapTree, connectorsLeaves []*psbt.Packet, -) ([]string, error) { - parsedForfeitAddr, err := btcutil.DecodeAddress(h.ForfeitAddress, nil) - if err != nil { - return nil, err - } - - forfeitPkScript, err := txscript.PayToAddrScript(parsedForfeitAddr) - if err != nil { - return nil, err - } - - signedForfeitTxs := make([]string, 0, len(vtxosToSign)) - for i, vtxo := range vtxosToSign { - connectorTx := connectorsLeaves[i] - - var connector *wire.TxOut - var connectorOutpoint *wire.OutPoint - for outIndex, output := range connectorTx.UnsignedTx.TxOut { - if bytes.Equal(txutils.ANCHOR_PKSCRIPT, output.PkScript) { - continue - } - - connector = output - connectorOutpoint = &wire.OutPoint{ - Hash: connectorTx.UnsignedTx.TxHash(), - Index: uint32(outIndex), - } - break - } - - if connector == nil { - return nil, fmt.Errorf("connector not found for vtxo %s", vtxo.Outpoint.String()) - } - - vtxoScript, err := script.ParseVtxoScript(vtxo.Tapscripts) - if err != nil { - return nil, err - } - - vtxoTapKey, vtxoTapTree, err := vtxoScript.TapTree() - if err != nil { - return nil, err - } - - vtxoOutputScript, err := script.P2TRScript(vtxoTapKey) - if err != nil { - return nil, err - } - - vtxoTxHash, err := chainhash.NewHashFromStr(vtxo.Txid) - if err != nil { - return nil, err - } - - vtxoInput := &wire.OutPoint{ - Hash: *vtxoTxHash, - Index: vtxo.VOut, - } - - forfeitClosures := vtxoScript.ForfeitClosures() - if len(forfeitClosures) <= 0 { - return nil, fmt.Errorf("no forfeit closures found") - } - - forfeitClosure := forfeitClosures[0] - - forfeitScript, err := forfeitClosure.Script() - if err != nil { - return nil, err - } - - forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) - leafProof, err := vtxoTapTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) - if err != nil { - return nil, err - } - - tapscript := psbt.TaprootTapLeafScript{ - ControlBlock: leafProof.ControlBlock, - Script: leafProof.Script, - LeafVersion: txscript.BaseLeafVersion, - } - - vtxoLocktime := arklib.AbsoluteLocktime(0) - if cltv, ok := forfeitClosure.(*script.CLTVMultisigClosure); ok { - vtxoLocktime = cltv.Locktime - } - - vtxoPrevout := &wire.TxOut{ - Value: int64(vtxo.Amount), - PkScript: vtxoOutputScript, - } - - vtxoSequence := wire.MaxTxInSequenceNum - if vtxoLocktime != 0 { - vtxoSequence = wire.MaxTxInSequenceNum - 1 - } - - forfeitTx, err := tree.BuildForfeitTx( - []*wire.OutPoint{vtxoInput, connectorOutpoint}, - []uint32{vtxoSequence, wire.MaxTxInSequenceNum}, - []*wire.TxOut{vtxoPrevout, connector}, - forfeitPkScript, - uint32(vtxoLocktime), - ) - if err != nil { - return nil, err - } - - forfeitTx.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{&tapscript} - - b64, err := forfeitTx.B64Encode() - if err != nil { - return nil, err - } - - signedForfeitTx, err := h.identity.SignTransaction(ctx, b64, h.keysByScript) - if err != nil { - return nil, err - } - - signedForfeitTxs = append(signedForfeitTxs, signedForfeitTx) - } - - return signedForfeitTxs, nil -} diff --git a/pkg/client-lib/batch_session_opts.go b/pkg/client-lib/batch_session_opts.go deleted file mode 100644 index 8c5afa415..000000000 --- a/pkg/client-lib/batch_session_opts.go +++ /dev/null @@ -1,133 +0,0 @@ -package wallet - -import ( - "fmt" - - "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/types" -) - -const ( - defaultExpiryThreshold int64 = 3 * 24 * 60 * 60 // 3 days - maxRetries int = 3 -) - -type BatchSessionOption interface { - applyBatch(*batchSessionOptions) error -} - -type batchOptFn func(*batchSessionOptions) error - -func (f batchOptFn) applyBatch(o *batchSessionOptions) error { return f(o) } - -// name alias, sub-dust vtxos are recoverable vtxos -var WithSubDustVtxos = WithRecoverableVtxos - -func WithRecoverableVtxos() BatchSessionOption { - return batchOptFn(func(o *batchSessionOptions) error { - o.withRecoverableVtxos = true - return nil - }) -} - -func WithEventsCh(ch chan<- any) BatchSessionOption { - return batchOptFn(func(o *batchSessionOptions) error { - if o.eventsCh != nil { - return fmt.Errorf("events channel already set") - } - o.eventsCh = ch - return nil - }) -} - -// WithoutTreeSigner disables the tree signer for the batch session -func WithoutTreeSigner() BatchSessionOption { - return batchOptFn(func(o *batchSessionOptions) error { - o.treeSignerDisabled = true - return nil - }) -} - -// WithExtraSigner allows to use a set of custom signer for the vtxo tree signing process -func WithExtraSigner(signerSessions ...tree.SignerSession) BatchSessionOption { - return batchOptFn(func(o *batchSessionOptions) error { - if len(signerSessions) == 0 { - return fmt.Errorf("no signer sessions provided") - } - o.extraSignerSessions = signerSessions - return nil - }) -} - -// WithCancelCh allows to cancel the settlement process -func WithCancelCh(ch <-chan struct{}) BatchSessionOption { - return batchOptFn(func(o *batchSessionOptions) error { - o.cancelCh = ch - return nil - }) -} - -func WithExpiryThreshold(threshold int64) BatchSessionOption { - return batchOptFn(func(o *batchSessionOptions) error { - o.expiryThreshold = threshold - return nil - }) -} - -func WithRetries(num int) BatchSessionOption { - return batchOptFn(func(o *batchSessionOptions) error { - if o.retryNum > 0 { - return fmt.Errorf("retry num already set") - } - if num <= 0 || num > maxRetries { - return fmt.Errorf("retry num must be in range [1, %d]", maxRetries) - } - o.retryNum = num - return nil - }) -} - -func WithFunds(boardingUtxos []types.Utxo, vtxos []types.VtxoWithTapTree) BatchSessionOption { - return batchOptFn(func(o *batchSessionOptions) error { - if len(boardingUtxos) <= 0 && len(vtxos) <= 0 { - return fmt.Errorf("missing funds") - } - if len(boardingUtxos) > 0 { - if len(o.boardingUtxos) > 0 { - return fmt.Errorf("boarding utxos already set") - } - o.boardingUtxos = make([]types.Utxo, len(boardingUtxos)) - copy(o.boardingUtxos, boardingUtxos) - } - if len(vtxos) > 0 { - if len(o.vtxos) > 0 { - return fmt.Errorf("vtxos already set") - } - o.vtxos = make([]types.VtxoWithTapTree, len(vtxos)) - copy(o.vtxos, vtxos) - } - return nil - }) -} - -// batchSessionOptions allows to customize the vtxo signing process -type batchSessionOptions struct { - extraSignerSessions []tree.SignerSession - treeSignerDisabled bool - withRecoverableVtxos bool - expiryThreshold int64 // In seconds - retryNum int - boardingUtxos []types.Utxo - vtxos []types.VtxoWithTapTree - keyIdsByScript map[string]string - receiver string - - cancelCh <-chan struct{} - eventsCh chan<- any -} - -func newDefaultSettleOptions() *batchSessionOptions { - return &batchSessionOptions{ - expiryThreshold: defaultExpiryThreshold, - } -} diff --git a/pkg/client-lib/client.go b/pkg/client-lib/client.go new file mode 100644 index 000000000..0392db665 --- /dev/null +++ b/pkg/client-lib/client.go @@ -0,0 +1,172 @@ +package clientlib + +import ( + "context" + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/tree" +) + +const ( + GrpcClient = "grpc" +) + +var ( + ErrConnectionClosedByServer = fmt.Errorf("connection closed by server") +) + +type AcceptedOffchainTx struct { + Txid string + FinalArkTx string + SignedCheckpointTxs []string +} + +type Client interface { + GetInfo(ctx context.Context) (*Info, error) + RegisterIntent(ctx context.Context, proof, message string) (string, error) + DeleteIntent(ctx context.Context, proof, message string) error + EstimateIntentFee(ctx context.Context, proof, message string) (int64, error) + ConfirmRegistration(ctx context.Context, intentID string) error + SubmitTreeNonces( + ctx context.Context, batchId, cosignerPubkey string, nonces tree.TreeNonces, + ) error + SubmitTreeSignatures( + ctx context.Context, batchId, cosignerPubkey string, signatures tree.TreePartialSigs, + ) error + SubmitSignedForfeitTxs( + ctx context.Context, signedForfeitTxs []string, signedCommitmentTx string, + ) error + GetEventStream(ctx context.Context, topics []string) (<-chan BatchEventChannel, func(), error) + SubmitTx(ctx context.Context, signedArkTx string, checkpointTxs []string) ( + // TODO SubmitTx should return AcceptedOffchainTx struct + arkTxid, finalArkTx string, signedCheckpointTxs []string, err error, + ) + FinalizeTx(ctx context.Context, arkTxid string, finalCheckpointTxs []string) error + GetPendingTx(ctx context.Context, proof, message string) ([]AcceptedOffchainTx, error) + GetTransactionsStream(ctx context.Context) (<-chan TransactionEvent, func(), error) + ModifyStreamTopics( + ctx context.Context, addTopics, removeTopics []string, + ) (addedTopics, removedTopics, allTopics []string, err error) + OverwriteStreamTopics( + ctx context.Context, topics []string, + ) (addedTopics, removedTopics, allTopics []string, err error) + Close() +} + +type Info struct { + Version string + SignerPubKey string + ForfeitPubKey string + UnilateralExitDelay int64 + BoardingExitDelay int64 + SessionDuration int64 + Network string + Dust uint64 + ForfeitAddress string + ScheduledSessionStartTime int64 + ScheduledSessionEndTime int64 + ScheduledSessionPeriod int64 + ScheduledSessionDuration int64 + ScheduledSessionFees FeeInfo + UtxoMinAmount int64 + UtxoMaxAmount int64 + VtxoMinAmount int64 + VtxoMaxAmount int64 + CheckpointTapscript string + MaxTxWeight int64 + MaxOpReturnOutputs int64 + Fees FeeInfo + DeprecatedSignerPubKeys []DeprecatedSignerInfo + ServiceStatus map[string]string + Digest string +} + +type DeprecatedSignerInfo struct { + PubKey string + CutoffDate int64 +} + +type BatchEventChannel struct { + Event any + Connection *StreamConnectionEvent + Err error +} + +type BatchFinalizationEvent struct { + Id string + Tx string +} + +type BatchFinalizedEvent struct { + Id string + Txid string +} + +type BatchFailedEvent struct { + Id string + Reason string +} + +type TreeSigningStartedEvent struct { + Id string + UnsignedCommitmentTx string + CosignersPubkeys []string +} + +type TreeNoncesAggregatedEvent struct { + Id string + Nonces tree.TreeNonces +} + +type TreeNoncesEvent struct { + Id string + Topic []string + Txid string + Nonces map[string]*tree.Musig2Nonce +} + +type TreeTxEvent struct { + Id string + Topic []string + BatchIndex int32 + Node tree.TxTreeNode +} + +type TreeSignatureEvent struct { + Id string + Topic []string + BatchIndex int32 + Txid string + Signature string +} + +type StreamStartedEvent struct { + Id string +} + +type BatchStartedEvent struct { + Id string + HashedIntentIds []string + BatchExpiry int64 +} + +type TransactionEvent struct { + CommitmentTx *TxNotification + ArkTx *TxNotification + SweepTx *TxNotification + Connection *StreamConnectionEvent + Err error +} + +type TxData struct { + Txid string + Tx string +} + +type TxNotification struct { + TxData + SpentVtxos []Vtxo + SpendableVtxos []Vtxo + CheckpointTxs map[Outpoint]TxData + SweptVtxos []Outpoint +} diff --git a/pkg/client-lib/client/client.go b/pkg/client-lib/client/client.go index 49bde30c0..c085c419b 100644 --- a/pkg/client-lib/client/client.go +++ b/pkg/client-lib/client/client.go @@ -3,172 +3,619 @@ package client import ( "context" "fmt" + "strconv" + "strings" + "sync" + "time" + arkv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/ark/v1" + "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" + "github.com/btcsuite/btcd/wire" + "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" ) -const ( - GrpcClient = "grpc" - RestClient = "rest" -) +type grpcClient struct { + conn *grpc.ClientConn + connMu *sync.RWMutex + listenerMu *sync.RWMutex + listenerId string +} -var ( - ErrConnectionClosedByServer = fmt.Errorf("connection closed by server") -) +func NewClient(serverUrl string) (clientlib.Client, error) { + if len(serverUrl) <= 0 { + return nil, fmt.Errorf("missing server url") + } + + port := 80 + creds := insecure.NewCredentials() + serverUrl = strings.TrimPrefix(serverUrl, "http://") + if strings.HasPrefix(serverUrl, "https://") { + serverUrl = strings.TrimPrefix(serverUrl, "https://") + creds = credentials.NewTLS(nil) + port = 443 + } + if !strings.Contains(serverUrl, ":") { + serverUrl = fmt.Sprintf("%s:%d", serverUrl, port) + } + + options := []grpc.DialOption{ + grpc.WithTransportCredentials(creds), + grpc.WithDisableServiceConfig(), + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(20 << 20)), + grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 1 * time.Second, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 10 * time.Second, + }, + MinConnectTimeout: 3 * time.Second, + }), + } + + conn, err := grpc.NewClient(serverUrl, options...) + if err != nil { + return nil, err + } -type AcceptedOffchainTx struct { - Txid string - FinalArkTx string - SignedCheckpointTxs []string -} - -type Client interface { - GetInfo(ctx context.Context) (*Info, error) - RegisterIntent(ctx context.Context, proof, message string) (string, error) - DeleteIntent(ctx context.Context, proof, message string) error - EstimateIntentFee(ctx context.Context, proof, message string) (int64, error) - ConfirmRegistration(ctx context.Context, intentID string) error - SubmitTreeNonces( - ctx context.Context, batchId, cosignerPubkey string, nonces tree.TreeNonces, - ) error - SubmitTreeSignatures( - ctx context.Context, batchId, cosignerPubkey string, signatures tree.TreePartialSigs, - ) error - SubmitSignedForfeitTxs( - ctx context.Context, signedForfeitTxs []string, signedCommitmentTx string, - ) error - GetEventStream(ctx context.Context, topics []string) (<-chan BatchEventChannel, func(), error) - SubmitTx(ctx context.Context, signedArkTx string, checkpointTxs []string) ( - // TODO SubmitTx should return AcceptedOffchainTx struct - arkTxid, finalArkTx string, signedCheckpointTxs []string, err error, + client := &grpcClient{ + conn: conn, + connMu: &sync.RWMutex{}, + listenerMu: &sync.RWMutex{}, + listenerId: "", + } + + return client, nil +} + +func (a *grpcClient) GetInfo(ctx context.Context) (*clientlib.Info, error) { + req := &arkv1.GetInfoRequest{} + resp, err := a.svc().GetInfo(ctx, req) + if err != nil { + return nil, err + } + fees, err := parseFees(resp.GetFees()) + if err != nil { + return nil, err + } + var ( + ssStartTime, ssEndTime, ssPeriod, ssDuration int64 + ssFees clientlib.FeeInfo ) - FinalizeTx(ctx context.Context, arkTxid string, finalCheckpointTxs []string) error - GetPendingTx(ctx context.Context, proof, message string) ([]AcceptedOffchainTx, error) - GetTransactionsStream(ctx context.Context) (<-chan TransactionEvent, func(), error) - ModifyStreamTopics( - ctx context.Context, addTopics, removeTopics []string, - ) (addedTopics, removedTopics, allTopics []string, err error) - OverwriteStreamTopics( - ctx context.Context, topics []string, - ) (addedTopics, removedTopics, allTopics []string, err error) - Close() + if ss := resp.GetScheduledSession(); ss != nil { + ssStartTime = ss.GetNextStartTime() + ssEndTime = ss.GetNextEndTime() + ssPeriod = ss.GetPeriod() + ssDuration = ss.GetDuration() + ssFees, err = parseFees(ss.GetFees()) + if err != nil { + return nil, err + } + } + var deprecatedSigners []clientlib.DeprecatedSignerInfo + for _, s := range resp.GetDeprecatedSigners() { + if s == nil { + continue + } + deprecatedSigners = append(deprecatedSigners, clientlib.DeprecatedSignerInfo{ + PubKey: s.GetPubkey(), + CutoffDate: s.GetCutoffDate(), + }) + } + return &clientlib.Info{ + SignerPubKey: resp.GetSignerPubkey(), + ForfeitPubKey: resp.GetForfeitPubkey(), + UnilateralExitDelay: resp.GetUnilateralExitDelay(), + SessionDuration: resp.GetSessionDuration(), + Network: resp.GetNetwork(), + Dust: uint64(resp.GetDust()), + BoardingExitDelay: resp.GetBoardingExitDelay(), + ForfeitAddress: resp.GetForfeitAddress(), + Version: resp.GetVersion(), + ScheduledSessionStartTime: ssStartTime, + ScheduledSessionEndTime: ssEndTime, + ScheduledSessionPeriod: ssPeriod, + ScheduledSessionDuration: ssDuration, + ScheduledSessionFees: ssFees, + UtxoMinAmount: resp.GetUtxoMinAmount(), + UtxoMaxAmount: resp.GetUtxoMaxAmount(), + VtxoMinAmount: resp.GetVtxoMinAmount(), + VtxoMaxAmount: resp.GetVtxoMaxAmount(), + CheckpointTapscript: resp.GetCheckpointTapscript(), + DeprecatedSignerPubKeys: deprecatedSigners, + MaxTxWeight: resp.GetMaxTxWeight(), + MaxOpReturnOutputs: resp.GetMaxOpReturnOutputs(), + Fees: fees, + ServiceStatus: resp.GetServiceStatus(), + Digest: resp.GetDigest(), + }, nil +} + +func (a *grpcClient) RegisterIntent( + ctx context.Context, + proof, message string, +) (string, error) { + req := &arkv1.RegisterIntentRequest{ + Intent: &arkv1.Intent{ + Message: message, + Proof: proof, + }, + } + + resp, err := a.svc().RegisterIntent(ctx, req) + if err != nil { + return "", err + } + return resp.GetIntentId(), nil +} + +func (a *grpcClient) DeleteIntent(ctx context.Context, proof, message string) error { + req := &arkv1.DeleteIntentRequest{ + Intent: &arkv1.Intent{ + Message: message, + Proof: proof, + }, + } + _, err := a.svc().DeleteIntent(ctx, req) + if err != nil { + return err + } + return nil } -type Info struct { - Version string - SignerPubKey string - ForfeitPubKey string - UnilateralExitDelay int64 - BoardingExitDelay int64 - SessionDuration int64 - Network string - Dust uint64 - ForfeitAddress string - ScheduledSessionStartTime int64 - ScheduledSessionEndTime int64 - ScheduledSessionPeriod int64 - ScheduledSessionDuration int64 - ScheduledSessionFees types.FeeInfo - UtxoMinAmount int64 - UtxoMaxAmount int64 - VtxoMinAmount int64 - VtxoMaxAmount int64 - CheckpointTapscript string - MaxTxWeight int64 - MaxOpReturnOutputs int64 - Fees types.FeeInfo - DeprecatedSignerPubKeys []DeprecatedSigner - ServiceStatus map[string]string - Digest string +func (a *grpcClient) EstimateIntentFee(ctx context.Context, proof, message string) (int64, error) { + req := &arkv1.EstimateIntentFeeRequest{ + Intent: &arkv1.Intent{ + Message: message, + Proof: proof, + }, + } + resp, err := a.svc().EstimateIntentFee(ctx, req) + if err != nil { + return -1, err + } + return resp.GetFee(), nil } -type DeprecatedSigner struct { - PubKey string - CutoffDate int64 +func (a *grpcClient) ConfirmRegistration(ctx context.Context, intentID string) error { + req := &arkv1.ConfirmRegistrationRequest{ + IntentId: intentID, + } + _, err := a.svc().ConfirmRegistration(ctx, req) + if err != nil { + return err + } + return nil +} + +func (a *grpcClient) SubmitTreeNonces( + ctx context.Context, batchId, cosignerPubkey string, nonces tree.TreeNonces, +) error { + req := &arkv1.SubmitTreeNoncesRequest{ + BatchId: batchId, + Pubkey: cosignerPubkey, + TreeNonces: nonces.ToMap(), + } + + if _, err := a.svc().SubmitTreeNonces(ctx, req); err != nil { + return err + } + + return nil +} + +func (a *grpcClient) SubmitTreeSignatures( + ctx context.Context, batchId, cosignerPubkey string, signatures tree.TreePartialSigs, +) error { + sigs, err := signatures.ToMap() + if err != nil { + return err + } + + req := &arkv1.SubmitTreeSignaturesRequest{ + BatchId: batchId, + Pubkey: cosignerPubkey, + TreeSignatures: sigs, + } + + if _, err := a.svc().SubmitTreeSignatures(ctx, req); err != nil { + return err + } + + return nil } -type BatchEventChannel struct { - Event any - Connection *types.StreamConnectionEvent - Err error +func (a *grpcClient) SubmitSignedForfeitTxs( + ctx context.Context, signedForfeitTxs []string, signedCommitmentTx string, +) error { + req := &arkv1.SubmitSignedForfeitTxsRequest{ + SignedForfeitTxs: signedForfeitTxs, + SignedCommitmentTx: signedCommitmentTx, + } + + _, err := a.svc().SubmitSignedForfeitTxs(ctx, req) + if err != nil { + return err + } + return nil } -type BatchFinalizationEvent struct { - Id string - Tx string +func (a *grpcClient) GetEventStream( + ctx context.Context, topics []string, +) (<-chan clientlib.BatchEventChannel, func(), error) { + req := &arkv1.GetEventStreamRequest{Topics: topics} + + return utils.StartReconnectingStream(ctx, utils.ReconnectingStreamConfig[ + arkv1.ArkService_GetEventStreamClient, + *arkv1.GetEventStreamResponse, + clientlib.BatchEventChannel, + ]{ + Connect: func(ctx context.Context) (arkv1.ArkService_GetEventStreamClient, error) { + return a.svc().GetEventStream(ctx, req) + }, + Reconnect: func(ctx context.Context) (string, arkv1.ArkService_GetEventStreamClient, error) { + stream, err := a.svc().GetEventStream(ctx, req) + return "", stream, err + }, + Recv: func(stream arkv1.ArkService_GetEventStreamClient) (**arkv1.GetEventStreamResponse, error) { + str, err := stream.Recv() + if err != nil { + return nil, err + } + return &str, nil + }, + HandleResp: func( + ctx context.Context, + eventsCh chan<- clientlib.BatchEventChannel, + resp *arkv1.GetEventStreamResponse, + ) error { + if started := resp.GetStreamStarted(); started != nil { + a.setListenerID(started.GetId()) + } + + ev, err := event{resp}.toBatchEvent() + if err != nil { + return err + } + if ev == nil { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case eventsCh <- clientlib.BatchEventChannel{Event: ev}: + return nil + } + }, + ErrorEvent: func(err error) clientlib.BatchEventChannel { + return clientlib.BatchEventChannel{Err: err} + }, + ConnectionEvent: func(event utils.ReconnectingStreamStateEvent) clientlib.BatchEventChannel { + return clientlib.BatchEventChannel{ + Connection: &clientlib.StreamConnectionEvent{ + State: toClientStreamConnectionState(event.State), + At: event.At, + DisconnectedAt: event.DisconnectedAt, + Err: event.Err, + }, + } + }, + OnDisconnect: func(error) { + a.setListenerID("") + }, + }) } -type BatchFinalizedEvent struct { - Id string - Txid string +func (a *grpcClient) SubmitTx( + ctx context.Context, signedArkTx string, checkpointTxs []string, +) (string, string, []string, error) { + req := &arkv1.SubmitTxRequest{ + SignedArkTx: signedArkTx, + CheckpointTxs: checkpointTxs, + } + + resp, err := a.svc().SubmitTx(ctx, req) + if err != nil { + return "", "", nil, err + } + + return resp.GetArkTxid(), resp.GetFinalArkTx(), resp.GetSignedCheckpointTxs(), nil } -type BatchFailedEvent struct { - Id string - Reason string +func (a *grpcClient) FinalizeTx( + ctx context.Context, arkTxid string, finalCheckpointTxs []string, +) error { + req := &arkv1.FinalizeTxRequest{ + ArkTxid: arkTxid, + FinalCheckpointTxs: finalCheckpointTxs, + } + + _, err := a.svc().FinalizeTx(ctx, req) + if err != nil { + return err + } + return nil } -type TreeSigningStartedEvent struct { - Id string - UnsignedCommitmentTx string - CosignersPubkeys []string +func (a *grpcClient) GetPendingTx( + ctx context.Context, + proof, message string, +) ([]clientlib.AcceptedOffchainTx, error) { + req := &arkv1.GetPendingTxRequest{ + Identifier: &arkv1.GetPendingTxRequest_Intent{ + Intent: &arkv1.Intent{ + Message: message, + Proof: proof, + }, + }, + } + + resp, err := a.svc().GetPendingTx(ctx, req) + if err != nil { + return nil, err + } + + pendingTxs := make([]clientlib.AcceptedOffchainTx, 0, len(resp.GetPendingTxs())) + for _, tx := range resp.GetPendingTxs() { + pendingTxs = append(pendingTxs, clientlib.AcceptedOffchainTx{ + Txid: tx.GetArkTxid(), + FinalArkTx: tx.GetFinalArkTx(), + SignedCheckpointTxs: tx.GetSignedCheckpointTxs(), + }) + } + return pendingTxs, nil } -type TreeNoncesAggregatedEvent struct { - Id string - Nonces tree.TreeNonces +func (c *grpcClient) GetTransactionsStream( + ctx context.Context, +) (<-chan clientlib.TransactionEvent, func(), error) { + req := &arkv1.GetTransactionsStreamRequest{} + + return utils.StartReconnectingStream(ctx, utils.ReconnectingStreamConfig[ + arkv1.ArkService_GetTransactionsStreamClient, + *arkv1.GetTransactionsStreamResponse, + clientlib.TransactionEvent, + ]{ + Connect: func(ctx context.Context) (arkv1.ArkService_GetTransactionsStreamClient, error) { + return c.svc().GetTransactionsStream(ctx, req) + }, + Reconnect: func( + ctx context.Context, + ) (string, arkv1.ArkService_GetTransactionsStreamClient, error) { + stream, err := c.svc().GetTransactionsStream(ctx, req) + return "", stream, err + }, + Recv: func( + stream arkv1.ArkService_GetTransactionsStreamClient, + ) (**arkv1.GetTransactionsStreamResponse, error) { + str, err := stream.Recv() + if err != nil { + return nil, err + } + return &str, nil + }, + HandleResp: func( + ctx context.Context, + eventsCh chan<- clientlib.TransactionEvent, + resp *arkv1.GetTransactionsStreamResponse, + ) error { + switch tx := resp.GetData().(type) { + case *arkv1.GetTransactionsStreamResponse_CommitmentTx: + select { + case <-ctx.Done(): + return ctx.Err() + case eventsCh <- clientlib.TransactionEvent{ + CommitmentTx: &clientlib.TxNotification{ + TxData: clientlib.TxData{ + Txid: tx.CommitmentTx.GetTxid(), + Tx: tx.CommitmentTx.GetTx(), + }, + SpentVtxos: vtxos(tx.CommitmentTx.SpentVtxos).toVtxos(), + SpendableVtxos: vtxos(tx.CommitmentTx.SpendableVtxos).toVtxos(), + }, + }: + return nil + } + case *arkv1.GetTransactionsStreamResponse_ArkTx: + checkpointTxs := make(map[clientlib.Outpoint]clientlib.TxData) + for k, v := range tx.ArkTx.CheckpointTxs { + out, parseErr := wire.NewOutPointFromString(k) + if parseErr != nil { + return fmt.Errorf("invalid checkpoint outpoint %q: %w", k, parseErr) + } + checkpointTxs[clientlib.Outpoint{ + Txid: out.Hash.String(), + VOut: out.Index, + }] = clientlib.TxData{ + Txid: v.GetTxid(), + Tx: v.GetTx(), + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case eventsCh <- clientlib.TransactionEvent{ + ArkTx: &clientlib.TxNotification{ + TxData: clientlib.TxData{ + Txid: tx.ArkTx.GetTxid(), + Tx: tx.ArkTx.GetTx(), + }, + SpentVtxos: vtxos(tx.ArkTx.SpentVtxos).toVtxos(), + SpendableVtxos: vtxos(tx.ArkTx.SpendableVtxos).toVtxos(), + CheckpointTxs: checkpointTxs, + }, + }: + return nil + } + case *arkv1.GetTransactionsStreamResponse_SweepTx: + sweptVtxos := make([]clientlib.Outpoint, 0, len(tx.SweepTx.SweptVtxos)) + for _, o := range tx.SweepTx.SweptVtxos { + sweptVtxos = append(sweptVtxos, clientlib.Outpoint{ + Txid: o.GetTxid(), + VOut: o.GetVout(), + }) + } + select { + case <-ctx.Done(): + return ctx.Err() + case eventsCh <- clientlib.TransactionEvent{ + SweepTx: &clientlib.TxNotification{ + TxData: clientlib.TxData{ + Txid: tx.SweepTx.GetTxid(), + Tx: tx.SweepTx.GetTx(), + }, + SpentVtxos: vtxos(tx.SweepTx.SpentVtxos).toVtxos(), + SpendableVtxos: vtxos(tx.SweepTx.SpendableVtxos).toVtxos(), + SweptVtxos: sweptVtxos, + }, + }: + return nil + } + default: + return nil + } + }, + ErrorEvent: func(err error) clientlib.TransactionEvent { + return clientlib.TransactionEvent{Err: err} + }, + ConnectionEvent: func(event utils.ReconnectingStreamStateEvent) clientlib.TransactionEvent { + return clientlib.TransactionEvent{ + Connection: &clientlib.StreamConnectionEvent{ + State: toClientStreamConnectionState(event.State), + At: event.At, + DisconnectedAt: event.DisconnectedAt, + Err: event.Err, + }, + } + }, + }) } -type TreeNoncesEvent struct { - Id string - Topic []string - Txid string - Nonces map[string]*tree.Musig2Nonce +func (c *grpcClient) ModifyStreamTopics( + ctx context.Context, addTopics, removeTopics []string, +) (addedTopics, removedTopics, allTopics []string, err error) { + listenerID := c.getListenerID() + if listenerID == "" { + return nil, nil, nil, fmt.Errorf("listenerId is not set; cannot modify stream topics") + } + + req := &arkv1.UpdateStreamTopicsRequest{ + StreamId: listenerID, + TopicsChange: &arkv1.UpdateStreamTopicsRequest_Modify{ + Modify: &arkv1.ModifyTopics{ + AddTopics: addTopics, + RemoveTopics: removeTopics, + }, + }, + } + updateRes, err := c.svc().UpdateStreamTopics(ctx, req) + if err != nil { + return nil, nil, nil, err + } + + return updateRes.GetTopicsAdded(), updateRes.GetTopicsRemoved(), updateRes.GetAllTopics(), nil } -type TreeTxEvent struct { - Id string - Topic []string - BatchIndex int32 - Node tree.TxTreeNode +func (c *grpcClient) OverwriteStreamTopics( + ctx context.Context, topics []string, +) (addedTopics, removedTopics, allTopics []string, err error) { + listenerID := c.getListenerID() + if listenerID == "" { + return nil, nil, nil, fmt.Errorf("listenerId is not set; cannot overwrite stream topics") + } + + req := &arkv1.UpdateStreamTopicsRequest{ + StreamId: listenerID, + TopicsChange: &arkv1.UpdateStreamTopicsRequest_Overwrite{ + Overwrite: &arkv1.OverwriteTopics{ + Topics: topics, + }, + }, + } + updateRes, err := c.svc().UpdateStreamTopics(ctx, req) + if err != nil { + return nil, nil, nil, err + } + + return updateRes.GetTopicsAdded(), updateRes.GetTopicsRemoved(), updateRes.GetAllTopics(), nil } -type TreeSignatureEvent struct { - Id string - Topic []string - BatchIndex int32 - Txid string - Signature string +func (c *grpcClient) Close() { + c.connMu.Lock() + defer c.connMu.Unlock() + // nolint:errcheck + c.conn.Close() } -type StreamStartedEvent struct { - Id string +func (a *grpcClient) svc() arkv1.ArkServiceClient { + a.connMu.RLock() + defer a.connMu.RUnlock() + + return arkv1.NewArkServiceClient(a.conn) } -type BatchStartedEvent struct { - Id string - HashedIntentIds []string - BatchExpiry int64 +func (a *grpcClient) getListenerID() string { + a.listenerMu.RLock() + defer a.listenerMu.RUnlock() + + return a.listenerId } -type TransactionEvent struct { - CommitmentTx *TxNotification - ArkTx *TxNotification - SweepTx *TxNotification - Connection *types.StreamConnectionEvent - Err error +func (a *grpcClient) setListenerID(id string) { + a.listenerMu.Lock() + defer a.listenerMu.Unlock() + + a.listenerId = id } -type TxData struct { - Txid string - Tx string +func toClientStreamConnectionState( + state utils.ReconnectingStreamState, +) clientlib.StreamConnectionState { + switch state { + case utils.ReconnectingStreamStateDisconnected: + return clientlib.StreamConnectionStateDisconnected + case utils.ReconnectingStreamStateReconnected: + return clientlib.StreamConnectionStateReconnected + default: + return clientlib.StreamConnectionState(state) + } } -type TxNotification struct { - TxData - SpentVtxos []types.Vtxo - SpendableVtxos []types.Vtxo - CheckpointTxs map[types.Outpoint]TxData - SweptVtxos []types.Outpoint +func parseFees(fees *arkv1.FeeInfo) (clientlib.FeeInfo, error) { + if fees == nil { + return clientlib.FeeInfo{}, nil + } + + var ( + err error + txFeeRate float64 + ) + if fees.GetTxFeeRate() != "" { + txFeeRate, err = strconv.ParseFloat(fees.GetTxFeeRate(), 64) + if err != nil { + return clientlib.FeeInfo{}, err + } + } + + var intentFees arkfee.Config + if f := fees.GetIntentFee(); f != nil { + intentFees = arkfee.Config{ + IntentOffchainInputProgram: f.GetOffchainInput(), + IntentOffchainOutputProgram: f.GetOffchainOutput(), + IntentOnchainInputProgram: f.GetOnchainInput(), + IntentOnchainOutputProgram: f.GetOnchainOutput(), + } + } + + return clientlib.FeeInfo{ + TxFeeRate: txFeeRate, + IntentFees: intentFees, + }, nil } diff --git a/pkg/client-lib/client/grpc/client.go b/pkg/client-lib/client/grpc/client.go deleted file mode 100644 index faeac55a1..000000000 --- a/pkg/client-lib/client/grpc/client.go +++ /dev/null @@ -1,622 +0,0 @@ -package grpcclient - -import ( - "context" - "fmt" - "strconv" - "strings" - "sync" - "time" - - arkv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/ark/v1" - "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" - "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/wire" - "google.golang.org/grpc" - "google.golang.org/grpc/backoff" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" -) - -type grpcClient struct { - conn *grpc.ClientConn - connMu *sync.RWMutex - listenerMu *sync.RWMutex - listenerId string -} - -func NewClient(serverUrl string) (client.Client, error) { - if len(serverUrl) <= 0 { - return nil, fmt.Errorf("missing server url") - } - - port := 80 - creds := insecure.NewCredentials() - serverUrl = strings.TrimPrefix(serverUrl, "http://") - if strings.HasPrefix(serverUrl, "https://") { - serverUrl = strings.TrimPrefix(serverUrl, "https://") - creds = credentials.NewTLS(nil) - port = 443 - } - if !strings.Contains(serverUrl, ":") { - serverUrl = fmt.Sprintf("%s:%d", serverUrl, port) - } - - options := []grpc.DialOption{ - grpc.WithTransportCredentials(creds), - grpc.WithDisableServiceConfig(), - grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(20 << 20)), - grpc.WithConnectParams(grpc.ConnectParams{ - Backoff: backoff.Config{ - BaseDelay: 1 * time.Second, - Multiplier: 1.6, - Jitter: 0.2, - MaxDelay: 10 * time.Second, - }, - MinConnectTimeout: 3 * time.Second, - }), - } - - conn, err := grpc.NewClient(serverUrl, options...) - if err != nil { - return nil, err - } - - client := &grpcClient{ - conn: conn, - connMu: &sync.RWMutex{}, - listenerMu: &sync.RWMutex{}, - listenerId: "", - } - - return client, nil -} - -func (a *grpcClient) GetInfo(ctx context.Context) (*client.Info, error) { - req := &arkv1.GetInfoRequest{} - resp, err := a.svc().GetInfo(ctx, req) - if err != nil { - return nil, err - } - fees, err := parseFees(resp.GetFees()) - if err != nil { - return nil, err - } - var ( - ssStartTime, ssEndTime, ssPeriod, ssDuration int64 - ssFees types.FeeInfo - ) - if ss := resp.GetScheduledSession(); ss != nil { - ssStartTime = ss.GetNextStartTime() - ssEndTime = ss.GetNextEndTime() - ssPeriod = ss.GetPeriod() - ssDuration = ss.GetDuration() - ssFees, err = parseFees(ss.GetFees()) - if err != nil { - return nil, err - } - } - var deprecatedSigners []client.DeprecatedSigner - for _, s := range resp.GetDeprecatedSigners() { - if s == nil { - continue - } - deprecatedSigners = append(deprecatedSigners, client.DeprecatedSigner{ - PubKey: s.GetPubkey(), - CutoffDate: s.GetCutoffDate(), - }) - } - return &client.Info{ - SignerPubKey: resp.GetSignerPubkey(), - ForfeitPubKey: resp.GetForfeitPubkey(), - UnilateralExitDelay: resp.GetUnilateralExitDelay(), - SessionDuration: resp.GetSessionDuration(), - Network: resp.GetNetwork(), - Dust: uint64(resp.GetDust()), - BoardingExitDelay: resp.GetBoardingExitDelay(), - ForfeitAddress: resp.GetForfeitAddress(), - Version: resp.GetVersion(), - ScheduledSessionStartTime: ssStartTime, - ScheduledSessionEndTime: ssEndTime, - ScheduledSessionPeriod: ssPeriod, - ScheduledSessionDuration: ssDuration, - ScheduledSessionFees: ssFees, - UtxoMinAmount: resp.GetUtxoMinAmount(), - UtxoMaxAmount: resp.GetUtxoMaxAmount(), - VtxoMinAmount: resp.GetVtxoMinAmount(), - VtxoMaxAmount: resp.GetVtxoMaxAmount(), - CheckpointTapscript: resp.GetCheckpointTapscript(), - DeprecatedSignerPubKeys: deprecatedSigners, - MaxTxWeight: resp.GetMaxTxWeight(), - MaxOpReturnOutputs: resp.GetMaxOpReturnOutputs(), - Fees: fees, - ServiceStatus: resp.GetServiceStatus(), - Digest: resp.GetDigest(), - }, nil -} - -func (a *grpcClient) RegisterIntent( - ctx context.Context, - proof, message string, -) (string, error) { - req := &arkv1.RegisterIntentRequest{ - Intent: &arkv1.Intent{ - Message: message, - Proof: proof, - }, - } - - resp, err := a.svc().RegisterIntent(ctx, req) - if err != nil { - return "", err - } - return resp.GetIntentId(), nil -} - -func (a *grpcClient) DeleteIntent(ctx context.Context, proof, message string) error { - req := &arkv1.DeleteIntentRequest{ - Intent: &arkv1.Intent{ - Message: message, - Proof: proof, - }, - } - _, err := a.svc().DeleteIntent(ctx, req) - if err != nil { - return err - } - return nil -} - -func (a *grpcClient) EstimateIntentFee(ctx context.Context, proof, message string) (int64, error) { - req := &arkv1.EstimateIntentFeeRequest{ - Intent: &arkv1.Intent{ - Message: message, - Proof: proof, - }, - } - resp, err := a.svc().EstimateIntentFee(ctx, req) - if err != nil { - return -1, err - } - return resp.GetFee(), nil -} - -func (a *grpcClient) ConfirmRegistration(ctx context.Context, intentID string) error { - req := &arkv1.ConfirmRegistrationRequest{ - IntentId: intentID, - } - _, err := a.svc().ConfirmRegistration(ctx, req) - if err != nil { - return err - } - return nil -} - -func (a *grpcClient) SubmitTreeNonces( - ctx context.Context, batchId, cosignerPubkey string, nonces tree.TreeNonces, -) error { - req := &arkv1.SubmitTreeNoncesRequest{ - BatchId: batchId, - Pubkey: cosignerPubkey, - TreeNonces: nonces.ToMap(), - } - - if _, err := a.svc().SubmitTreeNonces(ctx, req); err != nil { - return err - } - - return nil -} - -func (a *grpcClient) SubmitTreeSignatures( - ctx context.Context, batchId, cosignerPubkey string, signatures tree.TreePartialSigs, -) error { - sigs, err := signatures.ToMap() - if err != nil { - return err - } - - req := &arkv1.SubmitTreeSignaturesRequest{ - BatchId: batchId, - Pubkey: cosignerPubkey, - TreeSignatures: sigs, - } - - if _, err := a.svc().SubmitTreeSignatures(ctx, req); err != nil { - return err - } - - return nil -} - -func (a *grpcClient) SubmitSignedForfeitTxs( - ctx context.Context, signedForfeitTxs []string, signedCommitmentTx string, -) error { - req := &arkv1.SubmitSignedForfeitTxsRequest{ - SignedForfeitTxs: signedForfeitTxs, - SignedCommitmentTx: signedCommitmentTx, - } - - _, err := a.svc().SubmitSignedForfeitTxs(ctx, req) - if err != nil { - return err - } - return nil -} - -func (a *grpcClient) GetEventStream( - ctx context.Context, topics []string, -) (<-chan client.BatchEventChannel, func(), error) { - req := &arkv1.GetEventStreamRequest{Topics: topics} - - return utils.StartReconnectingStream(ctx, utils.ReconnectingStreamConfig[ - arkv1.ArkService_GetEventStreamClient, - *arkv1.GetEventStreamResponse, - client.BatchEventChannel, - ]{ - Connect: func(ctx context.Context) (arkv1.ArkService_GetEventStreamClient, error) { - return a.svc().GetEventStream(ctx, req) - }, - Reconnect: func(ctx context.Context) (string, arkv1.ArkService_GetEventStreamClient, error) { - stream, err := a.svc().GetEventStream(ctx, req) - return "", stream, err - }, - Recv: func(stream arkv1.ArkService_GetEventStreamClient) (**arkv1.GetEventStreamResponse, error) { - str, err := stream.Recv() - if err != nil { - return nil, err - } - return &str, nil - }, - HandleResp: func( - ctx context.Context, - eventsCh chan<- client.BatchEventChannel, - resp *arkv1.GetEventStreamResponse, - ) error { - if started := resp.GetStreamStarted(); started != nil { - a.setListenerID(started.GetId()) - } - - ev, err := event{resp}.toBatchEvent() - if err != nil { - return err - } - if ev == nil { - return nil - } - - select { - case <-ctx.Done(): - return ctx.Err() - case eventsCh <- client.BatchEventChannel{Event: ev}: - return nil - } - }, - ErrorEvent: func(err error) client.BatchEventChannel { - return client.BatchEventChannel{Err: err} - }, - ConnectionEvent: func(event utils.ReconnectingStreamStateEvent) client.BatchEventChannel { - return client.BatchEventChannel{ - Connection: &types.StreamConnectionEvent{ - State: toClientStreamConnectionState(event.State), - At: event.At, - DisconnectedAt: event.DisconnectedAt, - Err: event.Err, - }, - } - }, - OnDisconnect: func(error) { - a.setListenerID("") - }, - }) -} - -func (a *grpcClient) SubmitTx( - ctx context.Context, signedArkTx string, checkpointTxs []string, -) (string, string, []string, error) { - req := &arkv1.SubmitTxRequest{ - SignedArkTx: signedArkTx, - CheckpointTxs: checkpointTxs, - } - - resp, err := a.svc().SubmitTx(ctx, req) - if err != nil { - return "", "", nil, err - } - - return resp.GetArkTxid(), resp.GetFinalArkTx(), resp.GetSignedCheckpointTxs(), nil -} - -func (a *grpcClient) FinalizeTx( - ctx context.Context, arkTxid string, finalCheckpointTxs []string, -) error { - req := &arkv1.FinalizeTxRequest{ - ArkTxid: arkTxid, - FinalCheckpointTxs: finalCheckpointTxs, - } - - _, err := a.svc().FinalizeTx(ctx, req) - if err != nil { - return err - } - return nil -} - -func (a *grpcClient) GetPendingTx( - ctx context.Context, - proof, message string, -) ([]client.AcceptedOffchainTx, error) { - req := &arkv1.GetPendingTxRequest{ - Identifier: &arkv1.GetPendingTxRequest_Intent{ - Intent: &arkv1.Intent{ - Message: message, - Proof: proof, - }, - }, - } - - resp, err := a.svc().GetPendingTx(ctx, req) - if err != nil { - return nil, err - } - - pendingTxs := make([]client.AcceptedOffchainTx, 0, len(resp.GetPendingTxs())) - for _, tx := range resp.GetPendingTxs() { - pendingTxs = append(pendingTxs, client.AcceptedOffchainTx{ - Txid: tx.GetArkTxid(), - FinalArkTx: tx.GetFinalArkTx(), - SignedCheckpointTxs: tx.GetSignedCheckpointTxs(), - }) - } - return pendingTxs, nil -} - -func (c *grpcClient) GetTransactionsStream( - ctx context.Context, -) (<-chan client.TransactionEvent, func(), error) { - req := &arkv1.GetTransactionsStreamRequest{} - - return utils.StartReconnectingStream(ctx, utils.ReconnectingStreamConfig[ - arkv1.ArkService_GetTransactionsStreamClient, - *arkv1.GetTransactionsStreamResponse, - client.TransactionEvent, - ]{ - Connect: func(ctx context.Context) (arkv1.ArkService_GetTransactionsStreamClient, error) { - return c.svc().GetTransactionsStream(ctx, req) - }, - Reconnect: func( - ctx context.Context, - ) (string, arkv1.ArkService_GetTransactionsStreamClient, error) { - stream, err := c.svc().GetTransactionsStream(ctx, req) - return "", stream, err - }, - Recv: func( - stream arkv1.ArkService_GetTransactionsStreamClient, - ) (**arkv1.GetTransactionsStreamResponse, error) { - str, err := stream.Recv() - if err != nil { - return nil, err - } - return &str, nil - }, - HandleResp: func( - ctx context.Context, - eventsCh chan<- client.TransactionEvent, - resp *arkv1.GetTransactionsStreamResponse, - ) error { - switch tx := resp.GetData().(type) { - case *arkv1.GetTransactionsStreamResponse_CommitmentTx: - select { - case <-ctx.Done(): - return ctx.Err() - case eventsCh <- client.TransactionEvent{ - CommitmentTx: &client.TxNotification{ - TxData: client.TxData{ - Txid: tx.CommitmentTx.GetTxid(), - Tx: tx.CommitmentTx.GetTx(), - }, - SpentVtxos: vtxos(tx.CommitmentTx.SpentVtxos).toVtxos(), - SpendableVtxos: vtxos(tx.CommitmentTx.SpendableVtxos).toVtxos(), - }, - }: - return nil - } - case *arkv1.GetTransactionsStreamResponse_ArkTx: - checkpointTxs := make(map[types.Outpoint]client.TxData) - for k, v := range tx.ArkTx.CheckpointTxs { - out, parseErr := wire.NewOutPointFromString(k) - if parseErr != nil { - return fmt.Errorf("invalid checkpoint outpoint %q: %w", k, parseErr) - } - checkpointTxs[types.Outpoint{ - Txid: out.Hash.String(), - VOut: out.Index, - }] = client.TxData{ - Txid: v.GetTxid(), - Tx: v.GetTx(), - } - } - select { - case <-ctx.Done(): - return ctx.Err() - case eventsCh <- client.TransactionEvent{ - ArkTx: &client.TxNotification{ - TxData: client.TxData{ - Txid: tx.ArkTx.GetTxid(), - Tx: tx.ArkTx.GetTx(), - }, - SpentVtxos: vtxos(tx.ArkTx.SpentVtxos).toVtxos(), - SpendableVtxos: vtxos(tx.ArkTx.SpendableVtxos).toVtxos(), - CheckpointTxs: checkpointTxs, - }, - }: - return nil - } - case *arkv1.GetTransactionsStreamResponse_SweepTx: - sweptVtxos := make([]types.Outpoint, 0, len(tx.SweepTx.SweptVtxos)) - for _, o := range tx.SweepTx.SweptVtxos { - sweptVtxos = append(sweptVtxos, types.Outpoint{ - Txid: o.GetTxid(), - VOut: o.GetVout(), - }) - } - select { - case <-ctx.Done(): - return ctx.Err() - case eventsCh <- client.TransactionEvent{ - SweepTx: &client.TxNotification{ - TxData: client.TxData{ - Txid: tx.SweepTx.GetTxid(), - Tx: tx.SweepTx.GetTx(), - }, - SpentVtxos: vtxos(tx.SweepTx.SpentVtxos).toVtxos(), - SpendableVtxos: vtxos(tx.SweepTx.SpendableVtxos).toVtxos(), - SweptVtxos: sweptVtxos, - }, - }: - return nil - } - default: - return nil - } - }, - ErrorEvent: func(err error) client.TransactionEvent { - return client.TransactionEvent{Err: err} - }, - ConnectionEvent: func(event utils.ReconnectingStreamStateEvent) client.TransactionEvent { - return client.TransactionEvent{ - Connection: &types.StreamConnectionEvent{ - State: toClientStreamConnectionState(event.State), - At: event.At, - DisconnectedAt: event.DisconnectedAt, - Err: event.Err, - }, - } - }, - }) -} - -func (c *grpcClient) ModifyStreamTopics( - ctx context.Context, addTopics, removeTopics []string, -) (addedTopics, removedTopics, allTopics []string, err error) { - listenerID := c.getListenerID() - if listenerID == "" { - return nil, nil, nil, fmt.Errorf("listenerId is not set; cannot modify stream topics") - } - - req := &arkv1.UpdateStreamTopicsRequest{ - StreamId: listenerID, - TopicsChange: &arkv1.UpdateStreamTopicsRequest_Modify{ - Modify: &arkv1.ModifyTopics{ - AddTopics: addTopics, - RemoveTopics: removeTopics, - }, - }, - } - updateRes, err := c.svc().UpdateStreamTopics(ctx, req) - if err != nil { - return nil, nil, nil, err - } - - return updateRes.GetTopicsAdded(), updateRes.GetTopicsRemoved(), updateRes.GetAllTopics(), nil -} - -func (c *grpcClient) OverwriteStreamTopics( - ctx context.Context, topics []string, -) (addedTopics, removedTopics, allTopics []string, err error) { - listenerID := c.getListenerID() - if listenerID == "" { - return nil, nil, nil, fmt.Errorf("listenerId is not set; cannot overwrite stream topics") - } - - req := &arkv1.UpdateStreamTopicsRequest{ - StreamId: listenerID, - TopicsChange: &arkv1.UpdateStreamTopicsRequest_Overwrite{ - Overwrite: &arkv1.OverwriteTopics{ - Topics: topics, - }, - }, - } - updateRes, err := c.svc().UpdateStreamTopics(ctx, req) - if err != nil { - return nil, nil, nil, err - } - - return updateRes.GetTopicsAdded(), updateRes.GetTopicsRemoved(), updateRes.GetAllTopics(), nil -} - -func (c *grpcClient) Close() { - c.connMu.Lock() - defer c.connMu.Unlock() - // nolint:errcheck - c.conn.Close() -} - -func (a *grpcClient) svc() arkv1.ArkServiceClient { - a.connMu.RLock() - defer a.connMu.RUnlock() - - return arkv1.NewArkServiceClient(a.conn) -} - -func (a *grpcClient) getListenerID() string { - a.listenerMu.RLock() - defer a.listenerMu.RUnlock() - - return a.listenerId -} - -func (a *grpcClient) setListenerID(id string) { - a.listenerMu.Lock() - defer a.listenerMu.Unlock() - - a.listenerId = id -} - -func toClientStreamConnectionState( - state utils.ReconnectingStreamState, -) types.StreamConnectionState { - switch state { - case utils.ReconnectingStreamStateDisconnected: - return types.StreamConnectionStateDisconnected - case utils.ReconnectingStreamStateReconnected: - return types.StreamConnectionStateReconnected - default: - return types.StreamConnectionState(state) - } -} - -func parseFees(fees *arkv1.FeeInfo) (types.FeeInfo, error) { - if fees == nil { - return types.FeeInfo{}, nil - } - - var ( - err error - txFeeRate float64 - ) - if fees.GetTxFeeRate() != "" { - txFeeRate, err = strconv.ParseFloat(fees.GetTxFeeRate(), 64) - if err != nil { - return types.FeeInfo{}, err - } - } - - var intentFees arkfee.Config - if f := fees.GetIntentFee(); f != nil { - intentFees = arkfee.Config{ - IntentOffchainInputProgram: f.GetOffchainInput(), - IntentOffchainOutputProgram: f.GetOffchainOutput(), - IntentOnchainInputProgram: f.GetOnchainInput(), - IntentOnchainOutputProgram: f.GetOnchainOutput(), - } - } - - return types.FeeInfo{ - TxFeeRate: txFeeRate, - IntentFees: intentFees, - }, nil -} diff --git a/pkg/client-lib/client/grpc/reconnect_test.go b/pkg/client-lib/client/reconnect_test.go similarity index 95% rename from pkg/client-lib/client/grpc/reconnect_test.go rename to pkg/client-lib/client/reconnect_test.go index b02863ecc..9cfef541c 100644 --- a/pkg/client-lib/client/grpc/reconnect_test.go +++ b/pkg/client-lib/client/reconnect_test.go @@ -1,4 +1,4 @@ -package grpcclient +package client import ( "context" @@ -10,8 +10,8 @@ import ( "time" arkv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/ark/v1" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -103,9 +103,9 @@ func TestGetTransactionsStreamEmitsConnectionLifecycleEvents(t *testing.T) { if event.Connection != nil { switch event.Connection.State { - case types.StreamConnectionStateDisconnected: + case clientlib.StreamConnectionStateDisconnected: disconnectedAt = event.Connection.At - case types.StreamConnectionStateReconnected: + case clientlib.StreamConnectionStateReconnected: reconnectedAt = event.Connection.At } } @@ -180,7 +180,7 @@ func TestGetTransactionsStreamReconnectsAfterServerRestart(t *testing.T) { require.Nil(t, event.Err) if event.Connection != nil && - event.Connection.State == types.StreamConnectionStateReady { + event.Connection.State == clientlib.StreamConnectionStateReady { seenInitialReady = true } if event.CommitmentTx != nil && event.CommitmentTx.Txid == "commitment-1" { @@ -220,9 +220,9 @@ func TestGetTransactionsStreamReconnectsAfterServerRestart(t *testing.T) { if event.Connection != nil { switch event.Connection.State { - case types.StreamConnectionStateDisconnected: + case clientlib.StreamConnectionStateDisconnected: disconnectedAt = event.Connection.At - case types.StreamConnectionStateReconnected: + case clientlib.StreamConnectionStateReconnected: reconnectedAt = event.Connection.At } } diff --git a/pkg/client-lib/client/grpc/types.go b/pkg/client-lib/client/types.go similarity index 83% rename from pkg/client-lib/client/grpc/types.go rename to pkg/client-lib/client/types.go index 1f2da84ca..285ef58f2 100644 --- a/pkg/client-lib/client/grpc/types.go +++ b/pkg/client-lib/client/types.go @@ -1,4 +1,4 @@ -package grpcclient +package client import ( "encoding/hex" @@ -7,8 +7,7 @@ import ( arkv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/ark/v1" "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" ) // wrapper for GetEventStreamResponse @@ -36,14 +35,14 @@ func (e event) toBatchEvent() (any, error) { } if ee := e.GetBatchFailed(); ee != nil { - return client.BatchFailedEvent{ + return clientlib.BatchFailedEvent{ Id: ee.GetId(), Reason: ee.GetReason(), }, nil } if ee := e.GetBatchStarted(); ee != nil { - return client.BatchStartedEvent{ + return clientlib.BatchStartedEvent{ Id: ee.GetId(), HashedIntentIds: ee.GetIntentIdHashes(), BatchExpiry: ee.GetBatchExpiry(), @@ -51,21 +50,21 @@ func (e event) toBatchEvent() (any, error) { } if ee := e.GetBatchFinalization(); ee != nil { - return client.BatchFinalizationEvent{ + return clientlib.BatchFinalizationEvent{ Id: ee.GetId(), Tx: ee.GetCommitmentTx(), }, nil } if ee := e.GetBatchFinalized(); ee != nil { - return client.BatchFinalizedEvent{ + return clientlib.BatchFinalizedEvent{ Id: ee.GetId(), Txid: ee.GetCommitmentTxid(), }, nil } if ee := e.GetTreeSigningStarted(); ee != nil { - return client.TreeSigningStartedEvent{ + return clientlib.TreeSigningStartedEvent{ Id: ee.GetId(), UnsignedCommitmentTx: ee.GetUnsignedCommitmentTx(), CosignersPubkeys: ee.GetCosignersPubkeys(), @@ -77,7 +76,7 @@ func (e event) toBatchEvent() (any, error) { if err != nil { return nil, err } - return client.TreeNoncesAggregatedEvent{ + return clientlib.TreeNoncesAggregatedEvent{ Id: ee.GetId(), Nonces: nonces, }, nil @@ -103,7 +102,7 @@ func (e event) toBatchEvent() (any, error) { } } - return client.TreeNoncesEvent{ + return clientlib.TreeNoncesEvent{ Id: ee.GetId(), Topic: ee.GetTopic(), Txid: ee.GetTxid(), @@ -112,7 +111,7 @@ func (e event) toBatchEvent() (any, error) { } if ee := e.GetTreeTx(); ee != nil { - return client.TreeTxEvent{ + return clientlib.TreeTxEvent{ Id: ee.GetId(), Topic: ee.GetTopic(), BatchIndex: ee.GetBatchIndex(), @@ -125,7 +124,7 @@ func (e event) toBatchEvent() (any, error) { } if ee := e.GetTreeSignature(); ee != nil { - return client.TreeSignatureEvent{ + return clientlib.TreeSignatureEvent{ Id: ee.GetId(), Topic: ee.GetTopic(), BatchIndex: ee.GetBatchIndex(), @@ -135,7 +134,7 @@ func (e event) toBatchEvent() (any, error) { } if ee := e.GetStreamStarted(); ee != nil { - return client.StreamStartedEvent{ + return clientlib.StreamStartedEvent{ Id: ee.GetId(), }, nil } @@ -147,20 +146,20 @@ type vtxo struct { *arkv1.Vtxo } -func (v vtxo) toVtxo() types.Vtxo { +func (v vtxo) toVtxo() clientlib.Vtxo { assetsProto := v.GetAssets() - assets := make([]types.Asset, 0, len(assetsProto)) + assets := make([]clientlib.Asset, 0, len(assetsProto)) for _, a := range assetsProto { if a != nil { - assets = append(assets, types.Asset{ + assets = append(assets, clientlib.Asset{ AssetId: a.AssetId, Amount: a.Amount, }) } } - return types.Vtxo{ - Outpoint: types.Outpoint{ + return clientlib.Vtxo{ + Outpoint: clientlib.Outpoint{ Txid: v.GetOutpoint().GetTxid(), VOut: v.GetOutpoint().GetVout(), }, @@ -182,8 +181,8 @@ func (v vtxo) toVtxo() types.Vtxo { type vtxos []*arkv1.Vtxo -func (v vtxos) toVtxos() []types.Vtxo { - list := make([]types.Vtxo, 0, len(v)) +func (v vtxos) toVtxos() []clientlib.Vtxo { + list := make([]clientlib.Vtxo, 0, len(v)) for _, vv := range v { list = append(list, vtxo{vv}.toVtxo()) } diff --git a/pkg/client-lib/explorer/mempool/connection_pool.go b/pkg/client-lib/explorer/connection_pool.go similarity index 99% rename from pkg/client-lib/explorer/mempool/connection_pool.go rename to pkg/client-lib/explorer/connection_pool.go index 9b392f335..3dbb255df 100644 --- a/pkg/client-lib/explorer/mempool/connection_pool.go +++ b/pkg/client-lib/explorer/connection_pool.go @@ -1,4 +1,4 @@ -package mempoolexplorer +package explorer import ( "context" diff --git a/pkg/client-lib/explorer/mempool/listeners.go b/pkg/client-lib/explorer/listeners.go similarity index 65% rename from pkg/client-lib/explorer/mempool/listeners.go rename to pkg/client-lib/explorer/listeners.go index dbbba92b0..0fa9d12a4 100644 --- a/pkg/client-lib/explorer/mempool/listeners.go +++ b/pkg/client-lib/explorer/listeners.go @@ -1,37 +1,37 @@ -package mempoolexplorer +package explorer import ( "sync" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" log "github.com/sirupsen/logrus" ) type listeners struct { mu *sync.RWMutex - listeners map[chan types.OnchainAddressEvent]int + listeners map[chan clientlib.OnchainAddressEvent]int index int } func newListeners() *listeners { return &listeners{ mu: &sync.RWMutex{}, - listeners: make(map[chan types.OnchainAddressEvent]int), + listeners: make(map[chan clientlib.OnchainAddressEvent]int), index: 0, } } -func (l *listeners) add(ch chan types.OnchainAddressEvent) { +func (l *listeners) add(ch chan clientlib.OnchainAddressEvent) { l.mu.Lock() defer l.mu.Unlock() l.listeners[ch] = l.index l.index++ } -func (l *listeners) broadcast(event types.OnchainAddressEvent) { +func (l *listeners) broadcast(event clientlib.OnchainAddressEvent) { l.mu.RLock() defer l.mu.RUnlock() - listenersToRemove := make([]chan types.OnchainAddressEvent, 0) + listenersToRemove := make([]chan clientlib.OnchainAddressEvent, 0) chIds := make([]int, 0) for ch, id := range l.listeners { select { @@ -58,10 +58,10 @@ func (l *listeners) clear() { for ch := range l.listeners { close(ch) } - l.listeners = make(map[chan types.OnchainAddressEvent]int) + l.listeners = make(map[chan clientlib.OnchainAddressEvent]int) } -func (l *listeners) remove(chs []chan types.OnchainAddressEvent) { +func (l *listeners) remove(chs []chan clientlib.OnchainAddressEvent) { l.mu.Lock() defer l.mu.Unlock() for _, ch := range chs { diff --git a/pkg/client-lib/explorer/mempool/explorer.go b/pkg/client-lib/explorer/mempool/explorer.go deleted file mode 100644 index 95ea9200f..000000000 --- a/pkg/client-lib/explorer/mempool/explorer.go +++ /dev/null @@ -1,1040 +0,0 @@ -// Package explorer provides an explorer client with support for multiple concurrent WebSocket -// connections for addresses tracking. -// -// # Architecture -// -// - Multiple concurrent WebSocket connections -// - Hash-based address distribution for consistent routing -// - Automatic fallback to polling if WebSocket connections fails -// -// # Usage -// -// Basic usage with default settings: -// -// svc, err := explorer.NewExplorer("", arklib.Bitcoin, explorer.WithTracker(true)) -// if err != nil { -// log.Fatal(err) -// } -// defer svc.Stop() -// -// -// Subscribe to addresses: -// -// addresses := []string{"bc1q...", "bc1p...", ...} -// if err := svc.SubscribeForAddresses(addresses); err != nil { -// log.Fatal(err) -// } -// -// // Listen for events -// for event := range svc.GetAddressesEvents() { -// fmt.Printf("New UTXOs: %d, Spent: %d\n", len(event.NewUtxos), len(event.SpentUtxos)) -// } -// -// # Thread Safety -// -// All public methods are thread-safe and can be called concurrently. -package mempoolexplorer - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "maps" - "net/http" - "net/url" - "slices" - "strings" - "sync" - "time" - - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/client-lib/explorer" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/txscript" - "github.com/gorilla/websocket" - log "github.com/sirupsen/logrus" -) - -const ( - BitcoinExplorer = "bitcoin" - defaultPollInterval = 10 * time.Second - pongInterval = 60 * time.Second - pingInterval = (pongInterval * 9) / 10 -) - -var ( - defaultExplorerUrls = utils.SupportedType[string]{ - arklib.Bitcoin.Name: "https://mempool.arkade.sh/api", - arklib.BitcoinTestNet.Name: "https://mempool.space/testnet/api", - //arklib.BitcoinTestNet4.Name: "https://mempool.space/testnet4/api", //TODO uncomment once supported - arklib.BitcoinSigNet.Name: "https://mempool.signet.arkade.sh/api", - arklib.BitcoinMutinyNet.Name: "https://mempool.mutinynet.arkade.sh/api", - arklib.BitcoinRegTest.Name: "http://127.0.0.1:3000", - } -) - -type explorerSvc struct { - cache *utils.Cache[string] - baseUrl string - net arklib.Network - connPool *connectionPool - connPoolMu sync.RWMutex - subscribedMu *sync.RWMutex - subscribedMap map[string]addressData - stopTracking func() - pollInterval time.Duration - noTracking bool - listeners *listeners -} - -// NewExplorer creates a new Explorer instance for the specified network. -// If baseUrl is empty, it uses the default explorer URL for the network. -// -// The explorer supports: -// - Multiple concurrent WebSocket connections for scalability -// - Automatic fallback to polling if WebSocket connections fail -// -// Example: -// -// svc, err := explorer.NewExplorer("https://mempool.space/api", arklib.Bitcoin, explorer.WithTracker(true)) -func NewExplorer(baseUrl string, net arklib.Network, opts ...Option) (explorer.Explorer, error) { - if len(baseUrl) == 0 { - baseUrl, ok := defaultExplorerUrls[net.Name] - if !ok { - return nil, fmt.Errorf( - "cannot find default explorer url associated with network %s", - net.Name, - ) - } - return NewExplorer(baseUrl, net, opts...) - } - - if _, err := deriveWsURL(baseUrl); err != nil { - return nil, fmt.Errorf("invalid base url: %s", err) - } - - svcOpts := &explorerSvc{ - pollInterval: defaultPollInterval, - } - for _, opt := range opts { - opt(svcOpts) - } - - if svcOpts.noTracking { - return &explorerSvc{ - cache: utils.NewCache[string](), - baseUrl: baseUrl, - net: net, - noTracking: svcOpts.noTracking, - }, nil - } - if svcOpts.pollInterval <= 0 { - return nil, fmt.Errorf("poll interval must be positive") - } - - svc := &explorerSvc{ - cache: utils.NewCache[string](), - baseUrl: baseUrl, - net: net, - subscribedMu: &sync.RWMutex{}, - subscribedMap: make(map[string]addressData), - pollInterval: svcOpts.pollInterval, - noTracking: svcOpts.noTracking, - } - - return svc, nil -} - -func (e *explorerSvc) Start() { - // Nothing to do if tracking disabled. - if e.noTracking { - return - } - - // Nothing to do if service already started. - if e.stopTracking != nil { - return - } - - // nolint - wsURL, _ := deriveWsURL(e.baseUrl) - ctx, cancel := context.WithCancel(context.Background()) - - connPool, err := newConnectionPool(ctx, wsURL) - if err != nil { - log.WithError(err).WithField("wsURL", wsURL).Debugf( - "explorer: failed to create connection pool,sfalling back to polling with interval %s", - e.pollInterval, - ) - } - e.connPoolMu.Lock() - e.connPool = connPool - e.connPoolMu.Unlock() - - e.listeners = newListeners() - e.stopTracking = cancel - go e.startTracking(ctx) - log.Debug("explorer: started with address tracking") -} - -func (e *explorerSvc) Stop() { - // Nothing to do is tracking disabled. - if e.noTracking { - return - } - - // Nothing to do if service already stopped. - if e.stopTracking == nil { - return - } - - e.stopTracking() - - // Close all connections in the pool - e.connPoolMu.RLock() - connPool := e.connPool - e.connPoolMu.RUnlock() - if connPool != nil { - connPool.mu.Lock() - for _, wsConn := range connPool.connections { - if wsConn.conn != nil { - if err := wsConn.conn.Close(); err != nil { - log.WithError(err).Warn("explorer: failed to close ws connection") - } - } - } - connPool.mu.Unlock() - } - log.Debug("explorer: closed all connections") - - // Clear subscribed addresses map - e.subscribedMu.Lock() - e.subscribedMap = make(map[string]addressData) - e.subscribedMu.Unlock() - e.listeners.clear() - - e.stopTracking = nil - log.Debug("explorer: stopped") -} - -func (e *explorerSvc) BaseUrl() string { - return e.baseUrl -} - -func (e *explorerSvc) GetNetwork() arklib.Network { - return e.net -} - -func (e *explorerSvc) GetFeeRate() (float64, error) { - endpoint, err := url.JoinPath(e.baseUrl, "fee-estimates") - if err != nil { - return 0, err - } - - resp, err := http.Get(endpoint) - if err != nil { - return 0, err - } - // nolint:all - defer resp.Body.Close() - - var response map[string]float64 - - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return 0, err - } - - if resp.StatusCode != http.StatusOK { - return 0, fmt.Errorf("failed to get fee rate: %s", resp.Status) - } - - if len(response) == 0 { - return 1, nil - } - - return response["1"], nil -} - -func (e *explorerSvc) GetConnectionCount() int { - e.connPoolMu.RLock() - defer e.connPoolMu.RUnlock() - if e.connPool == nil { - return 0 - } - return e.connPool.getConnectionCount() -} - -func (e *explorerSvc) GetSubscribedAddresses() []string { - e.subscribedMu.RLock() - defer e.subscribedMu.RUnlock() - return slices.Collect(maps.Keys(e.subscribedMap)) -} - -func (e *explorerSvc) IsAddressSubscribed(address string) bool { - e.subscribedMu.RLock() - defer e.subscribedMu.RUnlock() - _, exists := e.subscribedMap[address] - return exists -} - -func (e *explorerSvc) GetAddressesEvents() <-chan types.OnchainAddressEvent { - ch := make(chan types.OnchainAddressEvent) - e.listeners.add(ch) - return ch -} - -func (e *explorerSvc) GetTxHex(txid string) (string, error) { - if hex, ok := e.cache.Get(txid); ok { - return hex, nil - } - - txHex, err := e.getTxHex(txid) - if err != nil { - return "", err - } - - e.cache.Set(txid, txHex) - - return txHex, nil -} - -func (e *explorerSvc) Broadcast(txs ...string) (string, error) { - if len(txs) == 0 { - return "", fmt.Errorf("no txs to broadcast") - } - - for _, tx := range txs { - txStr, txid, err := parseBitcoinTx(tx) - if err != nil { - return "", err - } - - e.cache.Set(txid, txStr) - } - - if len(txs) == 1 { - txid, err := e.broadcast(txs[0]) - if err != nil { - if strings.Contains( - strings.ToLower(err.Error()), "transaction already in block chain", - ) { - return txid, nil - } - - return "", err - } - - return txid, nil - } - - // package - return e.broadcastPackage(txs...) -} - -func (e *explorerSvc) GetTxs(addr string) ([]explorer.Tx, error) { - resp, err := http.Get(fmt.Sprintf("%s/address/%s/txs", e.baseUrl, addr)) - if err != nil { - return nil, err - } - // nolint:all - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get txs: %s", string(body)) - } - payload := txs{} - if err := json.Unmarshal(body, &payload); err != nil { - return nil, err - } - - return payload.toList(), nil -} - -func (e *explorerSvc) SubscribeForAddresses(addresses []string) error { - if e.noTracking { - return nil - } - - e.subscribedMu.Lock() - defer e.subscribedMu.Unlock() - - addressesToSubscribe := make([]string, 0, len(addresses)) - scripts := make(map[string]string) - for _, addr := range addresses { - if _, ok := e.subscribedMap[addr]; ok { - continue - } - decoded, err := btcutil.DecodeAddress(addr, nil) - if err != nil { - return fmt.Errorf("invalid address: %s", err) - } - - outputScript, err := txscript.PayToAddrScript(decoded) - if err != nil { - return fmt.Errorf("invalid address: %s", err) - } - addressesToSubscribe = append(addressesToSubscribe, addr) - scripts[addr] = hex.EncodeToString(outputScript) - } - - // Nothing to do if no addresses to subscribe. - if len(addressesToSubscribe) == 0 { - return nil - } - - var numAddressesLeftToSubscribe int - e.connPoolMu.RLock() - connPool := e.connPool - e.connPoolMu.RUnlock() - if connPool != nil && connPool.getConnectionCount() > 0 { - if connPool.noMoreConnections { - return fmt.Errorf( - "can't subscribe for any more addresses (max=%d)", - len(e.subscribedMap), - ) - } - - for i, addr := range addressesToSubscribe { - connId, err := connPool.pushAddress(addr) - if err != nil { - log.WithError(err).Warnf("failed to subscribe for address %s", addr) - numAddressesLeftToSubscribe = len(addressesToSubscribe[i:]) - addressesToSubscribe = addressesToSubscribe[:i] - break - } - log.Debugf("explorer: subscribed for new address on connection %d", connId) - // nolint - connPool.addConnection() - time.Sleep(time.Millisecond) - } - } - - // Add new addresses to the subscribed map - for _, addr := range addressesToSubscribe { - e.subscribedMap[addr] = addressData{script: scripts[addr]} - } - - if numAddressesLeftToSubscribe > 0 { - return fmt.Errorf( - "can't subscribe for any more addresses (max=%d) (left=%d)", - len(e.subscribedMap), numAddressesLeftToSubscribe, - ) - } - return nil -} - -func (e *explorerSvc) UnsubscribeForAddresses(addresses []string) error { - if e.noTracking { - return nil - } - - e.subscribedMu.Lock() - defer e.subscribedMu.Unlock() - - addressesToUnsubscribe := make([]string, 0, len(addresses)) - for _, addr := range addresses { - if _, ok := e.subscribedMap[addr]; !ok { - continue - } - addressesToUnsubscribe = append(addressesToUnsubscribe, addr) - } - - // Nothing to do if no addresses to unsubscribe. - if len(addressesToUnsubscribe) == 0 { - return nil - } - - e.connPoolMu.RLock() - connPool := e.connPool - e.connPoolMu.RUnlock() - if connPool != nil && connPool.getConnectionCount() > 0 { - for _, addr := range addressesToUnsubscribe { - _, found := connPool.getConnectionForAddress(addr) - if !found { - continue - } - connPool.popAddress(addr) - } - } - - for _, addr := range addresses { - delete(e.subscribedMap, addr) - } - - return nil -} - -func (e *explorerSvc) GetTxOutspends(txid string) ([]explorer.SpentStatus, error) { - resp, err := http.Get(fmt.Sprintf("%s/tx/%s/outspends", e.baseUrl, txid)) - if err != nil { - return nil, err - } - - // nolint:all - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get txs: %s", string(body)) - } - - res := make([]spentStatus, 0) - if err := json.Unmarshal(body, &res); err != nil { - return nil, err - } - spentStatuses := make([]explorer.SpentStatus, 0, len(res)) - for _, s := range res { - spentStatuses = append(spentStatuses, explorer.SpentStatus{ - Spent: s.Spent, - SpentBy: s.SpentBy, - }) - } - return spentStatuses, nil -} - -func (e *explorerSvc) GetUtxos(addresses []string) ([]explorer.Utxo, error) { - if len(addresses) <= 0 { - return nil, fmt.Errorf("missing addresses") - } - - addrs := make(map[string]string) - for _, addr := range addresses { - decoded, err := btcutil.DecodeAddress(addr, nil) - if err != nil { - return nil, fmt.Errorf("invalid address: %s", err) - } - - outputScript, err := txscript.PayToAddrScript(decoded) - if err != nil { - return nil, fmt.Errorf("invalid address: %s", err) - } - - addrs[addr] = hex.EncodeToString(outputScript) - } - - allUtxos := make([]explorer.Utxo, 0) - count := 0 - for addr, script := range addrs { - utxos, err := e.getUtxos(addr, script) - if err != nil { - return nil, err - } - allUtxos = append(allUtxos, utxos.toUtxoList()...) - count++ - - // Throttle requests to not overload the explorer. - if count%20 == 0 { - time.Sleep(time.Second) - } - } - return allUtxos, nil -} - -func (e *explorerSvc) GetRedeemedVtxosBalance( - addr string, unilateralExitDelay arklib.RelativeLocktime, -) (spendableBalance uint64, lockedBalance map[int64]uint64, err error) { - utxos, err := e.GetUtxos([]string{addr}) - if err != nil { - return - } - - lockedBalance = make(map[int64]uint64, 0) - now := time.Now() - for _, utxo := range utxos { - blocktime := now - if utxo.Status.Confirmed { - blocktime = time.Unix(utxo.Status.BlockTime, 0) - } - - delay := time.Duration(unilateralExitDelay.Seconds()) * time.Second - availableAt := blocktime.Add(delay) - if availableAt.After(now) { - if _, ok := lockedBalance[availableAt.Unix()]; !ok { - lockedBalance[availableAt.Unix()] = 0 - } - - lockedBalance[availableAt.Unix()] += utxo.Amount - } else { - spendableBalance += utxo.Amount - } - } - - return -} - -func (e *explorerSvc) GetTxBlockTime( - txid string, -) (confirmed bool, blocktime int64, err error) { - resp, err := http.Get(fmt.Sprintf("%s/tx/%s", e.baseUrl, txid)) - if err != nil { - return false, 0, err - } - // nolint:all - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return false, 0, err - } - - if resp.StatusCode != http.StatusOK { - return false, 0, fmt.Errorf("failed to get block time: %s", string(body)) - } - - var tx struct { - Status struct { - Confirmed bool `json:"confirmed"` - Blocktime int64 `json:"block_time"` - } `json:"status"` - } - if err := json.Unmarshal(body, &tx); err != nil { - return false, 0, err - } - - if !tx.Status.Confirmed { - return false, -1, nil - } - - return true, tx.Status.Blocktime, nil -} - -func (e *explorerSvc) startTracking(ctx context.Context) { - // If the ws endpoint is available (mempool.space url), read from websocket and eventually - // send notifications and periodically send a ping message to keep the connection alive. - e.connPoolMu.RLock() - connPool := e.connPool - e.connPoolMu.RUnlock() - if connPool != nil && connPool.getConnectionCount() > 0 { - // Start a listener and ping routine for each connection in the pool - e.trackWithWebsocket(ctx, connPool) - } else { - // Otherwise (esplora url), poll the explorer every 10s and manually send notifications of - // spent, new and confirmed utxos. - e.trackWithPolling(ctx) - } - -} - -func (e *explorerSvc) trackWithWebsocket(ctx context.Context, connPool *connectionPool) { - for { - select { - case <-ctx.Done(): - return - case wsConn := <-connPool.getNewConnections(): - // Go routine to listen for addresses updates from websocket. - go func(ctx context.Context, wsConn *websocketConnection) { - if err := wsConn.conn.SetReadDeadline(time.Now().Add(pongInterval)); err != nil { - if !isCloseError(err) { - go e.listeners.broadcast(types.OnchainAddressEvent{Error: fmt.Errorf( - "connection for address %s dropped, please resubscribe: %w", - wsConn.address.get(), err, - )}) - } - return - } - wsConn.conn.SetPongHandler(func(string) error { - return wsConn.conn.SetReadDeadline(time.Now().Add(pongInterval)) - }) - for { - var payload addressNotification - if err := wsConn.conn.ReadJSON(&payload); err != nil { - // The connection was closed, nothing to do but return - if isCloseError(err) { - return - } - // Connection issues, try to reconnect: - // If this happens all active connections will arrive to this point. - // Since resetConnection makes use of a lock, its inner reconnection logic - // is executed to only one connection and once it is restored and the lock - // is released, all others will be restored as well - if isTimeoutError(err) { - log.Debugf( - "explorer: connection %d dropped, reconnecting...", wsConn.id, - ) - - addr := wsConn.address.get() - - if err := connPool.resetConnection(wsConn); err != nil { - go e.listeners.broadcast(types.OnchainAddressEvent{ - Error: fmt.Errorf( - "failed to reset connection for address %s and "+ - "resubscription is required: %w", addr, err, - )}) - return - } - - if len(addr) > 0 { - if _, err := connPool.pushAddress(addr); err != nil { - go e.listeners.broadcast(types.OnchainAddressEvent{ - Error: fmt.Errorf( - "failed to resubscribe for address %s and "+ - "resubscription is required: %w", addr, err, - )}) - return - } - } - log.Debugf("explorer: connection %d restored", wsConn.id) - // Get rid of this go routine - return - } - - go e.listeners.broadcast(types.OnchainAddressEvent{Error: fmt.Errorf( - "failed to read message for address %s: %w", - wsConn.address.get(), err, - )}) - continue - } - - go e.sendAddressEventFromWs(payload) - } - }(ctx, wsConn) - - // Go routine to periodically send ping messages and keep the connection alive. - go func(ctx context.Context, wsConn *websocketConnection) { - ticker := time.NewTicker(pingInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - deadline := time.Now().Add(10 * time.Second) - if err := wsConn.conn.WriteControl( - websocket.PingMessage, nil, deadline, - ); err != nil { - if !isCloseError(err) { - go e.listeners.broadcast(types.OnchainAddressEvent{ - Error: fmt.Errorf( - "failed to ping explorer for address %s: %s", - wsConn.address.get(), err, - ), - }) - } - return - } - } - } - }(ctx, wsConn) - } - } -} - -func (e *explorerSvc) trackWithPolling(ctx context.Context) { - ticker := time.NewTicker(e.pollInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - e.subscribedMu.RLock() - // make a snapshot copy of the map to avoid race conditions - subscribedMap := make(map[string]addressData, len(e.subscribedMap)) - for addr, data := range e.subscribedMap { - hashCopy := make([]byte, len(data.hash)) - copy(hashCopy, data.hash) - utxosCopy := make([]utxo, len(data.utxos)) - copy(utxosCopy, data.utxos) - - subscribedMap[addr] = addressData{ - hash: hashCopy, - utxos: utxosCopy, - script: data.script, - } - } - e.subscribedMu.RUnlock() - - if len(subscribedMap) == 0 { - continue - } - for addr, data := range subscribedMap { - newUtxos, err := e.getUtxos(addr, data.script) - if err != nil { - log.WithError(err).Error("explorer: failed to poll explorer") - go e.listeners.broadcast(types.OnchainAddressEvent{ - Error: fmt.Errorf("failed to poll explorer: %s", err), - }) - continue - } - hashedResp := newUtxos.hash() - if !bytes.Equal(data.hash, hashedResp) { - go e.sendAddressEventFromPolling(data.utxos, newUtxos) - e.subscribedMu.Lock() - e.subscribedMap[addr] = addressData{ - hash: hashedResp, - utxos: newUtxos, - script: data.script, - } - e.subscribedMu.Unlock() - } - - } - } - } -} - -func (e *explorerSvc) getUtxos(addr, script string) (utxos, error) { - resp, err := http.Get(fmt.Sprintf("%s/address/%s/utxo", e.baseUrl, addr)) - if err != nil { - return nil, err - } - - // nolint:all - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get utxos: %s", string(body)) - } - utxos := []utxo{} - if err := json.Unmarshal(body, &utxos); err != nil { - return nil, err - } - - for i := range utxos { - utxos[i].Script = script - } - - return utxos, nil -} - -func (e *explorerSvc) sendAddressEventFromWs(payload addressNotification) { - // Forward the error if we received one. - if len(payload.Error) > 0 { - e.listeners.broadcast(types.OnchainAddressEvent{ - Error: fmt.Errorf("%s", payload.Error), - }) - return - } - // Nothing to do if it's not the message we're looking for. - if payload.MultiAddrTx == nil { - return - } - - // Parse the message and send the event. - spentUtxos := make([]types.OnchainOutput, 0) - newUtxos := make([]types.OnchainOutput, 0) - confirmedUtxos := make([]types.OnchainOutput, 0) - replacements := make(map[string]string) - for addr, data := range payload.MultiAddrTx { - if len(data.Removed) > 0 { - for _, tx := range data.Removed { - if len(data.Mempool) > 0 { - replacementTxid := data.Mempool[0].Txid - replacements[tx.Txid] = replacementTxid - } - } - continue - } - if len(data.Mempool) > 0 { - for _, tx := range data.Mempool { - for _, in := range tx.Inputs { - if in.Prevout.Address == addr { - spentUtxos = append(spentUtxos, types.OnchainOutput{ - Outpoint: types.Outpoint{ - Txid: in.Txid, - VOut: uint32(in.Vout), - }, - SpentBy: tx.Txid, - Spent: true, - }) - } - } - for i, out := range tx.Outputs { - if out.Address == addr { - var createdAt time.Time - if tx.Status.Confirmed { - createdAt = time.Unix(tx.Status.BlockTime, 0) - } - newUtxos = append(newUtxos, types.OnchainOutput{ - Outpoint: types.Outpoint{ - Txid: tx.Txid, - VOut: uint32(i), - }, - Script: out.Script, - Amount: out.Amount, - CreatedAt: createdAt, - }) - } - } - } - } - if len(data.Confirmed) > 0 { - for _, tx := range data.Confirmed { - for i, out := range tx.Outputs { - if out.Address == addr { - confirmedUtxos = append(confirmedUtxos, types.OnchainOutput{ - Outpoint: types.Outpoint{ - Txid: tx.Txid, - VOut: uint32(i), - }, - Script: out.Script, - Amount: out.Amount, - CreatedAt: time.Unix(tx.Status.BlockTime, 0), - }) - } - } - } - } - } - - e.listeners.broadcast(types.OnchainAddressEvent{ - NewUtxos: newUtxos, - SpentUtxos: spentUtxos, - ConfirmedUtxos: confirmedUtxos, - Replacements: replacements, - }) -} - -func (e *explorerSvc) sendAddressEventFromPolling(oldUtxos, newUtxos []utxo) { - indexedOldUtxos := make(map[string]utxo, 0) - indexedNewUtxos := make(map[string]utxo, 0) - for _, oldUtxo := range oldUtxos { - indexedOldUtxos[fmt.Sprintf("%s:%d", oldUtxo.Txid, oldUtxo.Vout)] = oldUtxo - } - for _, newUtxo := range newUtxos { - indexedNewUtxos[fmt.Sprintf("%s:%d", newUtxo.Txid, newUtxo.Vout)] = newUtxo - } - spentUtxos := make([]types.OnchainOutput, 0) - for _, oldUtxo := range oldUtxos { - if _, ok := indexedNewUtxos[fmt.Sprintf("%s:%d", oldUtxo.Txid, oldUtxo.Vout)]; !ok { - var spentBy string - spentStatus, _ := e.GetTxOutspends(oldUtxo.Txid) - if len(spentStatus) > int(oldUtxo.Vout) { - spentBy = spentStatus[oldUtxo.Vout].SpentBy - } - spentUtxos = append(spentUtxos, types.OnchainOutput{ - Outpoint: types.Outpoint{ - Txid: oldUtxo.Txid, - VOut: oldUtxo.Vout, - }, - SpentBy: spentBy, - Spent: true, - }) - } - } - receivedUtxos := make([]types.OnchainOutput, 0) - confirmedUtxos := make([]types.OnchainOutput, 0) - for _, newUtxo := range newUtxos { - oldUtxo, ok := indexedOldUtxos[fmt.Sprintf("%s:%d", newUtxo.Txid, newUtxo.Vout)] - if !ok { - utxo := types.OnchainOutput{ - Outpoint: types.Outpoint{ - Txid: newUtxo.Txid, - VOut: newUtxo.Vout, - }, - Script: newUtxo.Script, - Amount: newUtxo.Amount, - } - - if newUtxo.Status.Confirmed { - utxo.CreatedAt = time.Unix(newUtxo.Status.BlockTime, 0) - } - - receivedUtxos = append(receivedUtxos, utxo) - continue - } - - if !oldUtxo.Status.Confirmed && newUtxo.Status.Confirmed { - confirmedUtxos = append(confirmedUtxos, types.OnchainOutput{ - Outpoint: types.Outpoint{ - Txid: newUtxo.Txid, - VOut: newUtxo.Vout, - }, - Script: newUtxo.Script, - Amount: newUtxo.Amount, - CreatedAt: time.Unix(newUtxo.Status.BlockTime, 0), - }) - } - } - - if len(spentUtxos) > 0 || len(receivedUtxos) > 0 || len(confirmedUtxos) > 0 { - go e.listeners.broadcast(types.OnchainAddressEvent{ - SpentUtxos: spentUtxos, - NewUtxos: receivedUtxos, - ConfirmedUtxos: confirmedUtxos, - }) - } -} - -func (e *explorerSvc) getTxHex(txid string) (string, error) { - resp, err := http.Get(fmt.Sprintf("%s/tx/%s/hex", e.baseUrl, txid)) - if err != nil { - return "", err - } - // nolint:all - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to get tx hex: %s", string(body)) - } - - hex := string(body) - e.cache.Set(txid, hex) - return hex, nil -} - -func (e *explorerSvc) broadcast(txHex string) (string, error) { - body := bytes.NewBuffer([]byte(txHex)) - - resp, err := http.Post(fmt.Sprintf("%s/tx", e.baseUrl), "text/plain", body) - if err != nil { - return "", err - } - // nolint:all - defer resp.Body.Close() - bodyResponse, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to broadcast: %s", string(bodyResponse)) - } - - return string(bodyResponse), nil -} - -func (e *explorerSvc) broadcastPackage(txs ...string) (string, error) { - url := fmt.Sprintf("%s/txs/package", e.baseUrl) - - // body is a json array of txs hex - body := bytes.NewBuffer(nil) - if err := json.NewEncoder(body).Encode(txs); err != nil { - return "", err - } - - resp, err := http.Post(url, "application/json", body) - if err != nil { - return "", err - } - // nolint - defer resp.Body.Close() - - bodyResponse, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to broadcast package: %s", string(bodyResponse)) - } - - return string(bodyResponse), nil -} diff --git a/pkg/client-lib/explorer/mempool/utils_test.go b/pkg/client-lib/explorer/mempool/utils_test.go deleted file mode 100644 index 824fdfc82..000000000 --- a/pkg/client-lib/explorer/mempool/utils_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package mempoolexplorer - -import ( - "context" - "fmt" - "net" - "os" - "syscall" - "testing" - - "github.com/gorilla/websocket" - "github.com/stretchr/testify/require" -) - -func TestIsCloseError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "websocket normal closure", - err: &websocket.CloseError{Code: websocket.CloseNormalClosure}, - expected: true, - }, - { - name: "websocket going away", - err: &websocket.CloseError{Code: websocket.CloseGoingAway}, - expected: true, - }, - { - // CloseAbnormalClosure = TCP dropped without WS close frame. - // Must trigger reconnect, not a permanent clean close. - name: "websocket abnormal closure is not a close error", - err: &websocket.CloseError{Code: websocket.CloseAbnormalClosure}, - expected: false, - }, - { - name: "net.ErrClosed", - err: net.ErrClosed, - expected: true, - }, - { - name: "net.ErrClosed wrapped in net.OpError", - err: &net.OpError{Op: "read", Err: net.ErrClosed}, - expected: true, - }, - { - name: "context canceled", - err: context.Canceled, - expected: true, - }, - { - // Exact shape produced by gorilla/websocket WriteControl on a dead TCP connection: - // write tcp ->: write: broken pipe - name: "broken pipe wrapped in net.OpError", - err: &net.OpError{ - Op: "write", - Err: &os.SyscallError{Syscall: "write", Err: syscall.EPIPE}, - }, - expected: true, - }, - { - name: "plain broken pipe syscall error", - err: fmt.Errorf("write failed: %w", syscall.EPIPE), - expected: true, - }, - { - name: "timeout error is not a close error", - err: os.ErrDeadlineExceeded, - expected: false, - }, - { - name: "connection reset is not a close error", - err: &net.OpError{Op: "read", Err: &os.SyscallError{Syscall: "read", Err: syscall.ECONNRESET}}, - expected: false, - }, - { - name: "generic error is not a close error", - err: fmt.Errorf("some random error"), - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.expected, isCloseError(tt.err)) - }) - } -} - -func TestIsTimeoutError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "os.ErrDeadlineExceeded", - err: os.ErrDeadlineExceeded, - expected: true, - }, - { - name: "context deadline exceeded", - err: context.DeadlineExceeded, - expected: true, - }, - { - name: "network timeout via net.OpError", - err: &net.OpError{ - Op: "read", - Err: &timeoutError{}, - }, - expected: true, - }, - { - name: "ECONNRESET", - err: &net.OpError{Op: "read", Err: &os.SyscallError{Syscall: "read", Err: syscall.ECONNRESET}}, - expected: true, - }, - { - // CloseAbnormalClosure = TCP dropped without WS close frame → reconnect. - name: "websocket abnormal closure triggers reconnect", - err: &websocket.CloseError{Code: websocket.CloseAbnormalClosure}, - expected: true, - }, - { - name: "broken pipe is not a timeout error", - err: &net.OpError{Op: "write", Err: &os.SyscallError{Syscall: "write", Err: syscall.EPIPE}}, - expected: false, - }, - { - name: "context canceled is not a timeout error", - err: context.Canceled, - expected: false, - }, - { - name: "generic error is not a timeout error", - err: fmt.Errorf("something went wrong"), - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.expected, isTimeoutError(tt.err)) - }) - } -} - -// timeoutError is a helper that implements the Timeout() bool interface. -type timeoutError struct{} - -func (e *timeoutError) Error() string { return "timeout" } -func (e *timeoutError) Timeout() bool { return true } -func (e *timeoutError) Temporary() bool { return true } diff --git a/pkg/client-lib/explorer/mempool/opts.go b/pkg/client-lib/explorer/opts.go similarity index 96% rename from pkg/client-lib/explorer/mempool/opts.go rename to pkg/client-lib/explorer/opts.go index 6d50dddef..d2aedd64f 100644 --- a/pkg/client-lib/explorer/mempool/opts.go +++ b/pkg/client-lib/explorer/opts.go @@ -1,4 +1,4 @@ -package mempoolexplorer +package explorer import "time" diff --git a/pkg/client-lib/explorer/service.go b/pkg/client-lib/explorer/service.go index 5dcfa34ea..325ee7172 100644 --- a/pkg/client-lib/explorer/service.go +++ b/pkg/client-lib/explorer/service.go @@ -1,139 +1,1040 @@ +// Package explorer provides an explorer client with support for multiple concurrent WebSocket +// connections for addresses tracking. +// +// # Architecture +// +// - Multiple concurrent WebSocket connections +// - Hash-based address distribution for consistent routing +// - Automatic fallback to polling if WebSocket connections fails +// +// # Usage +// +// Basic usage with default settings: +// +// svc, err := clientlib.NewExplorer("", arklib.Bitcoin, clientlib.WithTracker(true)) +// if err != nil { +// log.Fatal(err) +// } +// defer svc.Stop() +// +// +// Subscribe to addresses: +// +// addresses := []string{"bc1q...", "bc1p...", ...} +// if err := svc.SubscribeForAddresses(addresses); err != nil { +// log.Fatal(err) +// } +// +// // Listen for events +// for event := range svc.GetAddressesEvents() { +// fmt.Printf("New UTXOs: %d, Spent: %d\n", len(event.NewUtxos), len(event.SpentUtxos)) +// } +// +// # Thread Safety +// +// All public methods are thread-safe and can be called concurrently. package explorer import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "slices" + "strings" + "sync" "time" arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/gorilla/websocket" + log "github.com/sirupsen/logrus" ) -// Explorer provides methods to interact with blockchain explorers (e.g., mempool.space, esplora). -// It supports both HTTP REST API calls and WebSocket connections for real-time address tracking. -// The implementation uses a connection pool architecture with multiple concurrent WebSocket connections -// to handle high-volume address subscriptions without overwhelming individual connections. -type Explorer interface { - // Start must be used when using the explorer with tracking enabled. - Start() +const ( + BitcoinExplorer = "bitcoin" + defaultPollInterval = 10 * time.Second + pongInterval = 60 * time.Second + pingInterval = (pongInterval * 9) / 10 +) - // GetTxHex retrieves the raw transaction hex for a given transaction ID. - GetTxHex(txid string) (string, error) +var ( + supportedExplorers = supportedType[string]{ + arklib.Bitcoin.Name: "https://mempool.arkade.sh/api", + arklib.BitcoinTestNet.Name: "https://mempool.space/testnet/api", + //arklib.BitcoinTestNet4.Name: "https://mempool.space/testnet4/api", //TODO uncomment once supported + arklib.BitcoinSigNet.Name: "https://mempool.signet.arkade.sh/api", + arklib.BitcoinMutinyNet.Name: "https://mempool.mutinynet.arkade.sh/api", + arklib.BitcoinRegTest.Name: "http://127.0.0.1:3000", + } +) - // Broadcast broadcasts one or more raw transactions to the network. - // Returns the transaction ID of the first transaction on success. - Broadcast(txs ...string) (string, error) +type explorerSvc struct { + cache *cache[string] + baseUrl string + net arklib.Network + connPool *connectionPool + connPoolMu sync.RWMutex + subscribedMu *sync.RWMutex + subscribedMap map[string]addressData + stopTracking func() + pollInterval time.Duration + noTracking bool + listeners *listeners +} - // GetTxs retrieves all transactions associated with a given address. - GetTxs(addr string) ([]Tx, error) +// NewExplorer creates a new Explorer instance for the specified network. +// If baseUrl is empty, it uses the default explorer URL for the network. +// +// The explorer supports: +// - Multiple concurrent WebSocket connections for scalability +// - Automatic fallback to polling if WebSocket connections fail +// +// Example: +// +// svc, err := clientlib.NewExplorer("https://mempool.space/api", arklib.Bitcoin, clientlib.WithTracker(true)) +func NewExplorer(url string, net arklib.Network, opts ...Option) (clientlib.Explorer, error) { + baseUrl := url + if len(baseUrl) <= 0 { + if !supportedExplorers.supports(net.Name) { + return nil, fmt.Errorf( + "network not supported, please chose one of %s, ", supportedExplorers.String(), + ) + } + baseUrl = supportedExplorers[net.Name] + } - // GetTxOutspends returns the spent status of all outputs for a given transaction. - GetTxOutspends(tx string) ([]SpentStatus, error) + if _, err := deriveWsURL(baseUrl); err != nil { + return nil, fmt.Errorf("invalid base url: %s", err) + } - // GetUtxos retrieves all unspent transaction outputs (UTXOs) for the given addresses. - GetUtxos(addresses []string) ([]Utxo, error) + svcOpts := &explorerSvc{ + pollInterval: defaultPollInterval, + } + for _, opt := range opts { + opt(svcOpts) + } - // GetRedeemedVtxosBalance calculates the redeemed virtual UTXO balance for an address - // considering the unilateral exit delay. - GetRedeemedVtxosBalance( - addr string, unilateralExitDelay arklib.RelativeLocktime, - ) (uint64, map[int64]uint64, error) + if svcOpts.noTracking { + return &explorerSvc{ + cache: newCache[string](), + baseUrl: baseUrl, + net: net, + noTracking: svcOpts.noTracking, + }, nil + } + if svcOpts.pollInterval <= 0 { + return nil, fmt.Errorf("poll interval must be positive") + } - // GetTxBlockTime returns whether a transaction is confirmed and its block time. - GetTxBlockTime(txid string) (confirmed bool, blocktime int64, err error) + svc := &explorerSvc{ + cache: newCache[string](), + baseUrl: baseUrl, + net: net, + subscribedMu: &sync.RWMutex{}, + subscribedMap: make(map[string]addressData), + pollInterval: svcOpts.pollInterval, + noTracking: svcOpts.noTracking, + } - // BaseUrl returns the base URL of the explorer service. - BaseUrl() string + return svc, nil +} - // GetFeeRate retrieves the current recommended fee rate in sat/vB. - GetFeeRate() (float64, error) +func (e *explorerSvc) Start() { + // Nothing to do if tracking disabled. + if e.noTracking { + return + } - // GetConnectionCount returns the number of active WebSocket connections. - GetConnectionCount() int + // Nothing to do if service already started. + if e.stopTracking != nil { + return + } + + // nolint + wsURL, _ := deriveWsURL(e.baseUrl) + ctx, cancel := context.WithCancel(context.Background()) + + connPool, err := newConnectionPool(ctx, wsURL) + if err != nil { + log.WithError(err).WithField("wsURL", wsURL).Debugf( + "explorer: failed to create connection pool, falling back to polling with interval %s", + e.pollInterval, + ) + } + e.connPoolMu.Lock() + e.connPool = connPool + e.connPoolMu.Unlock() + + e.listeners = newListeners() + e.stopTracking = cancel + go e.startTracking(ctx) + log.Debug("explorer: started with address tracking") +} - // GetSubscribedAddresses returns a list of all currently subscribed addresses. - GetSubscribedAddresses() []string +func (e *explorerSvc) Stop() { + // Nothing to do is tracking disabled. + if e.noTracking { + return + } + + // Nothing to do if service already stopped. + if e.stopTracking == nil { + return + } - // IsAddressSubscribed checks if a specific address is currently subscribed. - IsAddressSubscribed(address string) bool + e.stopTracking() - // GetAddressesEvents returns a channel that receives onchain address events - // (new UTXOs, spent UTXOs, confirmed UTXOs) for all subscribed addresses. - GetAddressesEvents() <-chan types.OnchainAddressEvent + // Close all connections in the pool + e.connPoolMu.RLock() + connPool := e.connPool + e.connPoolMu.RUnlock() + if connPool != nil { + connPool.mu.Lock() + for _, wsConn := range connPool.connections { + if wsConn.conn != nil { + if err := wsConn.conn.Close(); err != nil { + log.WithError(err).Warn("explorer: failed to close ws connection") + } + } + } + connPool.mu.Unlock() + } + log.Debug("explorer: closed all connections") - // SubscribeForAddresses subscribes to address updates via WebSocket connections. - // Addresses are automatically distributed across multiple connections using hash-based routing. - // Subscriptions are batched to prevent overwhelming individual connections. - // Duplicate subscriptions are automatically prevented via instance-scoped deduplication. - SubscribeForAddresses(addresses []string) error + // Clear subscribed addresses map + e.subscribedMu.Lock() + e.subscribedMap = make(map[string]addressData) + e.subscribedMu.Unlock() + e.listeners.clear() - // UnsubscribeForAddresses removes address subscriptions and updates the WebSocket connections. - UnsubscribeForAddresses(addresses []string) error + e.stopTracking = nil + log.Debug("explorer: stopped") +} - // Stop gracefully shuts down the explorer, closing all WebSocket connections and channels. - Stop() +func (e *explorerSvc) BaseUrl() string { + return e.baseUrl } -type SpentStatus struct { - Spent bool - SpentBy string +func (e *explorerSvc) GetNetwork() arklib.Network { + return e.net } -type Output struct { - Script string - Address string - Amount uint64 +func (e *explorerSvc) GetFeeRate() (float64, error) { + endpoint, err := url.JoinPath(e.baseUrl, "fee-estimates") + if err != nil { + return 0, err + } + + resp, err := http.Get(endpoint) + if err != nil { + return 0, err + } + // nolint:all + defer resp.Body.Close() + + var response map[string]float64 + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return 0, err + } + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("failed to get fee rate: %s", resp.Status) + } + + if len(response) == 0 { + return 1, nil + } + + return response["1"], nil } -type Input struct { - Output - Txid string - Vout uint32 +func (e *explorerSvc) GetConnectionCount() int { + e.connPoolMu.RLock() + defer e.connPoolMu.RUnlock() + if e.connPool == nil { + return 0 + } + return e.connPool.getConnectionCount() } -type Tx struct { - Txid string - Vin []Input - Vout []Output - Status ConfirmedStatus +func (e *explorerSvc) GetSubscribedAddresses() []string { + e.subscribedMu.RLock() + defer e.subscribedMu.RUnlock() + return slices.Collect(maps.Keys(e.subscribedMap)) } -type ConfirmedStatus struct { - Confirmed bool - BlockTime int64 +func (e *explorerSvc) IsAddressSubscribed(address string) bool { + e.subscribedMu.RLock() + defer e.subscribedMu.RUnlock() + _, exists := e.subscribedMap[address] + return exists } -// Utxo represents an unspent transaction output from the blockchain explorer. -type Utxo struct { - Txid string - Vout uint32 - Amount uint64 - Script string - Status ConfirmedStatus +func (e *explorerSvc) GetAddressesEvents() <-chan clientlib.OnchainAddressEvent { + if e.listeners == nil { + return nil + } + ch := make(chan clientlib.OnchainAddressEvent) + e.listeners.add(ch) + return ch } -// ToUtxo converts the explorer UTXO to the internal types.Utxo format -// with the specified relative locktime delay and tapscripts. -func (e Utxo) ToUtxo(delay arklib.RelativeLocktime, tapscripts []string) types.Utxo { - return newUtxo(e, delay, tapscripts) +func (e *explorerSvc) GetTxHex(txid string) (string, error) { + if hex, ok := e.cache.get(txid); ok { + return hex, nil + } + + txHex, err := e.getTxHex(txid) + if err != nil { + return "", err + } + + e.cache.set(txid, txHex) + + return txHex, nil } -func newUtxo(explorerUtxo Utxo, delay arklib.RelativeLocktime, tapscripts []string) types.Utxo { - utxoTime := explorerUtxo.Status.BlockTime - createdAt := time.Unix(utxoTime, 0) - if utxoTime == 0 { - createdAt = time.Time{} - utxoTime = time.Now().Unix() +func (e *explorerSvc) Broadcast(txs ...string) (string, error) { + if len(txs) == 0 { + return "", fmt.Errorf("no txs to broadcast") + } + + for _, tx := range txs { + txStr, txid, err := parseBitcoinTx(tx) + if err != nil { + return "", err + } + + e.cache.set(txid, txStr) } - return types.Utxo{ - Outpoint: types.Outpoint{ - Txid: explorerUtxo.Txid, - VOut: explorerUtxo.Vout, - }, - Amount: explorerUtxo.Amount, - Script: explorerUtxo.Script, - Delay: delay, - SpendableAt: time.Unix(utxoTime, 0).Add(time.Duration(delay.Seconds()) * time.Second), - CreatedAt: createdAt, - Tapscripts: tapscripts, + if len(txs) == 1 { + txid, err := e.broadcast(txs[0]) + if err != nil { + if strings.Contains( + strings.ToLower(err.Error()), "transaction already in block chain", + ) { + return txid, nil + } + + return "", err + } + + return txid, nil } + + // package + return e.broadcastPackage(txs...) +} + +func (e *explorerSvc) GetTxs(addr string) ([]clientlib.Tx, error) { + resp, err := http.Get(fmt.Sprintf("%s/address/%s/txs", e.baseUrl, addr)) + if err != nil { + return nil, err + } + // nolint:all + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get txs: %s", string(body)) + } + payload := txs{} + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + + return payload.toList(), nil +} + +func (e *explorerSvc) SubscribeForAddresses(addresses []string) error { + if e.noTracking { + return nil + } + + e.subscribedMu.Lock() + defer e.subscribedMu.Unlock() + + addressesToSubscribe := make([]string, 0, len(addresses)) + scripts := make(map[string]string) + for _, addr := range addresses { + if _, ok := e.subscribedMap[addr]; ok { + continue + } + decoded, err := btcutil.DecodeAddress(addr, nil) + if err != nil { + return fmt.Errorf("invalid address: %s", err) + } + + outputScript, err := txscript.PayToAddrScript(decoded) + if err != nil { + return fmt.Errorf("invalid address: %s", err) + } + addressesToSubscribe = append(addressesToSubscribe, addr) + scripts[addr] = hex.EncodeToString(outputScript) + } + + // Nothing to do if no addresses to subscribe. + if len(addressesToSubscribe) == 0 { + return nil + } + + var numAddressesLeftToSubscribe int + e.connPoolMu.RLock() + connPool := e.connPool + e.connPoolMu.RUnlock() + if connPool != nil && connPool.getConnectionCount() > 0 { + if connPool.noMoreConnections { + return fmt.Errorf( + "can't subscribe for any more addresses (max=%d)", + len(e.subscribedMap), + ) + } + + for i, addr := range addressesToSubscribe { + connId, err := connPool.pushAddress(addr) + if err != nil { + log.WithError(err).Warnf("failed to subscribe for address %s", addr) + numAddressesLeftToSubscribe = len(addressesToSubscribe[i:]) + addressesToSubscribe = addressesToSubscribe[:i] + break + } + log.Debugf("explorer: subscribed for new address on connection %d", connId) + // nolint + connPool.addConnection() + time.Sleep(time.Millisecond) + } + } + + // Add new addresses to the subscribed map + for _, addr := range addressesToSubscribe { + e.subscribedMap[addr] = addressData{script: scripts[addr]} + } + + if numAddressesLeftToSubscribe > 0 { + return fmt.Errorf( + "can't subscribe for any more addresses (max=%d) (left=%d)", + len(e.subscribedMap), numAddressesLeftToSubscribe, + ) + } + return nil +} + +func (e *explorerSvc) UnsubscribeForAddresses(addresses []string) error { + if e.noTracking { + return nil + } + + e.subscribedMu.Lock() + defer e.subscribedMu.Unlock() + + addressesToUnsubscribe := make([]string, 0, len(addresses)) + for _, addr := range addresses { + if _, ok := e.subscribedMap[addr]; !ok { + continue + } + addressesToUnsubscribe = append(addressesToUnsubscribe, addr) + } + + // Nothing to do if no addresses to unsubscribe. + if len(addressesToUnsubscribe) == 0 { + return nil + } + + e.connPoolMu.RLock() + connPool := e.connPool + e.connPoolMu.RUnlock() + if connPool != nil && connPool.getConnectionCount() > 0 { + for _, addr := range addressesToUnsubscribe { + _, found := connPool.getConnectionForAddress(addr) + if !found { + continue + } + connPool.popAddress(addr) + } + } + + for _, addr := range addresses { + delete(e.subscribedMap, addr) + } + + return nil +} + +func (e *explorerSvc) GetTxOutspends(txid string) ([]clientlib.SpentStatus, error) { + resp, err := http.Get(fmt.Sprintf("%s/tx/%s/outspends", e.baseUrl, txid)) + if err != nil { + return nil, err + } + + // nolint:all + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get txs: %s", string(body)) + } + + res := make([]spentStatus, 0) + if err := json.Unmarshal(body, &res); err != nil { + return nil, err + } + spentStatuses := make([]clientlib.SpentStatus, 0, len(res)) + for _, s := range res { + spentStatuses = append(spentStatuses, clientlib.SpentStatus{ + Spent: s.Spent, + SpentBy: s.SpentBy, + }) + } + return spentStatuses, nil +} + +func (e *explorerSvc) GetUtxos(addresses []string) ([]clientlib.ExplorerUtxo, error) { + if len(addresses) <= 0 { + return nil, fmt.Errorf("missing addresses") + } + + addrs := make(map[string]string) + for _, addr := range addresses { + decoded, err := btcutil.DecodeAddress(addr, nil) + if err != nil { + return nil, fmt.Errorf("invalid address: %s", err) + } + + outputScript, err := txscript.PayToAddrScript(decoded) + if err != nil { + return nil, fmt.Errorf("invalid address: %s", err) + } + + addrs[addr] = hex.EncodeToString(outputScript) + } + + allUtxos := make([]clientlib.ExplorerUtxo, 0) + count := 0 + for addr, script := range addrs { + utxos, err := e.getUtxos(addr, script) + if err != nil { + return nil, err + } + allUtxos = append(allUtxos, utxos.toUtxoList()...) + count++ + + // Throttle requests to not overload the clientlib. + if count%20 == 0 { + time.Sleep(time.Second) + } + } + return allUtxos, nil +} + +func (e *explorerSvc) GetRedeemedVtxosBalance( + addr string, unilateralExitDelay arklib.RelativeLocktime, +) (spendableBalance uint64, lockedBalance map[int64]uint64, err error) { + utxos, err := e.GetUtxos([]string{addr}) + if err != nil { + return + } + + lockedBalance = make(map[int64]uint64, 0) + now := time.Now() + for _, utxo := range utxos { + blocktime := now + if utxo.Status.Confirmed { + blocktime = time.Unix(utxo.Status.BlockTime, 0) + } + + delay := time.Duration(unilateralExitDelay.Seconds()) * time.Second + availableAt := blocktime.Add(delay) + if availableAt.After(now) { + if _, ok := lockedBalance[availableAt.Unix()]; !ok { + lockedBalance[availableAt.Unix()] = 0 + } + + lockedBalance[availableAt.Unix()] += utxo.Amount + } else { + spendableBalance += utxo.Amount + } + } + + return +} + +func (e *explorerSvc) GetTxBlockTime( + txid string, +) (confirmed bool, blocktime int64, err error) { + resp, err := http.Get(fmt.Sprintf("%s/tx/%s", e.baseUrl, txid)) + if err != nil { + return false, 0, err + } + // nolint:all + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, 0, err + } + + if resp.StatusCode != http.StatusOK { + return false, 0, fmt.Errorf("failed to get block time: %s", string(body)) + } + + var tx struct { + Status struct { + Confirmed bool `json:"confirmed"` + Blocktime int64 `json:"block_time"` + } `json:"status"` + } + if err := json.Unmarshal(body, &tx); err != nil { + return false, 0, err + } + + if !tx.Status.Confirmed { + return false, -1, nil + } + + return true, tx.Status.Blocktime, nil +} + +func (e *explorerSvc) startTracking(ctx context.Context) { + // If the ws endpoint is available (mempool.space url), read from websocket and eventually + // send notifications and periodically send a ping message to keep the connection alive. + e.connPoolMu.RLock() + connPool := e.connPool + e.connPoolMu.RUnlock() + if connPool != nil && connPool.getConnectionCount() > 0 { + // Start a listener and ping routine for each connection in the pool + e.trackWithWebsocket(ctx, connPool) + } else { + // Otherwise (esplora url), poll the explorer every 10s and manually send notifications of + // spent, new and confirmed utxos. + e.trackWithPolling(ctx) + } + +} + +func (e *explorerSvc) trackWithWebsocket(ctx context.Context, connPool *connectionPool) { + for { + select { + case <-ctx.Done(): + return + case wsConn := <-connPool.getNewConnections(): + // Go routine to listen for addresses updates from websocket. + go func(ctx context.Context, wsConn *websocketConnection) { + if err := wsConn.conn.SetReadDeadline(time.Now().Add(pongInterval)); err != nil { + if !isCloseError(err) { + go e.listeners.broadcast(clientlib.OnchainAddressEvent{Error: fmt.Errorf( + "connection for address %s dropped, please resubscribe: %w", + wsConn.address.get(), err, + )}) + } + return + } + wsConn.conn.SetPongHandler(func(string) error { + return wsConn.conn.SetReadDeadline(time.Now().Add(pongInterval)) + }) + for { + var payload addressNotification + if err := wsConn.conn.ReadJSON(&payload); err != nil { + // The connection was closed, nothing to do but return + if isCloseError(err) { + return + } + // Connection issues, try to reconnect: + // If this happens all active connections will arrive to this point. + // Since resetConnection makes use of a lock, its inner reconnection logic + // is executed to only one connection and once it is restored and the lock + // is released, all others will be restored as well + if isTimeoutError(err) { + log.Debugf( + "explorer: connection %d dropped, reconnecting...", wsConn.id, + ) + + addr := wsConn.address.get() + + if err := connPool.resetConnection(wsConn); err != nil { + go e.listeners.broadcast(clientlib.OnchainAddressEvent{ + Error: fmt.Errorf( + "failed to reset connection for address %s and "+ + "resubscription is required: %w", addr, err, + )}) + return + } + + if len(addr) > 0 { + if _, err := connPool.pushAddress(addr); err != nil { + go e.listeners.broadcast(clientlib.OnchainAddressEvent{ + Error: fmt.Errorf( + "failed to resubscribe for address %s and "+ + "resubscription is required: %w", addr, err, + )}) + return + } + } + log.Debugf("explorer: connection %d restored", wsConn.id) + // Get rid of this go routine + return + } + + go e.listeners.broadcast(clientlib.OnchainAddressEvent{Error: fmt.Errorf( + "failed to read message for address %s: %w", + wsConn.address.get(), err, + )}) + continue + } + + go e.sendAddressEventFromWs(payload) + } + }(ctx, wsConn) + + // Go routine to periodically send ping messages and keep the connection alive. + go func(ctx context.Context, wsConn *websocketConnection) { + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + deadline := time.Now().Add(10 * time.Second) + if err := wsConn.conn.WriteControl( + websocket.PingMessage, nil, deadline, + ); err != nil { + if !isCloseError(err) { + go e.listeners.broadcast(clientlib.OnchainAddressEvent{ + Error: fmt.Errorf( + "failed to ping explorer for address %s: %s", + wsConn.address.get(), err, + ), + }) + } + return + } + } + } + }(ctx, wsConn) + } + } +} + +func (e *explorerSvc) trackWithPolling(ctx context.Context) { + ticker := time.NewTicker(e.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + e.subscribedMu.RLock() + // make a snapshot copy of the map to avoid race conditions + subscribedMap := make(map[string]addressData, len(e.subscribedMap)) + for addr, data := range e.subscribedMap { + hashCopy := make([]byte, len(data.hash)) + copy(hashCopy, data.hash) + utxosCopy := make([]utxo, len(data.utxos)) + copy(utxosCopy, data.utxos) + + subscribedMap[addr] = addressData{ + hash: hashCopy, + utxos: utxosCopy, + script: data.script, + } + } + e.subscribedMu.RUnlock() + + if len(subscribedMap) == 0 { + continue + } + for addr, data := range subscribedMap { + newUtxos, err := e.getUtxos(addr, data.script) + if err != nil { + log.WithError(err).Error("explorer: failed to poll explorer") + go e.listeners.broadcast(clientlib.OnchainAddressEvent{ + Error: fmt.Errorf("failed to poll explorer: %s", err), + }) + continue + } + hashedResp := newUtxos.hash() + if !bytes.Equal(data.hash, hashedResp) { + go e.sendAddressEventFromPolling(data.utxos, newUtxos) + e.subscribedMu.Lock() + e.subscribedMap[addr] = addressData{ + hash: hashedResp, + utxos: newUtxos, + script: data.script, + } + e.subscribedMu.Unlock() + } + + } + } + } +} + +func (e *explorerSvc) getUtxos(addr, script string) (utxos, error) { + resp, err := http.Get(fmt.Sprintf("%s/address/%s/utxo", e.baseUrl, addr)) + if err != nil { + return nil, err + } + + // nolint:all + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get utxos: %s", string(body)) + } + utxos := []utxo{} + if err := json.Unmarshal(body, &utxos); err != nil { + return nil, err + } + + for i := range utxos { + utxos[i].Script = script + } + + return utxos, nil +} + +func (e *explorerSvc) sendAddressEventFromWs(payload addressNotification) { + // Forward the error if we received one. + if len(payload.Error) > 0 { + e.listeners.broadcast(clientlib.OnchainAddressEvent{ + Error: fmt.Errorf("%s", payload.Error), + }) + return + } + // Nothing to do if it's not the message we're looking for. + if payload.MultiAddrTx == nil { + return + } + + // Parse the message and send the event. + spentUtxos := make([]clientlib.OnchainOutput, 0) + newUtxos := make([]clientlib.OnchainOutput, 0) + confirmedUtxos := make([]clientlib.OnchainOutput, 0) + replacements := make(map[string]string) + for addr, data := range payload.MultiAddrTx { + if len(data.Removed) > 0 { + for _, tx := range data.Removed { + if len(data.Mempool) > 0 { + replacementTxid := data.Mempool[0].Txid + replacements[tx.Txid] = replacementTxid + } + } + continue + } + if len(data.Mempool) > 0 { + for _, tx := range data.Mempool { + for _, in := range tx.Inputs { + if in.Prevout.Address == addr { + spentUtxos = append(spentUtxos, clientlib.OnchainOutput{ + Outpoint: clientlib.Outpoint{ + Txid: in.Txid, + VOut: uint32(in.Vout), + }, + SpentBy: tx.Txid, + Spent: true, + }) + } + } + for i, out := range tx.Outputs { + if out.Address == addr { + var createdAt time.Time + if tx.Status.Confirmed { + createdAt = time.Unix(tx.Status.BlockTime, 0) + } + newUtxos = append(newUtxos, clientlib.OnchainOutput{ + Outpoint: clientlib.Outpoint{ + Txid: tx.Txid, + VOut: uint32(i), + }, + Script: out.Script, + Amount: out.Amount, + CreatedAt: createdAt, + }) + } + } + } + } + if len(data.Confirmed) > 0 { + for _, tx := range data.Confirmed { + for i, out := range tx.Outputs { + if out.Address == addr { + confirmedUtxos = append(confirmedUtxos, clientlib.OnchainOutput{ + Outpoint: clientlib.Outpoint{ + Txid: tx.Txid, + VOut: uint32(i), + }, + Script: out.Script, + Amount: out.Amount, + CreatedAt: time.Unix(tx.Status.BlockTime, 0), + }) + } + } + } + } + } + + e.listeners.broadcast(clientlib.OnchainAddressEvent{ + NewUtxos: newUtxos, + SpentUtxos: spentUtxos, + ConfirmedUtxos: confirmedUtxos, + Replacements: replacements, + }) +} + +func (e *explorerSvc) sendAddressEventFromPolling(oldUtxos, newUtxos []utxo) { + indexedOldUtxos := make(map[string]utxo, 0) + indexedNewUtxos := make(map[string]utxo, 0) + for _, oldUtxo := range oldUtxos { + indexedOldUtxos[fmt.Sprintf("%s:%d", oldUtxo.Txid, oldUtxo.Vout)] = oldUtxo + } + for _, newUtxo := range newUtxos { + indexedNewUtxos[fmt.Sprintf("%s:%d", newUtxo.Txid, newUtxo.Vout)] = newUtxo + } + spentUtxos := make([]clientlib.OnchainOutput, 0) + for _, oldUtxo := range oldUtxos { + if _, ok := indexedNewUtxos[fmt.Sprintf("%s:%d", oldUtxo.Txid, oldUtxo.Vout)]; !ok { + var spentBy string + spentStatus, _ := e.GetTxOutspends(oldUtxo.Txid) + if len(spentStatus) > int(oldUtxo.Vout) { + spentBy = spentStatus[oldUtxo.Vout].SpentBy + } + spentUtxos = append(spentUtxos, clientlib.OnchainOutput{ + Outpoint: clientlib.Outpoint{ + Txid: oldUtxo.Txid, + VOut: oldUtxo.Vout, + }, + SpentBy: spentBy, + Spent: true, + }) + } + } + receivedUtxos := make([]clientlib.OnchainOutput, 0) + confirmedUtxos := make([]clientlib.OnchainOutput, 0) + for _, newUtxo := range newUtxos { + oldUtxo, ok := indexedOldUtxos[fmt.Sprintf("%s:%d", newUtxo.Txid, newUtxo.Vout)] + if !ok { + utxo := clientlib.OnchainOutput{ + Outpoint: clientlib.Outpoint{ + Txid: newUtxo.Txid, + VOut: newUtxo.Vout, + }, + Script: newUtxo.Script, + Amount: newUtxo.Amount, + } + + if newUtxo.Status.Confirmed { + utxo.CreatedAt = time.Unix(newUtxo.Status.BlockTime, 0) + } + + receivedUtxos = append(receivedUtxos, utxo) + continue + } + + if !oldUtxo.Status.Confirmed && newUtxo.Status.Confirmed { + confirmedUtxos = append(confirmedUtxos, clientlib.OnchainOutput{ + Outpoint: clientlib.Outpoint{ + Txid: newUtxo.Txid, + VOut: newUtxo.Vout, + }, + Script: newUtxo.Script, + Amount: newUtxo.Amount, + CreatedAt: time.Unix(newUtxo.Status.BlockTime, 0), + }) + } + } + + if len(spentUtxos) > 0 || len(receivedUtxos) > 0 || len(confirmedUtxos) > 0 { + go e.listeners.broadcast(clientlib.OnchainAddressEvent{ + SpentUtxos: spentUtxos, + NewUtxos: receivedUtxos, + ConfirmedUtxos: confirmedUtxos, + }) + } +} + +func (e *explorerSvc) getTxHex(txid string) (string, error) { + resp, err := http.Get(fmt.Sprintf("%s/tx/%s/hex", e.baseUrl, txid)) + if err != nil { + return "", err + } + // nolint:all + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get tx hex: %s", string(body)) + } + + hex := string(body) + e.cache.set(txid, hex) + return hex, nil +} + +func (e *explorerSvc) broadcast(txHex string) (string, error) { + body := bytes.NewBuffer([]byte(txHex)) + + resp, err := http.Post(fmt.Sprintf("%s/tx", e.baseUrl), "text/plain", body) + if err != nil { + return "", err + } + // nolint:all + defer resp.Body.Close() + bodyResponse, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to broadcast: %s", string(bodyResponse)) + } + + return string(bodyResponse), nil +} + +func (e *explorerSvc) broadcastPackage(txs ...string) (string, error) { + url := fmt.Sprintf("%s/txs/package", e.baseUrl) + + // body is a json array of txs hex + body := bytes.NewBuffer(nil) + if err := json.NewEncoder(body).Encode(txs); err != nil { + return "", err + } + + resp, err := http.Post(url, "application/json", body) + if err != nil { + return "", err + } + // nolint + defer resp.Body.Close() + + bodyResponse, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to broadcast package: %s", string(bodyResponse)) + } + + return string(bodyResponse), nil } diff --git a/pkg/client-lib/explorer/service_test.go b/pkg/client-lib/explorer/service_test.go index fc77528f1..deba42c39 100644 --- a/pkg/client-lib/explorer/service_test.go +++ b/pkg/client-lib/explorer/service_test.go @@ -1,14 +1,20 @@ package explorer_test import ( + "bytes" + "encoding/hex" + "encoding/json" "fmt" "net/http" + "net/http/httptest" "sync" "testing" "time" arklib "github.com/arkade-os/arkd/pkg/ark-lib" - mempool "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-lib/explorer" + "github.com/btcsuite/btcd/wire" "github.com/gorilla/websocket" "github.com/stretchr/testify/require" ) @@ -1099,8 +1105,8 @@ func TestNewExplorerPollInterval(t *testing.T) { //It does not register /v1/ws, so websocket setup fails and the explorer falls back to polling. ts := newTestServer(t) - svc, err := mempool.NewExplorer( - ts.URL, arklib.Bitcoin, mempool.WithTracker(true), + svc, err := explorer.NewExplorer( + ts.URL, arklib.Bitcoin, explorer.WithTracker(true), ) require.NoError(t, err) @@ -1115,10 +1121,10 @@ func TestNewExplorerPollInterval(t *testing.T) { ts := newTestServer(t) for _, interval := range []time.Duration{0, -time.Second} { - _, err := mempool.NewExplorer( + _, err := explorer.NewExplorer( ts.URL, arklib.Bitcoin, - mempool.WithTracker(true), - mempool.WithPollInterval(interval), + explorer.WithTracker(true), + explorer.WithPollInterval(interval), ) require.ErrorContains(t, err, "poll interval must be positive") } @@ -1149,7 +1155,7 @@ func TestStartIsIdempotent(t *testing.T) { t.Run("start when noTracking is set is always a noop", func(t *testing.T) { ts := newTestServer(t) - svc, err := mempool.NewExplorer(ts.URL, arklib.Bitcoin, mempool.WithTracker(false)) + svc, err := explorer.NewExplorer(ts.URL, arklib.Bitcoin, explorer.WithTracker(false)) require.NoError(t, err) require.NotPanics(t, func() { svc.Start() }) @@ -1157,3 +1163,97 @@ func TestStartIsIdempotent(t *testing.T) { }) }) } + +// testServer wraps httptest.Server with a mutable mux so each test +// can register handlers before starting the clientlib. +type testServer struct { + *httptest.Server + mux *http.ServeMux +} + +func newTestServer(t *testing.T) *testServer { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return &testServer{Server: srv, mux: mux} +} + +func (ts *testServer) handle(pattern string, handler http.HandlerFunc) { + ts.mux.HandleFunc(pattern, handler) +} + +// jsonResponse returns a handler that writes the given status code and JSON-encodes body. +func (ts *testServer) jsonResponse(status int, body any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) + } +} + +// textResponse returns a handler that writes the given status code and plain text body. +func (ts *testServer) textResponse(status int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + fmt.Fprint(w, body) + } +} + +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// handleWS registers a WebSocket handler at /v1/ws. The onConn callback is +// called with a monotonically increasing connection number (1-based) and the +// upgraded connection. The callback is responsible for draining incoming +// messages (use keepAliveWS for a simple drain-and-block pattern). +func (ts *testServer) handleWS(onConn func(connNum int, conn *websocket.Conn)) { + var mu sync.Mutex + var count int + ts.handle("/v1/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + mu.Lock() + count++ + n := count + mu.Unlock() + + onConn(n, conn) + }) +} + +// validTxHex returns the hex of a minimal valid Bitcoin transaction (coinbase +// with a single OP_RETURN output) suitable for passing through parseBitcoinTx. +func validTxHex(t *testing.T) string { + t.Helper() + tx := wire.MsgTx{Version: 1, LockTime: 0} + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Index: 0xffffffff}, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{Value: 0, PkScript: []byte{0x6a}}) // OP_RETURN + var buf bytes.Buffer + require.NoError(t, tx.Serialize(&buf)) + return hex.EncodeToString(buf.Bytes()) +} + +// keepAliveWS is a convenience WS handler that blocks until the connection is +// closed. Use it when the test doesn't care what the server does. +func keepAliveWS(_ int, conn *websocket.Conn) { + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } +} + +func makeExplorer(t *testing.T, url string) clientlib.Explorer { + t.Helper() + + svc, err := explorer.NewExplorer(url, arklib.Bitcoin) + require.NoError(t, err) + return svc +} diff --git a/pkg/client-lib/explorer/mempool/types.go b/pkg/client-lib/explorer/types.go similarity index 78% rename from pkg/client-lib/explorer/mempool/types.go rename to pkg/client-lib/explorer/types.go index 9fca03897..a47e52347 100644 --- a/pkg/client-lib/explorer/mempool/types.go +++ b/pkg/client-lib/explorer/types.go @@ -1,4 +1,4 @@ -package mempoolexplorer +package explorer import ( "bytes" @@ -6,8 +6,9 @@ import ( "sort" "strconv" "strings" + "sync" - "github.com/arkade-os/arkd/pkg/client-lib/explorer" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" ) type spentStatus struct { @@ -39,34 +40,34 @@ type tx struct { type txs []tx -func (t txs) toList() []explorer.Tx { - txs := make([]explorer.Tx, 0) +func (t txs) toList() []clientlib.Tx { + txs := make([]clientlib.Tx, 0) for _, tx := range t { - ins := make([]explorer.Input, 0, len(tx.Vin)) + ins := make([]clientlib.Input, 0, len(tx.Vin)) for _, in := range tx.Vin { - ins = append(ins, explorer.Input{ + ins = append(ins, clientlib.Input{ Txid: in.Txid, Vout: in.Vout, - Output: explorer.Output{ + Output: clientlib.Output{ Script: in.Prevout.Script, Address: in.Prevout.Address, Amount: in.Prevout.Amount, }, }) } - outs := make([]explorer.Output, 0, len(tx.Vout)) + outs := make([]clientlib.Output, 0, len(tx.Vout)) for _, out := range tx.Vout { - outs = append(outs, explorer.Output{ + outs = append(outs, clientlib.Output{ Script: out.Script, Address: out.Address, Amount: out.Amount, }) } - txs = append(txs, explorer.Tx{ + txs = append(txs, clientlib.Tx{ Txid: tx.Txid, Vin: ins, Vout: outs, - Status: explorer.ConfirmedStatus{ + Status: clientlib.ConfirmedStatus{ Confirmed: tx.Status.Confirmed, BlockTime: tx.Status.Blocktime, }, @@ -150,14 +151,14 @@ func (u utxo) hash() []byte { type utxos []utxo -func (u utxos) toUtxoList() []explorer.Utxo { - utxos := make([]explorer.Utxo, 0) +func (u utxos) toUtxoList() []clientlib.ExplorerUtxo { + utxos := make([]clientlib.ExplorerUtxo, 0) for _, utxo := range u { - utxos = append(utxos, explorer.Utxo{ + utxos = append(utxos, clientlib.ExplorerUtxo{ Txid: utxo.Txid, Vout: utxo.Vout, Amount: utxo.Amount, - Status: explorer.ConfirmedStatus{ + Status: clientlib.ConfirmedStatus{ Confirmed: utxo.Status.Confirmed, BlockTime: utxo.Status.BlockTime, }, @@ -183,3 +184,30 @@ func (u utxos) hash() []byte { hash := sha256.Sum256(buf.Bytes()) return hash[:] } + +type cache[V any] struct { + mapping map[string]V + lock *sync.RWMutex +} + +func newCache[V any]() *cache[V] { + return &cache[V]{ + mapping: make(map[string]V), + lock: &sync.RWMutex{}, + } +} + +func (c *cache[V]) set(key string, value V) { + c.lock.Lock() + defer c.lock.Unlock() + + c.mapping[key] = value +} + +func (c *cache[V]) get(key string) (V, bool) { + c.lock.RLock() + defer c.lock.RUnlock() + + val, ok := c.mapping[key] + return val, ok +} diff --git a/pkg/client-lib/explorer/mempool/utils.go b/pkg/client-lib/explorer/utils.go similarity index 91% rename from pkg/client-lib/explorer/mempool/utils.go rename to pkg/client-lib/explorer/utils.go index 6b91cb0ec..dd3d53495 100644 --- a/pkg/client-lib/explorer/mempool/utils.go +++ b/pkg/client-lib/explorer/utils.go @@ -1,4 +1,4 @@ -package mempoolexplorer +package explorer import ( "bytes" @@ -135,3 +135,18 @@ func isTimeoutError(err error) bool { // All other errors are potentially temporary (should retry with circuit breaker) return false } + +type supportedType[V any] map[string]V + +func (t supportedType[V]) String() string { + types := make([]string, 0, len(t)) + for tt := range t { + types = append(types, tt) + } + return strings.Join(types, " | ") +} + +func (t supportedType[V]) supports(typeStr string) bool { + _, ok := t[typeStr] + return ok +} diff --git a/pkg/client-lib/explorer/utils_test.go b/pkg/client-lib/explorer/utils_test.go index 3ee1e95a3..72e855008 100644 --- a/pkg/client-lib/explorer/utils_test.go +++ b/pkg/client-lib/explorer/utils_test.go @@ -1,113 +1,156 @@ -package explorer_test +package explorer import ( - "bytes" - "encoding/hex" - "encoding/json" + "context" "fmt" - "net/http" - "net/http/httptest" - "sync" + "net" + "os" + "syscall" "testing" - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - explorer "github.com/arkade-os/arkd/pkg/client-lib/explorer" - mempoolexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" - "github.com/btcsuite/btcd/wire" "github.com/gorilla/websocket" "github.com/stretchr/testify/require" ) -// testServer wraps httptest.Server with a mutable mux so each test -// can register handlers before starting the explorer. -type testServer struct { - *httptest.Server - mux *http.ServeMux -} - -func newTestServer(t *testing.T) *testServer { - t.Helper() - mux := http.NewServeMux() - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - return &testServer{Server: srv, mux: mux} -} - -func (ts *testServer) handle(pattern string, handler http.HandlerFunc) { - ts.mux.HandleFunc(pattern, handler) -} - -// jsonResponse returns a handler that writes the given status code and JSON-encodes body. -func (ts *testServer) jsonResponse(status int, body any) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(body) +func TestIsCloseError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "websocket normal closure", + err: &websocket.CloseError{Code: websocket.CloseNormalClosure}, + expected: true, + }, + { + name: "websocket going away", + err: &websocket.CloseError{Code: websocket.CloseGoingAway}, + expected: true, + }, + { + // CloseAbnormalClosure = TCP dropped without WS close frame. + // Must trigger reconnect, not a permanent clean close. + name: "websocket abnormal closure is not a close error", + err: &websocket.CloseError{Code: websocket.CloseAbnormalClosure}, + expected: false, + }, + { + name: "net.ErrClosed", + err: net.ErrClosed, + expected: true, + }, + { + name: "net.ErrClosed wrapped in net.OpError", + err: &net.OpError{Op: "read", Err: net.ErrClosed}, + expected: true, + }, + { + name: "context canceled", + err: context.Canceled, + expected: true, + }, + { + // Exact shape produced by gorilla/websocket WriteControl on a dead TCP connection: + // write tcp ->: write: broken pipe + name: "broken pipe wrapped in net.OpError", + err: &net.OpError{ + Op: "write", + Err: &os.SyscallError{Syscall: "write", Err: syscall.EPIPE}, + }, + expected: true, + }, + { + name: "plain broken pipe syscall error", + err: fmt.Errorf("write failed: %w", syscall.EPIPE), + expected: true, + }, + { + name: "timeout error is not a close error", + err: os.ErrDeadlineExceeded, + expected: false, + }, + { + name: "connection reset is not a close error", + err: &net.OpError{Op: "read", Err: &os.SyscallError{Syscall: "read", Err: syscall.ECONNRESET}}, + expected: false, + }, + { + name: "generic error is not a close error", + err: fmt.Errorf("some random error"), + expected: false, + }, } -} -// textResponse returns a handler that writes the given status code and plain text body. -func (ts *testServer) textResponse(status int, body string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(status) - fmt.Fprint(w, body) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, isCloseError(tt.err)) + }) } } -var wsUpgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, -} - -// handleWS registers a WebSocket handler at /v1/ws. The onConn callback is -// called with a monotonically increasing connection number (1-based) and the -// upgraded connection. The callback is responsible for draining incoming -// messages (use keepAliveWS for a simple drain-and-block pattern). -func (ts *testServer) handleWS(onConn func(connNum int, conn *websocket.Conn)) { - var mu sync.Mutex - var count int - ts.handle("/v1/ws", func(w http.ResponseWriter, r *http.Request) { - conn, err := wsUpgrader.Upgrade(w, r, nil) - if err != nil { - return - } - mu.Lock() - count++ - n := count - mu.Unlock() - - onConn(n, conn) - }) -} - -// validTxHex returns the hex of a minimal valid Bitcoin transaction (coinbase -// with a single OP_RETURN output) suitable for passing through parseBitcoinTx. -func validTxHex(t *testing.T) string { - t.Helper() - tx := wire.MsgTx{Version: 1, LockTime: 0} - tx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{Index: 0xffffffff}, - Sequence: wire.MaxTxInSequenceNum, - }) - tx.AddTxOut(&wire.TxOut{Value: 0, PkScript: []byte{0x6a}}) // OP_RETURN - var buf bytes.Buffer - require.NoError(t, tx.Serialize(&buf)) - return hex.EncodeToString(buf.Bytes()) -} +func TestIsTimeoutError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "os.ErrDeadlineExceeded", + err: os.ErrDeadlineExceeded, + expected: true, + }, + { + name: "context deadline exceeded", + err: context.DeadlineExceeded, + expected: true, + }, + { + name: "network timeout via net.OpError", + err: &net.OpError{ + Op: "read", + Err: &timeoutError{}, + }, + expected: true, + }, + { + name: "ECONNRESET", + err: &net.OpError{Op: "read", Err: &os.SyscallError{Syscall: "read", Err: syscall.ECONNRESET}}, + expected: true, + }, + { + // CloseAbnormalClosure = TCP dropped without WS close frame → reconnect. + name: "websocket abnormal closure triggers reconnect", + err: &websocket.CloseError{Code: websocket.CloseAbnormalClosure}, + expected: true, + }, + { + name: "broken pipe is not a timeout error", + err: &net.OpError{Op: "write", Err: &os.SyscallError{Syscall: "write", Err: syscall.EPIPE}}, + expected: false, + }, + { + name: "context canceled is not a timeout error", + err: context.Canceled, + expected: false, + }, + { + name: "generic error is not a timeout error", + err: fmt.Errorf("something went wrong"), + expected: false, + }, + } -// keepAliveWS is a convenience WS handler that blocks until the connection is -// closed. Use it when the test doesn't care what the server does. -func keepAliveWS(_ int, conn *websocket.Conn) { - for { - if _, _, err := conn.ReadMessage(); err != nil { - return - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, isTimeoutError(tt.err)) + }) } } -func makeExplorer(t *testing.T, url string) explorer.Explorer { - t.Helper() +// timeoutError is a helper that implements the Timeout() bool interface. +type timeoutError struct{} - svc, err := mempoolexplorer.NewExplorer(url, arklib.Bitcoin) - require.NoError(t, err) - return svc -} +func (e *timeoutError) Error() string { return "timeout" } +func (e *timeoutError) Timeout() bool { return true } +func (e *timeoutError) Temporary() bool { return true } diff --git a/pkg/client-lib/go.mod b/pkg/client-lib/go.mod index 5570d4a54..cd50456e8 100644 --- a/pkg/client-lib/go.mod +++ b/pkg/client-lib/go.mod @@ -19,18 +19,15 @@ require ( github.com/btcsuite/btcd/btcutil/psbt v1.1.9 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/btcsuite/btcwallet v0.16.10-0.20240718224643-db3a4a2543bd - github.com/golang-migrate/migrate/v4 v4.17.1 github.com/gorilla/websocket v1.5.3 github.com/lightningnetwork/lnd v0.18.2-beta github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.48.0 google.golang.org/grpc v1.79.3 ) require ( cel.dev/expr v0.25.1 // indirect - dario.cat/mergo v1.0.1 // indirect github.com/aead/siphash v1.0.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/arkade-os/arkd/pkg/errors v0.0.0-00010101000000-000000000000 // indirect @@ -42,85 +39,38 @@ require ( github.com/btcsuite/btcwallet/wtxmgr v1.5.3 // indirect github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect - github.com/coreos/go-semver v0.3.1 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/decred/dcrd/lru v1.1.3 // indirect - github.com/docker/cli v27.1.1+incompatible // indirect - github.com/fergusstrange/embedded-postgres v1.28.0 // indirect - github.com/go-errors/errors v1.5.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/google/btree v1.1.2 // indirect + github.com/decred/dcrd/lru v1.1.2 // indirect github.com/google/cel-go v0.26.1 // indirect - github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgtype v1.14.3 // indirect - github.com/jackc/pgx/v4 v4.18.3 // indirect - github.com/jackc/pgx/v5 v5.6.0 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jessevdk/go-flags v1.6.1 // indirect - github.com/jonboulle/clockwork v0.4.0 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kkdai/bstream v1.0.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd // indirect github.com/lightninglabs/neutrino/cache v1.1.2 // indirect - github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb // indirect github.com/lightningnetwork/lnd/clock v1.1.1 // indirect github.com/lightningnetwork/lnd/fn v1.2.1 // indirect - github.com/lightningnetwork/lnd/healthcheck v1.2.5 // indirect - github.com/lightningnetwork/lnd/kvdb v1.4.10 // indirect github.com/lightningnetwork/lnd/queue v1.1.1 // indirect - github.com/lightningnetwork/lnd/sqldb v1.0.3 // indirect github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect github.com/lightningnetwork/lnd/tlv v1.2.6 // indirect - github.com/lightningnetwork/lnd/tor v1.1.3 // indirect - github.com/ltcsuite/ltcd v0.23.5 // indirect github.com/meshapi/grpc-api-gateway v0.1.0 // indirect - github.com/miekg/dns v1.1.61 // indirect - github.com/moby/sys/user v0.4.0 // indirect - github.com/opencontainers/runc v1.2.4 // indirect - github.com/ory/dockertest/v3 v3.11.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.19.1 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 // indirect - github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 // indirect - go.etcd.io/etcd/api/v3 v3.5.15 // indirect - go.etcd.io/etcd/client/pkg/v3 v3.5.15 // indirect - go.etcd.io/etcd/client/v3 v3.5.15 // indirect - go.etcd.io/etcd/server/v3 v3.5.15 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.uber.org/atomic v1.11.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.6.0 // indirect google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - lukechampine.com/blake3 v1.3.0 // indirect - modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect - modernc.org/libc v1.59.3 // indirect - modernc.org/sqlite v1.33.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/pkg/client-lib/go.sum b/pkg/client-lib/go.sum index 597a3e952..a80cb9cd4 100644 --- a/pkg/client-lib/go.sum +++ b/pkg/client-lib/go.sum @@ -1,14 +1,9 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= @@ -17,7 +12,6 @@ github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= @@ -61,25 +55,17 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0 h1:J9B4L7e3oqhXOcm+2IuNApwzQec85lE+QaikUcCs+dk= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= +github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= -github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= -github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= -github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= +github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c= +github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -92,50 +78,35 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= -github.com/decred/dcrd/lru v1.1.3 h1:w9EAbvGLyzm6jTjF83UKuqZEiUtJmvRhQDOCEIvSuE0= -github.com/decred/dcrd/lru v1.1.3/go.mod h1:Tw0i0pJyiLEx/oZdHLe1Wdv/Y7EGzAX+sYftnmxBR4o= -github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE= -github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= -github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/decred/dcrd/lru v1.1.2 h1:KdCzlkxppuoIDGEvCGah1fZRicrDH36IipvlB1ROkFY= +github.com/decred/dcrd/lru v1.1.2/go.mod h1:gEdCVgXs1/YoBvFWt7Scgknbhwik3FgVSzlnCcXL2N8= +github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M= +github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= +github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fergusstrange/embedded-postgres v1.28.0 h1:Atixd24HCuBHBavnG4eiZAjRizOViwUahKGSjJdz1SU= -github.com/fergusstrange/embedded-postgres v1.28.0/go.mod h1:t/MLs0h9ukYM6FSt99R7InCHs1nW0ordoVCcnzmpTYw= +github.com/fergusstrange/embedded-postgres v1.25.0 h1:sa+k2Ycrtz40eCRPOzI7Ry7TtkWXXJ+YRsxpKMDhxK0= +github.com/fergusstrange/embedded-postgres v1.25.0/go.mod h1:t/MLs0h9ukYM6FSt99R7InCHs1nW0ordoVCcnzmpTYw= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= -github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= -github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU= +github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= @@ -146,34 +117,28 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= -github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= @@ -181,97 +146,44 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= -github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= -github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= -github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= -github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= -github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= -github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= -github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= -github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= -github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= -github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= -github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= -github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= -github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= -github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= -github.com/jackc/pgtype v1.14.3 h1:h6W9cPuHsRWQFTWUZMAKMgG5jSwQI0Zurzdvlx3Plus= -github.com/jackc/pgtype v1.14.3/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= -github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= -github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= -github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= -github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU= github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= -github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= -github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= -github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= -github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc= @@ -280,55 +192,46 @@ github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd h1:D8aRo github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd/go.mod h1:x3OmY2wsA18+Kc3TSV2QpSUewOCiscw2mKpXgZv2kZk= github.com/lightninglabs/neutrino/cache v1.1.2 h1:C9DY/DAPaPxbFC+xNNEI/z1SJY9GS3shmlu5hIQ798g= github.com/lightninglabs/neutrino/cache v1.1.2/go.mod h1:XJNcgdOw1LQnanGjw8Vj44CvguYA25IMKjWFZczwZuo= -github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb h1:yfM05S8DXKhuCBp5qSMZdtSwvJ+GFzl94KbXMNB1JDY= -github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb/go.mod h1:c0kvRShutpj3l6B9WtTsNTBUtjSmjZXbJd9ZBRQOSKI= +github.com/lightningnetwork/lightning-onion v1.2.1-0.20230823005744-06182b1d7d2f h1:Pua7+5TcFEJXIIZ1I2YAUapmbcttmLj4TTi786bIi3s= +github.com/lightningnetwork/lightning-onion v1.2.1-0.20230823005744-06182b1d7d2f/go.mod h1:c0kvRShutpj3l6B9WtTsNTBUtjSmjZXbJd9ZBRQOSKI= github.com/lightningnetwork/lnd v0.18.2-beta h1:Qv4xQ2ka05vqzmdkFdISHCHP6CzHoYNVKfD18XPjHsM= github.com/lightningnetwork/lnd v0.18.2-beta/go.mod h1:cGQR1cVEZFZQcCx2VBbDY8xwGjCz+SupSopU1HpjP2I= github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0= github.com/lightningnetwork/lnd/clock v1.1.1/go.mod h1:mGnAhPyjYZQJmebS7aevElXKTFDuO+uNFFfMXK1W8xQ= github.com/lightningnetwork/lnd/fn v1.2.1 h1:pPsVGrwi9QBwdLJzaEGK33wmiVKOxs/zc8H7+MamFf0= github.com/lightningnetwork/lnd/fn v1.2.1/go.mod h1:SyFohpVrARPKH3XVAJZlXdVe+IwMYc4OMAvrDY32kw0= -github.com/lightningnetwork/lnd/healthcheck v1.2.5 h1:aTJy5xeBpcWgRtW/PGBDe+LMQEmNm/HQewlQx2jt7OA= -github.com/lightningnetwork/lnd/healthcheck v1.2.5/go.mod h1:G7Tst2tVvWo7cx6mSBEToQC5L1XOGxzZTPB29g9Rv2I= -github.com/lightningnetwork/lnd/kvdb v1.4.10 h1:vK89IVv1oVH9ubQWU+EmoCQFeVRaC8kfmOrqHbY5zoY= -github.com/lightningnetwork/lnd/kvdb v1.4.10/go.mod h1:J2diNABOoII9UrMnxXS5w7vZwP7CA1CStrl8MnIrb3A= +github.com/lightningnetwork/lnd/healthcheck v1.2.4 h1:lLPLac+p/TllByxGSlkCwkJlkddqMP5UCoawCj3mgFQ= +github.com/lightningnetwork/lnd/healthcheck v1.2.4/go.mod h1:G7Tst2tVvWo7cx6mSBEToQC5L1XOGxzZTPB29g9Rv2I= +github.com/lightningnetwork/lnd/kvdb v1.4.8 h1:xH0a5Vi1yrcZ5BEeF2ba3vlKBRxrL9uYXlWTjOjbNTY= +github.com/lightningnetwork/lnd/kvdb v1.4.8/go.mod h1:J2diNABOoII9UrMnxXS5w7vZwP7CA1CStrl8MnIrb3A= github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= -github.com/lightningnetwork/lnd/sqldb v1.0.3 h1:zLfAwOvM+6+3+hahYO9Q3h8pVV0TghAR7iJ5YMLCd3I= -github.com/lightningnetwork/lnd/sqldb v1.0.3/go.mod h1:4cQOkdymlZ1znnjuRNvMoatQGJkRneTj2CoPSPaQhWo= +github.com/lightningnetwork/lnd/sqldb v1.0.2 h1:PfuYzScYMD9/QonKo/QvgsbXfTnH5DfldIimkfdW4Bk= +github.com/lightningnetwork/lnd/sqldb v1.0.2/go.mod h1:V2Xl6JNWLTKE97WJnwfs0d0TYJdIQTqK8/3aAwkd3qI= github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM= github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.2.6 h1:icvQG2yDr6k3ZuZzfRdG3EJp6pHurcuh3R6dg0gv/Mw= github.com/lightningnetwork/lnd/tlv v1.2.6/go.mod h1:/CmY4VbItpOldksocmGT4lxiJqRP9oLxwSZOda2kzNQ= -github.com/lightningnetwork/lnd/tor v1.1.3 h1:hPIxSpT0UUJmt7iCbF4n4nsmkYe++fvQ/zRadeFfprY= -github.com/lightningnetwork/lnd/tor v1.1.3/go.mod h1:/LwOzgL6c+bVW0Aegoj1pGlxx9wSvbulBe876knJetc= -github.com/ltcsuite/ltcd v0.23.5 h1:MFWjmx2hCwxrUu9v0wdIPOSN7PHg9BWQeh+AO4FsVLI= -github.com/ltcsuite/ltcd v0.23.5/go.mod h1:JV6swXR5m0cYFi0VYdQPp3UnMdaDQxaRUCaU1PPjb+g= -github.com/ltcsuite/ltcd/chaincfg/chainhash v1.0.2 h1:xuWxvRKxLvOKuS7/Q/7I3tpc3cWAB0+hZpU8YdVqkzg= -github.com/ltcsuite/ltcd/chaincfg/chainhash v1.0.2/go.mod h1:nkLkAFGhursWf2U68gt61hPieK1I+0m78e+2aevNyD8= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/lightningnetwork/lnd/tor v1.1.2 h1:3zv9z/EivNFaMF89v3ciBjCS7kvCj4ZFG7XvD2Qq0/k= +github.com/lightningnetwork/lnd/tor v1.1.2/go.mod h1:j7T9uJ2NLMaHwE7GiBGnpYLn4f7NRoTM6qj+ul6/ycA= +github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw= +github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796/go.mod h1:3p7ZTf9V1sNPI5H8P3NkTFF4LuwMdPl2DodF60qAKqY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/meshapi/grpc-api-gateway v0.1.0 h1:0rGp4qZQ6T9Ud0KfzdHYsEju4AX/Q3AQOU7unoBLssY= github.com/meshapi/grpc-api-gateway v0.1.0/go.mod h1:lkFQUbwq7i/JqEPZMzCIRskp9Jb7tm1uLODwsOdw064= -github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= -github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= +github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -342,42 +245,29 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/opencontainers/runc v1.2.4 h1:yWFgLkghp71D76Fa0l349yAl5g4Gse7DPYNlvkQ9Eiw= -github.com/opencontainers/runc v1.2.4/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/ory/dockertest/v3 v3.11.0 h1:OiHcxKAvSDUwsEVh2BjxQQc/5EHz9n0va9awCtNGuyA= -github.com/ory/dockertest/v3 v3.11.0/go.mod h1:VIPxS1gwT9NpPOrfD3rACs8Y9Z7yhzO4SB194iUDnUI= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v1.1.12 h1:BOIssBaW1La0/qbNZHXOOa71dZfZEQOzW7dqQf3phss= +github.com/opencontainers/runc v1.1.12/go.mod h1:S+lQwSfncpBha7XTy/5lBwWgm5+y5Ma/O44Ekby9FK8= +github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= +github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/client_golang v1.11.1 h1:+4eQaD7vAZ6DsfsxB15hbE0odUjGI5ARs9yskGu1v4s= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= -github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= @@ -387,66 +277,55 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4dN7GR16kFc5fp3d1RIYzJW5onx8Ybykw2YQFA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= -go.etcd.io/etcd/api/v3 v3.5.15 h1:3KpLJir1ZEBrYuV2v+Twaa/e2MdDCEZ/70H+lzEiwsk= -go.etcd.io/etcd/api/v3 v3.5.15/go.mod h1:N9EhGzXq58WuMllgH9ZvnEr7SI9pS0k0+DHZezGp7jM= -go.etcd.io/etcd/client/pkg/v3 v3.5.15 h1:fo0HpWz/KlHGMCC+YejpiCmyWDEuIpnTDzpJLB5fWlA= -go.etcd.io/etcd/client/pkg/v3 v3.5.15/go.mod h1:mXDI4NAOwEiszrHCb0aqfAYNCrZP4e9hRca3d1YK8EU= -go.etcd.io/etcd/client/v2 v2.305.15 h1:VG2xbf8Vz1KJh65Ar2V5eDmfkp1bpzkSEHlhJM3usp8= -go.etcd.io/etcd/client/v2 v2.305.15/go.mod h1:Ad5dRjPVb/n5yXgAWQ/hXzuXXkBk0Y658ocuXYaUU48= -go.etcd.io/etcd/client/v3 v3.5.15 h1:23M0eY4Fd/inNv1ZfU3AxrbbOdW79r9V9Rl62Nm6ip4= -go.etcd.io/etcd/client/v3 v3.5.15/go.mod h1:CLSJxrYjvLtHsrPKsy7LmZEE+DK2ktfd2bN4RhBMwlU= -go.etcd.io/etcd/pkg/v3 v3.5.15 h1:/Iu6Sr3iYaAjy++8sIDoZW9/EfhcwLZwd4FOZX2mMOU= -go.etcd.io/etcd/pkg/v3 v3.5.15/go.mod h1:e3Acf298sPFmTCGTrnGvkClEw9RYIyPtNzi1XM8rets= -go.etcd.io/etcd/raft/v3 v3.5.15 h1:jOA2HJF7zb3wy8H/pL13e8geWqkEa/kUs0waUggZC0I= -go.etcd.io/etcd/raft/v3 v3.5.15/go.mod h1:k3r7P4seEiUcgxOPLp+mloJWV3Q4QLPGNvy/OgC8OtM= -go.etcd.io/etcd/server/v3 v3.5.15 h1:x35jrWnZgsRwMsFsUJIUdT1bvzIz1B+29HjMfRYVN/E= -go.etcd.io/etcd/server/v3 v3.5.15/go.mod h1:l9jX9oa/iuArjqz0RNX/TDbc70dLXxRZo/nmPucrpFo= +go.etcd.io/etcd/api/v3 v3.5.7 h1:sbcmosSVesNrWOJ58ZQFitHMdncusIifYcrBfwrlJSY= +go.etcd.io/etcd/api/v3 v3.5.7/go.mod h1:9qew1gCdDDLu+VwmeG+iFpL+QlpHTo7iubavdVDgCAA= +go.etcd.io/etcd/client/pkg/v3 v3.5.7 h1:y3kf5Gbp4e4q7egZdn5T7W9TSHUvkClN6u+Rq9mEOmg= +go.etcd.io/etcd/client/pkg/v3 v3.5.7/go.mod h1:o0Abi1MK86iad3YrWhgUsbGx1pmTS+hrORWc2CamuhY= +go.etcd.io/etcd/client/v2 v2.305.7 h1:AELPkjNR3/igjbO7CjyF1fPuVPjrblliiKj+Y6xSGOU= +go.etcd.io/etcd/client/v2 v2.305.7/go.mod h1:GQGT5Z3TBuAQGvgPfhR7VPySu/SudxmEkRq9BgzFU6s= +go.etcd.io/etcd/client/v3 v3.5.7 h1:u/OhpiuCgYY8awOHlhIhmGIGpxfBU/GZBUP3m/3/Iz4= +go.etcd.io/etcd/client/v3 v3.5.7/go.mod h1:sOWmj9DZUMyAngS7QQwCyAXXAL6WhgTOPLNS/NabQgw= +go.etcd.io/etcd/pkg/v3 v3.5.7 h1:obOzeVwerFwZ9trMWapU/VjDcYUJb5OfgC1zqEGWO/0= +go.etcd.io/etcd/pkg/v3 v3.5.7/go.mod h1:kcOfWt3Ov9zgYdOiJ/o1Y9zFfLhQjylTgL4Lru8opRo= +go.etcd.io/etcd/raft/v3 v3.5.7 h1:aN79qxLmV3SvIq84aNTliYGmjwsW6NqJSnqmI1HLJKc= +go.etcd.io/etcd/raft/v3 v3.5.7/go.mod h1:TflkAb/8Uy6JFBxcRaH2Fr6Slm9mCPVdI2efzxY96yU= +go.etcd.io/etcd/server/v3 v3.5.7 h1:BTBD8IJUV7YFgsczZMHhMTS67XuA4KpRquL0MFOJGRk= +go.etcd.io/etcd/server/v3 v3.5.7/go.mod h1:gxBgT84issUVBRpZ3XkW1T55NjOb4vZZRI4wVvNhf4A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0 h1:Wx7nFnvCaissIUZxPkBqDz2963Z+Cl+PkYbDKzTxDqQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0/go.mod h1:E5NNboN0UqSAki0Atn9kVwaN7I+l25gGxDqBueo/74E= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1 h1:ofMbch7i29qIUf7VtF+r0HRF6ac0SBaPSziSsKp7wkk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1/go.mod h1:Kv8liBeVNFkkkbilbgWRpV+wWuu+H5xdOT6HAgd30iw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1 h1:CFMFNoz+CGprjFAFy+RJFrfEe4GBia3RRm2a4fREvCA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1/go.mod h1:xOvWoTOrQjxjW61xtOmD/WKGRYb/P4NzRo3bs65U6Rk= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -455,186 +334,67 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.opentelemetry.io/proto/otlp v0.9.0 h1:C0g6TWmQYvjKRnljRULLWUVJGy8Uvu0NEL/5frY2/t4= +go.opentelemetry.io/proto/otlp v0.9.0/go.mod h1:1vKfU9rv61e9EVGthD1zNvUbiwPcimSsOPU9brfSHJg= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM= google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -646,44 +406,34 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= -lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= -modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M= -modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.59.3 h1:A4QAp1lRSn2/b4aU+wBtq+yeKgq/2BUevrj0p1ZNy2M= -modernc.org/libc v1.59.3/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= -modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/sqlite v1.29.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8= +modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/pkg/client-lib/identity/identity.go b/pkg/client-lib/identity.go similarity index 90% rename from pkg/client-lib/identity/identity.go rename to pkg/client-lib/identity.go index a7902953a..b04871b19 100644 --- a/pkg/client-lib/identity/identity.go +++ b/pkg/client-lib/identity.go @@ -1,9 +1,8 @@ -package identity +package clientlib import ( "context" - "github.com/arkade-os/arkd/pkg/ark-lib/tree" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/chaincfg" ) @@ -37,5 +36,4 @@ type Identity interface { ) (signedTx string, err error) SignMessage(ctx context.Context, message []byte) (signature string, err error) Dump(ctx context.Context) (seed string, err error) - NewVtxoTreeSigner(ctx context.Context) (tree.SignerSession, error) } diff --git a/pkg/client-lib/indexer/service.go b/pkg/client-lib/indexer.go similarity index 83% rename from pkg/client-lib/indexer/service.go rename to pkg/client-lib/indexer.go index b9f255745..2ddda1d69 100644 --- a/pkg/client-lib/indexer/service.go +++ b/pkg/client-lib/indexer.go @@ -1,23 +1,22 @@ -package indexer +package clientlib import ( "context" "github.com/arkade-os/arkd/pkg/ark-lib/asset" "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/types" ) type Indexer interface { GetCommitmentTx(ctx context.Context, txid string) (*CommitmentTx, error) GetVtxoTree( - ctx context.Context, batchOutpoint types.Outpoint, opts ...PageOption, + ctx context.Context, batchOutpoint Outpoint, opts ...PageOption, ) (*VtxoTreeResponse, error) GetFullVtxoTree( - ctx context.Context, batchOutpoint types.Outpoint, opts ...PageOption, + ctx context.Context, batchOutpoint Outpoint, opts ...PageOption, ) ([]tree.TxTreeNode, error) GetVtxoTreeLeaves( - ctx context.Context, batchOutpoint types.Outpoint, opts ...PageOption, + ctx context.Context, batchOutpoint Outpoint, opts ...PageOption, ) (*VtxoTreeLeavesResponse, error) GetForfeitTxs( ctx context.Context, txid string, opts ...PageOption, @@ -27,12 +26,12 @@ type Indexer interface { ) (*ConnectorsResponse, error) GetVtxos(ctx context.Context, opts ...GetVtxosOption) (*VtxosResponse, error) GetVtxoChain( - ctx context.Context, outpoint types.Outpoint, opts ...PageOption, + ctx context.Context, outpoint Outpoint, opts ...PageOption, ) (*VtxoChainResponse, error) GetVirtualTxs( ctx context.Context, txids []string, opts ...PageOption, ) (*VirtualTxsResponse, error) - GetBatchSweepTxs(ctx context.Context, batchOutpoint types.Outpoint) ([]string, error) + GetBatchSweepTxs(ctx context.Context, batchOutpoint Outpoint) ([]string, error) NewSubscription( ctx context.Context, scripts []string, ) (string, <-chan ScriptEvent, func(), error) @@ -46,7 +45,7 @@ type Indexer interface { type AssetInfo struct { AssetId string - Supply string + Supply uint64 ControlAssetId string Metadata []asset.Metadata } @@ -57,7 +56,7 @@ type VtxoTreeResponse struct { } type VtxoTreeLeavesResponse struct { - Leaves []types.Outpoint + Leaves []Outpoint Page *PageResponse } @@ -72,12 +71,12 @@ type ConnectorsResponse struct { } type VtxosResponse struct { - Vtxos []types.Vtxo + Vtxos []Vtxo Page *PageResponse } type TxHistoryResponse struct { - History []types.Transaction + History []Transaction Page *PageResponse } @@ -91,14 +90,9 @@ type VirtualTxsResponse struct { Page *PageResponse } -type TxData struct { - Txid string - Tx string -} - type ScriptEvent struct { Data *ScriptEventData - Connection *types.StreamConnectionEvent + Connection *StreamConnectionEvent Err error } @@ -106,8 +100,8 @@ type ScriptEventData struct { Txid string Tx string Scripts []string - NewVtxos []types.Vtxo - SpentVtxos []types.Vtxo + NewVtxos []Vtxo + SpentVtxos []Vtxo CheckpointTxs map[string]TxData } diff --git a/pkg/client-lib/indexer/grpc/cache.go b/pkg/client-lib/indexer/cache.go similarity index 99% rename from pkg/client-lib/indexer/grpc/cache.go rename to pkg/client-lib/indexer/cache.go index 222b2ec35..fe37650b5 100644 --- a/pkg/client-lib/indexer/grpc/cache.go +++ b/pkg/client-lib/indexer/cache.go @@ -1,4 +1,4 @@ -package grpcindexer +package indexer import ( "maps" diff --git a/pkg/client-lib/indexer/grpc/cache_test.go b/pkg/client-lib/indexer/cache_test.go similarity index 99% rename from pkg/client-lib/indexer/grpc/cache_test.go rename to pkg/client-lib/indexer/cache_test.go index 6f7fd3125..bb379c1d8 100644 --- a/pkg/client-lib/indexer/grpc/cache_test.go +++ b/pkg/client-lib/indexer/cache_test.go @@ -1,4 +1,4 @@ -package grpcindexer +package indexer import ( "fmt" diff --git a/pkg/client-lib/indexer/grpc/client.go b/pkg/client-lib/indexer/client.go similarity index 78% rename from pkg/client-lib/indexer/grpc/client.go rename to pkg/client-lib/indexer/client.go index 636e8ef57..1435dffba 100644 --- a/pkg/client-lib/indexer/grpc/client.go +++ b/pkg/client-lib/indexer/client.go @@ -1,8 +1,9 @@ -package grpcindexer +package indexer import ( "context" "fmt" + "strconv" "strings" "sync" "time" @@ -10,9 +11,8 @@ import ( arkv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/ark/v1" "github.com/arkade-os/arkd/pkg/ark-lib/asset" "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" "google.golang.org/grpc" "google.golang.org/grpc/backoff" "google.golang.org/grpc/credentials" @@ -32,7 +32,7 @@ type grpcClient struct { scripts *scriptsCache } -func NewClient(serverUrl string) (indexer.Indexer, error) { +func NewClient(serverUrl string) (clientlib.Indexer, error) { if len(serverUrl) <= 0 { return nil, fmt.Errorf("missing server url") } @@ -80,7 +80,7 @@ func NewClient(serverUrl string) (indexer.Indexer, error) { func (a *grpcClient) GetCommitmentTx( ctx context.Context, txid string, -) (*indexer.CommitmentTx, error) { +) (*clientlib.CommitmentTx, error) { req := &arkv1.GetCommitmentTxRequest{ Txid: txid, } @@ -89,9 +89,9 @@ func (a *grpcClient) GetCommitmentTx( return nil, err } - batches := make(map[uint32]*indexer.Batch) + batches := make(map[uint32]*clientlib.Batch) for vout, batch := range resp.GetBatches() { - batches[vout] = &indexer.Batch{ + batches[vout] = &clientlib.Batch{ TotalOutputAmount: batch.GetTotalOutputAmount(), TotalOutputVtxos: batch.GetTotalOutputVtxos(), ExpiresAt: batch.GetExpiresAt(), @@ -99,7 +99,7 @@ func (a *grpcClient) GetCommitmentTx( } } - return &indexer.CommitmentTx{ + return &clientlib.CommitmentTx{ StartedAt: resp.GetStartedAt(), EndedAt: resp.GetEndedAt(), TotalInputAmount: resp.GetTotalInputAmount(), @@ -111,9 +111,9 @@ func (a *grpcClient) GetCommitmentTx( } func (a *grpcClient) GetVtxoTree( - ctx context.Context, batchOutpoint types.Outpoint, opts ...indexer.PageOption, -) (*indexer.VtxoTreeResponse, error) { - o, err := indexer.ApplyPageOptions(opts...) + ctx context.Context, batchOutpoint clientlib.Outpoint, opts ...clientlib.PageOption, +) (*clientlib.VtxoTreeResponse, error) { + o, err := clientlib.ApplyPageOptions(opts...) if err != nil { return nil, err } @@ -138,24 +138,24 @@ func (a *grpcClient) GetVtxoTree( return nil, err } - nodes := make([]indexer.TxNode, 0, len(resp.GetVtxoTree())) + nodes := make([]clientlib.TxNode, 0, len(resp.GetVtxoTree())) for _, node := range resp.GetVtxoTree() { - nodes = append(nodes, indexer.TxNode{ + nodes = append(nodes, clientlib.TxNode{ Txid: node.GetTxid(), Children: node.GetChildren(), }) } - return &indexer.VtxoTreeResponse{ + return &clientlib.VtxoTreeResponse{ Tree: nodes, Page: parsePage(resp.GetPage()), }, nil } func (a *grpcClient) GetFullVtxoTree( - ctx context.Context, batchOutpoint types.Outpoint, opts ...indexer.PageOption, + ctx context.Context, batchOutpoint clientlib.Outpoint, opts ...clientlib.PageOption, ) ([]tree.TxTreeNode, error) { - o, err := indexer.ApplyPageOptions(opts...) + o, err := clientlib.ApplyPageOptions(opts...) if err != nil { return nil, err } @@ -165,13 +165,13 @@ func (a *grpcClient) GetFullVtxoTree( return nil, err } - var allTxs indexer.TxNodes = resp.Tree + var allTxs clientlib.TxNodes = resp.Tree for resp.Page != nil && resp.Page.Next != resp.Page.Total { - nextPage := &indexer.PageRequest{Index: resp.Page.Next} + nextPage := &clientlib.PageRequest{Index: resp.Page.Next} if o.Page != nil { nextPage.Size = o.Page.Size } - resp, err = a.GetVtxoTree(ctx, batchOutpoint, indexer.WithPage(nextPage)) + resp, err = a.GetVtxoTree(ctx, batchOutpoint, clientlib.WithPage(nextPage)) if err != nil { return nil, err } @@ -191,9 +191,9 @@ func (a *grpcClient) GetFullVtxoTree( } func (a *grpcClient) GetVtxoTreeLeaves( - ctx context.Context, batchOutpoint types.Outpoint, opts ...indexer.PageOption, -) (*indexer.VtxoTreeLeavesResponse, error) { - o, err := indexer.ApplyPageOptions(opts...) + ctx context.Context, batchOutpoint clientlib.Outpoint, opts ...clientlib.PageOption, +) (*clientlib.VtxoTreeLeavesResponse, error) { + o, err := clientlib.ApplyPageOptions(opts...) if err != nil { return nil, err } @@ -218,24 +218,24 @@ func (a *grpcClient) GetVtxoTreeLeaves( return nil, err } - leaves := make([]types.Outpoint, 0, len(resp.GetLeaves())) + leaves := make([]clientlib.Outpoint, 0, len(resp.GetLeaves())) for _, leaf := range resp.GetLeaves() { - leaves = append(leaves, types.Outpoint{ + leaves = append(leaves, clientlib.Outpoint{ Txid: leaf.GetTxid(), VOut: leaf.GetVout(), }) } - return &indexer.VtxoTreeLeavesResponse{ + return &clientlib.VtxoTreeLeavesResponse{ Leaves: leaves, Page: parsePage(resp.GetPage()), }, nil } func (a *grpcClient) GetForfeitTxs( - ctx context.Context, txid string, opts ...indexer.PageOption, -) (*indexer.ForfeitTxsResponse, error) { - o, err := indexer.ApplyPageOptions(opts...) + ctx context.Context, txid string, opts ...clientlib.PageOption, +) (*clientlib.ForfeitTxsResponse, error) { + o, err := clientlib.ApplyPageOptions(opts...) if err != nil { return nil, err } @@ -257,16 +257,16 @@ func (a *grpcClient) GetForfeitTxs( return nil, err } - return &indexer.ForfeitTxsResponse{ + return &clientlib.ForfeitTxsResponse{ Txids: resp.GetTxids(), Page: parsePage(resp.GetPage()), }, nil } func (a *grpcClient) GetConnectors( - ctx context.Context, txid string, opts ...indexer.PageOption, -) (*indexer.ConnectorsResponse, error) { - o, err := indexer.ApplyPageOptions(opts...) + ctx context.Context, txid string, opts ...clientlib.PageOption, +) (*clientlib.ConnectorsResponse, error) { + o, err := clientlib.ApplyPageOptions(opts...) if err != nil { return nil, err } @@ -288,30 +288,27 @@ func (a *grpcClient) GetConnectors( return nil, err } - connectors := make([]indexer.TxNode, 0, len(resp.GetConnectors())) + connectors := make([]clientlib.TxNode, 0, len(resp.GetConnectors())) for _, connector := range resp.GetConnectors() { - connectors = append(connectors, indexer.TxNode{ + connectors = append(connectors, clientlib.TxNode{ Txid: connector.GetTxid(), Children: connector.GetChildren(), }) } - return &indexer.ConnectorsResponse{ + return &clientlib.ConnectorsResponse{ Tree: connectors, Page: parsePage(resp.GetPage()), }, nil } func (a *grpcClient) GetVtxos( - ctx context.Context, opts ...indexer.GetVtxosOption, -) (*indexer.VtxosResponse, error) { - o, err := indexer.ApplyGetVtxosOptions(opts...) + ctx context.Context, opts ...clientlib.GetVtxosOption, +) (*clientlib.VtxosResponse, error) { + o, err := clientlib.ApplyGetVtxosOptions(opts...) if err != nil { return nil, err } - if len(o.Scripts) == 0 && len(o.Outpoints) == 0 { - return nil, fmt.Errorf("missing opts") - } if o.Page == nil && (len(o.Scripts)+len(o.Outpoints) > maxPageSize) { return a.paginatedGetVtxos(ctx, opts...) @@ -337,21 +334,25 @@ func (a *grpcClient) GetVtxos( Page: page, } + if len(o.Scripts) == 0 && len(o.Outpoints) == 0 { + return nil, fmt.Errorf("missing opts") + } + resp, err := a.svc().GetVtxos(ctx, req) if err != nil { return nil, err } - return &indexer.VtxosResponse{ + return &clientlib.VtxosResponse{ Vtxos: newIndexerVtxos(resp.GetVtxos()), Page: parsePage(resp.GetPage()), }, nil } func (a *grpcClient) GetVtxoChain( - ctx context.Context, outpoint types.Outpoint, opts ...indexer.PageOption, -) (*indexer.VtxoChainResponse, error) { - o, err := indexer.ApplyPageOptions(opts...) + ctx context.Context, outpoint clientlib.Outpoint, opts ...clientlib.PageOption, +) (*clientlib.VtxoChainResponse, error) { + o, err := clientlib.ApplyPageOptions(opts...) if err != nil { return nil, err } @@ -376,23 +377,23 @@ func (a *grpcClient) GetVtxoChain( return nil, err } - chain := make([]indexer.ChainWithExpiry, 0, len(resp.GetChain())) + chain := make([]clientlib.ChainWithExpiry, 0, len(resp.GetChain())) for _, c := range resp.GetChain() { - var txType indexer.IndexerChainedTxType + var txType clientlib.IndexerChainedTxType switch c.GetType() { case arkv1.IndexerChainedTxType_INDEXER_CHAINED_TX_TYPE_COMMITMENT: - txType = indexer.IndexerChainedTxTypeCommitment + txType = clientlib.IndexerChainedTxTypeCommitment case arkv1.IndexerChainedTxType_INDEXER_CHAINED_TX_TYPE_ARK: - txType = indexer.IndexerChainedTxTypeArk + txType = clientlib.IndexerChainedTxTypeArk case arkv1.IndexerChainedTxType_INDEXER_CHAINED_TX_TYPE_TREE: - txType = indexer.IndexerChainedTxTypeTree + txType = clientlib.IndexerChainedTxTypeTree case arkv1.IndexerChainedTxType_INDEXER_CHAINED_TX_TYPE_CHECKPOINT: - txType = indexer.IndexerChainedTxTypeCheckpoint + txType = clientlib.IndexerChainedTxTypeCheckpoint default: - txType = indexer.IndexerChainedTxTypeUnspecified + txType = clientlib.IndexerChainedTxTypeUnspecified } - chain = append(chain, indexer.ChainWithExpiry{ + chain = append(chain, clientlib.ChainWithExpiry{ Txid: c.GetTxid(), Type: txType, ExpiresAt: c.GetExpiresAt(), @@ -400,16 +401,16 @@ func (a *grpcClient) GetVtxoChain( }) } - return &indexer.VtxoChainResponse{ + return &clientlib.VtxoChainResponse{ Chain: chain, Page: parsePage(resp.GetPage()), }, nil } func (a *grpcClient) GetVirtualTxs( - ctx context.Context, txids []string, opts ...indexer.PageOption, -) (*indexer.VirtualTxsResponse, error) { - o, err := indexer.ApplyPageOptions(opts...) + ctx context.Context, txids []string, opts ...clientlib.PageOption, +) (*clientlib.VirtualTxsResponse, error) { + o, err := clientlib.ApplyPageOptions(opts...) if err != nil { return nil, err } @@ -436,14 +437,14 @@ func (a *grpcClient) GetVirtualTxs( return nil, err } - return &indexer.VirtualTxsResponse{ + return &clientlib.VirtualTxsResponse{ Txs: resp.GetTxs(), Page: parsePage(resp.GetPage()), }, nil } func (a *grpcClient) GetBatchSweepTxs( - ctx context.Context, batchOutpoint types.Outpoint, + ctx context.Context, batchOutpoint clientlib.Outpoint, ) ([]string, error) { req := &arkv1.GetBatchSweepTransactionsRequest{ BatchOutpoint: &arkv1.IndexerOutpoint{ @@ -462,7 +463,7 @@ func (a *grpcClient) GetBatchSweepTxs( func (a *grpcClient) NewSubscription( ctx context.Context, scripts []string, -) (string, <-chan indexer.ScriptEvent, func(), error) { +) (string, <-chan clientlib.ScriptEvent, func(), error) { resp, err := a.svc().SubscribeForScripts(ctx, &arkv1.SubscribeForScriptsRequest{ Scripts: scripts, }) @@ -474,7 +475,7 @@ func (a *grpcClient) NewSubscription( stream, closeFn, err := utils.StartReconnectingStream(ctx, utils.ReconnectingStreamConfig[ arkv1.IndexerService_GetSubscriptionClient, *arkv1.GetSubscriptionResponse, - indexer.ScriptEvent, + clientlib.ScriptEvent, ]{ Connect: func(ctx context.Context) (arkv1.IndexerService_GetSubscriptionClient, error) { return a.svc().GetSubscription(ctx, &arkv1.GetSubscriptionRequest{ @@ -513,18 +514,18 @@ func (a *grpcClient) NewSubscription( }, HandleResp: func( ctx context.Context, - eventsCh chan<- indexer.ScriptEvent, + eventsCh chan<- clientlib.ScriptEvent, resp *arkv1.GetSubscriptionResponse, ) error { - var checkpointTxs map[string]indexer.TxData + var checkpointTxs map[string]clientlib.TxData var event *arkv1.IndexerSubscriptionEvent switch data := resp.GetData().(type) { case *arkv1.GetSubscriptionResponse_Event: event = data.Event if len(event.GetCheckpointTxs()) > 0 { - checkpointTxs = make(map[string]indexer.TxData) + checkpointTxs = make(map[string]clientlib.TxData) for k, v := range event.GetCheckpointTxs() { - checkpointTxs[k] = indexer.TxData{ + checkpointTxs[k] = clientlib.TxData{ Txid: v.GetTxid(), Tx: v.GetTx(), } @@ -538,8 +539,8 @@ func (a *grpcClient) NewSubscription( select { case <-ctx.Done(): return ctx.Err() - case eventsCh <- indexer.ScriptEvent{ - Data: &indexer.ScriptEventData{ + case eventsCh <- clientlib.ScriptEvent{ + Data: &clientlib.ScriptEventData{ Txid: event.GetTxid(), Tx: event.GetTx(), Scripts: event.GetScripts(), @@ -551,12 +552,12 @@ func (a *grpcClient) NewSubscription( return nil } }, - ErrorEvent: func(err error) indexer.ScriptEvent { - return indexer.ScriptEvent{Err: err} + ErrorEvent: func(err error) clientlib.ScriptEvent { + return clientlib.ScriptEvent{Err: err} }, - ConnectionEvent: func(event utils.ReconnectingStreamStateEvent) indexer.ScriptEvent { - return indexer.ScriptEvent{ - Connection: &types.StreamConnectionEvent{ + ConnectionEvent: func(event utils.ReconnectingStreamStateEvent) clientlib.ScriptEvent { + return clientlib.ScriptEvent{ + Connection: &clientlib.StreamConnectionEvent{ State: toStreamConnectionState(event.State), At: event.At, DisconnectedAt: event.DisconnectedAt, @@ -606,7 +607,7 @@ func (a *grpcClient) UpdateSubscription( } func (a *grpcClient) GetAsset(ctx context.Context, assetID string) ( - *indexer.AssetInfo, error, + *clientlib.AssetInfo, error, ) { req := &arkv1.GetAssetRequest{ AssetId: assetID, @@ -625,9 +626,14 @@ func (a *grpcClient) GetAsset(ctx context.Context, assetID string) ( } } - return &indexer.AssetInfo{ + supply, err := strconv.ParseUint(resp.GetSupply(), 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse supply: %w", err) + } + + return &clientlib.AssetInfo{ AssetId: resp.GetAssetId(), - Supply: resp.GetSupply(), + Supply: supply, ControlAssetId: resp.GetControlAsset(), Metadata: metadata, }, nil @@ -685,16 +691,16 @@ func (a *grpcClient) unsubscribeForScripts( } func (a *grpcClient) paginatedGetVtxos( - ctx context.Context, opts ...indexer.GetVtxosOption, -) (*indexer.VtxosResponse, error) { + ctx context.Context, opts ...clientlib.GetVtxosOption, +) (*clientlib.VtxosResponse, error) { // nolint - o, _ := indexer.ApplyGetVtxosOptions(opts...) + o, _ := clientlib.ApplyGetVtxosOptions(opts...) svc := a.svc() - fetchPages := func(scripts []string, outpoints []types.Outpoint) ([]types.Vtxo, error) { + fetchPages := func(scripts []string, outpoints []clientlib.Outpoint) ([]clientlib.Vtxo, error) { return paginatedFetch(ctx, func( ctx context.Context, page *arkv1.IndexerPageRequest, - ) ([]types.Vtxo, *arkv1.IndexerPageResponse, error) { + ) ([]clientlib.Vtxo, *arkv1.IndexerPageResponse, error) { resp, err := svc.GetVtxos(ctx, &arkv1.GetVtxosRequest{ Scripts: scripts, Outpoints: formatOutpoints(outpoints), @@ -713,8 +719,8 @@ func (a *grpcClient) paginatedGetVtxos( }) } - var vtxos []types.Vtxo - appendPages := func(scripts []string, outpoints []types.Outpoint) error { + var vtxos []clientlib.Vtxo + appendPages := func(scripts []string, outpoints []clientlib.Outpoint) error { pageVtxos, err := fetchPages(scripts, outpoints) if err != nil { return err @@ -735,10 +741,10 @@ func (a *grpcClient) paginatedGetVtxos( return nil, err } } - return &indexer.VtxosResponse{Vtxos: vtxos}, nil + return &clientlib.VtxosResponse{Vtxos: vtxos}, nil } -func formatOutpoints(outpoints []types.Outpoint) []string { +func formatOutpoints(outpoints []clientlib.Outpoint) []string { outs := make([]string, 0, len(outpoints)) for _, outpoint := range outpoints { outs = append(outs, fmt.Sprintf("%s:%d", outpoint.Txid, outpoint.VOut)) @@ -748,7 +754,7 @@ func formatOutpoints(outpoints []types.Outpoint) []string { func (a *grpcClient) paginatedGetVirtualTxs( ctx context.Context, txids []string, -) (*indexer.VirtualTxsResponse, error) { +) (*clientlib.VirtualTxsResponse, error) { svc := a.svc() txs, err := paginatedFetch(ctx, func( @@ -766,7 +772,7 @@ func (a *grpcClient) paginatedGetVirtualTxs( if err != nil { return nil, err } - return &indexer.VirtualTxsResponse{Txs: txs}, nil + return &clientlib.VirtualTxsResponse{Txs: txs}, nil } // paginatedFetch fetches all pages from a paginated endpoint, throttling @@ -814,49 +820,49 @@ func paginatedFetch[T any]( func toStreamConnectionState( state utils.ReconnectingStreamState, -) types.StreamConnectionState { +) clientlib.StreamConnectionState { switch state { case utils.ReconnectingStreamStateDisconnected: - return types.StreamConnectionStateDisconnected + return clientlib.StreamConnectionStateDisconnected case utils.ReconnectingStreamStateReconnected: - return types.StreamConnectionStateReconnected + return clientlib.StreamConnectionStateReconnected default: - return types.StreamConnectionState(state) + return clientlib.StreamConnectionState(state) } } -func parsePage(page *arkv1.IndexerPageResponse) *indexer.PageResponse { +func parsePage(page *arkv1.IndexerPageResponse) *clientlib.PageResponse { if page == nil { return nil } - return &indexer.PageResponse{ + return &clientlib.PageResponse{ Current: page.GetCurrent(), Next: page.GetNext(), Total: page.GetTotal(), } } -func newIndexerVtxos(vtxos []*arkv1.IndexerVtxo) []types.Vtxo { - res := make([]types.Vtxo, 0, len(vtxos)) +func newIndexerVtxos(vtxos []*arkv1.IndexerVtxo) []clientlib.Vtxo { + res := make([]clientlib.Vtxo, 0, len(vtxos)) for _, vtxo := range vtxos { res = append(res, newIndexerVtxo(vtxo)) } return res } -func newIndexerVtxo(vtxo *arkv1.IndexerVtxo) types.Vtxo { - var assetLists []types.Asset +func newIndexerVtxo(vtxo *arkv1.IndexerVtxo) clientlib.Vtxo { + var assetLists []clientlib.Asset for _, a := range vtxo.GetAssets() { if a != nil { - assetLists = append(assetLists, types.Asset{ + assetLists = append(assetLists, clientlib.Asset{ AssetId: a.GetAssetId(), Amount: a.GetAmount(), }) } } - return types.Vtxo{ - Outpoint: types.Outpoint{ + return clientlib.Vtxo{ + Outpoint: clientlib.Outpoint{ Txid: vtxo.GetOutpoint().GetTxid(), VOut: vtxo.GetOutpoint().GetVout(), }, diff --git a/pkg/client-lib/indexer/grpc/paginated_fetch_test.go b/pkg/client-lib/indexer/paginated_fetch_test.go similarity index 99% rename from pkg/client-lib/indexer/grpc/paginated_fetch_test.go rename to pkg/client-lib/indexer/paginated_fetch_test.go index 10d300efb..674fc753d 100644 --- a/pkg/client-lib/indexer/grpc/paginated_fetch_test.go +++ b/pkg/client-lib/indexer/paginated_fetch_test.go @@ -1,4 +1,4 @@ -package grpcindexer +package indexer import ( "context" diff --git a/pkg/client-lib/indexer/grpc/reconnect_get_subscription_stream_test.go b/pkg/client-lib/indexer/reconnect_get_subscription_stream_test.go similarity index 92% rename from pkg/client-lib/indexer/grpc/reconnect_get_subscription_stream_test.go rename to pkg/client-lib/indexer/reconnect_get_subscription_stream_test.go index 784c974b2..84f028e1d 100644 --- a/pkg/client-lib/indexer/grpc/reconnect_get_subscription_stream_test.go +++ b/pkg/client-lib/indexer/reconnect_get_subscription_stream_test.go @@ -1,4 +1,4 @@ -package grpcindexer +package indexer import ( "context" @@ -10,8 +10,7 @@ import ( "time" arkv1 "github.com/arkade-os/arkd/api-spec/protobuf/gen/ark/v1" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -147,9 +146,9 @@ func TestSubscriptionLifecycleEventsAndDeltaFetchByTimestamp(t *testing.T) { if event.Connection != nil { switch event.Connection.State { - case types.StreamConnectionStateDisconnected: + case clientlib.StreamConnectionStateDisconnected: disconnectedAt = event.Connection.At - case types.StreamConnectionStateReconnected: + case clientlib.StreamConnectionStateReconnected: reconnectedAt = event.Connection.At } } @@ -170,9 +169,9 @@ func TestSubscriptionLifecycleEventsAndDeltaFetchByTimestamp(t *testing.T) { after := disconnectedAt.Add(-100 * time.Millisecond).UnixMilli() before := reconnectedAt.Add(100 * time.Millisecond).UnixMilli() - opts := []indexer.GetVtxosOption{ - indexer.WithScripts([]string{"0014deadbeef"}), - indexer.WithTimeRange(before, after), + opts := []clientlib.GetVtxosOption{ + clientlib.WithScripts([]string{"0014deadbeef"}), + clientlib.WithTimeRange(before, after), } resp, getErr := c.GetVtxos(ctx, opts...) require.NoError(t, getErr) diff --git a/pkg/client-lib/indexer/opts.go b/pkg/client-lib/indexer_opts.go similarity index 95% rename from pkg/client-lib/indexer/opts.go rename to pkg/client-lib/indexer_opts.go index ff990a82c..483174a6a 100644 --- a/pkg/client-lib/indexer/opts.go +++ b/pkg/client-lib/indexer_opts.go @@ -1,10 +1,8 @@ -package indexer +package clientlib import ( "fmt" "time" - - "github.com/arkade-os/arkd/pkg/client-lib/types" ) // PageOption is a functional option for paginated requests. @@ -47,7 +45,7 @@ func WithScripts(scripts []string) GetVtxosOption { } } -func WithOutpoints(outpoints []types.Outpoint) GetVtxosOption { +func WithOutpoints(outpoints []Outpoint) GetVtxosOption { return func(o *getVtxosOption) error { if o.Outpoints != nil { return fmt.Errorf("outpoints already set") @@ -118,7 +116,7 @@ func WithTimeRange(before, after int64) GetVtxosOption { type getVtxosOption struct { Page *PageRequest Scripts []string - Outpoints []types.Outpoint + Outpoints []Outpoint SpentOnly bool SpendableOnly bool RecoverableOnly bool diff --git a/pkg/client-lib/indexer/opts_test.go b/pkg/client-lib/indexer_opts_test.go similarity index 62% rename from pkg/client-lib/indexer/opts_test.go rename to pkg/client-lib/indexer_opts_test.go index 814091295..d341a8227 100644 --- a/pkg/client-lib/indexer/opts_test.go +++ b/pkg/client-lib/indexer_opts_test.go @@ -1,11 +1,10 @@ -package indexer_test +package clientlib_test import ( "testing" "time" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" "github.com/stretchr/testify/require" ) @@ -13,14 +12,14 @@ func TestWithPage(t *testing.T) { t.Run("valid", func(t *testing.T) { testCases := []struct { name string - page *indexer.PageRequest + page *clientlib.PageRequest }{ - {name: "size and index", page: &indexer.PageRequest{Size: 10, Index: 2}}, - {name: "zero index", page: &indexer.PageRequest{Size: 5, Index: 0}}, + {name: "size and index", page: &clientlib.PageRequest{Size: 10, Index: 2}}, + {name: "zero index", page: &clientlib.PageRequest{Size: 5, Index: 0}}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - o, err := indexer.ApplyPageOptions(indexer.WithPage(tc.page)) + o, err := clientlib.ApplyPageOptions(clientlib.WithPage(tc.page)) require.NoError(t, err) require.Equal(t, tc.page, o.Page) }) @@ -32,14 +31,14 @@ func TestWithVtxosPage(t *testing.T) { t.Run("valid", func(t *testing.T) { testCases := []struct { name string - page *indexer.PageRequest + page *clientlib.PageRequest }{ - {name: "size and index", page: &indexer.PageRequest{Size: 10, Index: 2}}, - {name: "zero index", page: &indexer.PageRequest{Size: 5, Index: 0}}, + {name: "size and index", page: &clientlib.PageRequest{Size: 10, Index: 2}}, + {name: "zero index", page: &clientlib.PageRequest{Size: 5, Index: 0}}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - o, err := indexer.ApplyGetVtxosOptions(indexer.WithVtxosPage(tc.page)) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithVtxosPage(tc.page)) require.NoError(t, err) require.Equal(t, tc.page, o.Page) }) @@ -51,7 +50,7 @@ func TestWithScripts(t *testing.T) { scripts := []string{"script1", "script2"} t.Run("valid", func(t *testing.T) { - o, err := indexer.ApplyGetVtxosOptions(indexer.WithScripts(scripts)) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithScripts(scripts)) require.NoError(t, err) require.Equal(t, scripts, o.Scripts) }) @@ -59,26 +58,26 @@ func TestWithScripts(t *testing.T) { t.Run("invalid", func(t *testing.T) { testCases := []struct { name string - opts []indexer.GetVtxosOption + opts []clientlib.GetVtxosOption expectError string }{ { name: "scripts already set", - opts: []indexer.GetVtxosOption{indexer.WithScripts(scripts), indexer.WithScripts(scripts)}, + opts: []clientlib.GetVtxosOption{clientlib.WithScripts(scripts), clientlib.WithScripts(scripts)}, expectError: "scripts already set", }, { name: "outpoints already set", - opts: []indexer.GetVtxosOption{ - indexer.WithOutpoints([]types.Outpoint{{Txid: "abc", VOut: 0}}), - indexer.WithScripts(scripts), + opts: []clientlib.GetVtxosOption{ + clientlib.WithOutpoints([]clientlib.Outpoint{{Txid: "abc", VOut: 0}}), + clientlib.WithScripts(scripts), }, expectError: "outpoints already set", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := indexer.ApplyGetVtxosOptions(tc.opts...) + _, err := clientlib.ApplyGetVtxosOptions(tc.opts...) require.EqualError(t, err, tc.expectError) }) } @@ -86,13 +85,13 @@ func TestWithScripts(t *testing.T) { } func TestWithOutpoints(t *testing.T) { - outpoints := []types.Outpoint{ + outpoints := []clientlib.Outpoint{ {Txid: "abc123", VOut: 0}, {Txid: "def456", VOut: 1}, } t.Run("valid", func(t *testing.T) { - o, err := indexer.ApplyGetVtxosOptions(indexer.WithOutpoints(outpoints)) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithOutpoints(outpoints)) require.NoError(t, err) require.Equal(t, outpoints, o.Outpoints) }) @@ -100,26 +99,26 @@ func TestWithOutpoints(t *testing.T) { t.Run("invalid", func(t *testing.T) { testCases := []struct { name string - opts []indexer.GetVtxosOption + opts []clientlib.GetVtxosOption expectError string }{ { name: "outpoints already set", - opts: []indexer.GetVtxosOption{indexer.WithOutpoints(outpoints), indexer.WithOutpoints(outpoints)}, + opts: []clientlib.GetVtxosOption{clientlib.WithOutpoints(outpoints), clientlib.WithOutpoints(outpoints)}, expectError: "outpoints already set", }, { name: "scripts already set", - opts: []indexer.GetVtxosOption{ - indexer.WithScripts([]string{"s1"}), - indexer.WithOutpoints(outpoints), + opts: []clientlib.GetVtxosOption{ + clientlib.WithScripts([]string{"s1"}), + clientlib.WithOutpoints(outpoints), }, expectError: "scripts already set", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := indexer.ApplyGetVtxosOptions(tc.opts...) + _, err := clientlib.ApplyGetVtxosOptions(tc.opts...) require.EqualError(t, err, tc.expectError) }) } @@ -127,11 +126,11 @@ func TestWithOutpoints(t *testing.T) { } func TestFormattedOutpoints(t *testing.T) { - outpoints := []types.Outpoint{ + outpoints := []clientlib.Outpoint{ {Txid: "abc123", VOut: 0}, {Txid: "def456", VOut: 2}, } - o, err := indexer.ApplyGetVtxosOptions(indexer.WithOutpoints(outpoints)) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithOutpoints(outpoints)) require.NoError(t, err) require.Equal(t, []string{"abc123:0", "def456:2"}, o.FormattedOutpoints()) } @@ -139,25 +138,25 @@ func TestFormattedOutpoints(t *testing.T) { func TestWithFilters(t *testing.T) { t.Run("valid", func(t *testing.T) { t.Run("spent only", func(t *testing.T) { - o, err := indexer.ApplyGetVtxosOptions(indexer.WithSpentOnly()) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithSpentOnly()) require.NoError(t, err) require.True(t, o.SpentOnly) }) t.Run("spendable only", func(t *testing.T) { - o, err := indexer.ApplyGetVtxosOptions(indexer.WithSpendableOnly()) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithSpendableOnly()) require.NoError(t, err) require.True(t, o.SpendableOnly) }) t.Run("recoverable only", func(t *testing.T) { - o, err := indexer.ApplyGetVtxosOptions(indexer.WithRecoverableOnly()) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithRecoverableOnly()) require.NoError(t, err) require.True(t, o.RecoverableOnly) }) t.Run("pending only", func(t *testing.T) { - o, err := indexer.ApplyGetVtxosOptions(indexer.WithPendingOnly()) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithPendingOnly()) require.NoError(t, err) require.True(t, o.PendingOnly) }) @@ -179,7 +178,7 @@ func TestWithTimeRange(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - o, err := indexer.ApplyGetVtxosOptions(indexer.WithTimeRange(tc.before, tc.after)) + o, err := clientlib.ApplyGetVtxosOptions(clientlib.WithTimeRange(tc.before, tc.after)) require.NoError(t, err) require.Equal(t, tc.expectedBefore, o.Before) require.Equal(t, tc.expectedAfter, o.After) @@ -190,43 +189,43 @@ func TestWithTimeRange(t *testing.T) { t.Run("invalid", func(t *testing.T) { testCases := []struct { name string - opts []indexer.GetVtxosOption + opts []clientlib.GetVtxosOption expectError string }{ { name: "both bounds zero", - opts: []indexer.GetVtxosOption{indexer.WithTimeRange(0, 0)}, + opts: []clientlib.GetVtxosOption{clientlib.WithTimeRange(0, 0)}, expectError: "missing time range", }, { name: "negative before", - opts: []indexer.GetVtxosOption{indexer.WithTimeRange(-1, 1000)}, + opts: []clientlib.GetVtxosOption{clientlib.WithTimeRange(-1, 1000)}, expectError: "negative time bound", }, { name: "negative after", - opts: []indexer.GetVtxosOption{indexer.WithTimeRange(1000, -1)}, + opts: []clientlib.GetVtxosOption{clientlib.WithTimeRange(1000, -1)}, expectError: "negative time bound", }, { name: "before less than after", - opts: []indexer.GetVtxosOption{indexer.WithTimeRange(1000, 2000)}, + opts: []clientlib.GetVtxosOption{clientlib.WithTimeRange(1000, 2000)}, expectError: "before must be greater than after", }, { name: "before equals after", - opts: []indexer.GetVtxosOption{indexer.WithTimeRange(1000, 1000)}, + opts: []clientlib.GetVtxosOption{clientlib.WithTimeRange(1000, 1000)}, expectError: "before must be greater than after", }, { name: "time range already set", - opts: []indexer.GetVtxosOption{indexer.WithTimeRange(2000, 1000), indexer.WithTimeRange(3000, 2000)}, + opts: []clientlib.GetVtxosOption{clientlib.WithTimeRange(2000, 1000), clientlib.WithTimeRange(3000, 2000)}, expectError: "time range already set", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := indexer.ApplyGetVtxosOptions(tc.opts...) + _, err := clientlib.ApplyGetVtxosOptions(tc.opts...) require.EqualError(t, err, tc.expectError) }) } @@ -240,30 +239,30 @@ func TestWithStartAndEndTime(t *testing.T) { testCases := []struct { name string - opts []indexer.GetTxHistoryOption + opts []clientlib.GetTxHistoryOption expectedStart time.Time expectedEnd time.Time }{ { name: "start time only", - opts: []indexer.GetTxHistoryOption{indexer.WithStartTime(start)}, + opts: []clientlib.GetTxHistoryOption{clientlib.WithStartTime(start)}, expectedStart: start, }, { name: "end time only", - opts: []indexer.GetTxHistoryOption{indexer.WithEndTime(end)}, + opts: []clientlib.GetTxHistoryOption{clientlib.WithEndTime(end)}, expectedEnd: end, }, { name: "both start and end", - opts: []indexer.GetTxHistoryOption{indexer.WithStartTime(start), indexer.WithEndTime(end)}, + opts: []clientlib.GetTxHistoryOption{clientlib.WithStartTime(start), clientlib.WithEndTime(end)}, expectedStart: start, expectedEnd: end, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - o, err := indexer.ApplyGetTxHistoryOptions(tc.opts...) + o, err := clientlib.ApplyGetTxHistoryOptions(tc.opts...) require.NoError(t, err) require.Equal(t, tc.expectedStart, o.StartTime) require.Equal(t, tc.expectedEnd, o.EndTime) diff --git a/pkg/client-lib/internal/utils/listener_test.go b/pkg/client-lib/internal/utils/listener_test.go new file mode 100644 index 000000000..baed8ab00 --- /dev/null +++ b/pkg/client-lib/internal/utils/listener_test.go @@ -0,0 +1,268 @@ +package utils_test + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" + "github.com/stretchr/testify/require" +) + +// drainOK is the duration we're willing to wait for asynchronous removals or +// channel closes triggered by the broadcaster. Kept short so the suite stays +// snappy; bump it if CI proves flaky on slow runners. +const drainOK = 200 * time.Millisecond + +// expectReceive asserts the channel yields the expected value within drainOK. +func expectReceive[T comparable](t *testing.T, ch <-chan T, want T) { + t.Helper() + select { + case got, ok := <-ch: + require.True(t, ok, "channel was closed before receiving the expected value") + require.Equal(t, want, got) + case <-time.After(drainOK): + t.Fatalf("expected to receive %v within %s, got nothing", want, drainOK) + } +} + +// expectClosed asserts the channel is closed within drainOK (Recv returns zero +// value with ok=false). Used to verify post-Unsubscribe / Close semantics. +func expectClosed[T any](t *testing.T, ch <-chan T) { + t.Helper() + select { + case _, ok := <-ch: + require.False(t, ok, "expected channel to be closed, got a value") + case <-time.After(drainOK): + t.Fatalf("expected channel to be closed within %s", drainOK) + } +} + +func TestBroadcaster(t *testing.T) { + t.Run("subscribe receives published values", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + defer b.Close() + + ch := b.Subscribe(4) + dropped := b.Publish(42) + require.Equal(t, 0, dropped) + + expectReceive(t, ch, 42) + }) + + t.Run("multiple subscribers each receive a copy", func(t *testing.T) { + b := utils.NewBroadcaster[string]() + defer b.Close() + + ch1 := b.Subscribe(4) + ch2 := b.Subscribe(4) + ch3 := b.Subscribe(4) + + require.Equal(t, 0, b.Publish("hello")) + + expectReceive(t, ch1, "hello") + expectReceive(t, ch2, "hello") + expectReceive(t, ch3, "hello") + }) + + t.Run("buf=0 defaults to 64", func(t *testing.T) { + // Publish 64 values without consuming; none should be dropped because + // the default buffer absorbs them all. The 65th publish should drop + // the subscriber (overflow). + b := utils.NewBroadcaster[int]() + defer b.Close() + + ch := b.Subscribe(0) + + for i := 0; i < 64; i++ { + require.Equal(t, 0, b.Publish(i), "publish #%d should not overflow with the default buffer", i) + } + require.Equal(t, 1, b.Publish(64), "65th publish should overflow and drop the slow subscriber") + + // After overflow, the broadcaster removes the listener asynchronously + // and closes its channel. Drain the 64 buffered items first, then + // confirm the channel is closed. + for i := 0; i < 64; i++ { + expectReceive(t, ch, i) + } + expectClosed(t, ch) + }) + + t.Run("unsubscribe closes channel and stops delivery", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + defer b.Close() + + ch := b.Subscribe(4) + b.Unsubscribe(ch) + + expectClosed(t, ch) + + // A subsequent publish to no remaining listener is a no-op (0 dropped). + require.Equal(t, 0, b.Publish(99)) + }) + + t.Run("unsubscribe with unknown channel is a no-op", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + defer b.Close() + + registered := b.Subscribe(4) + + // A channel that was never subscribed. + stranger := make(chan int, 1) + b.Unsubscribe(stranger) + + // The registered subscriber is still active and reachable. + require.Equal(t, 0, b.Publish(7)) + expectReceive(t, registered, 7) + + // The stranger channel was untouched (still open, no panic). + select { + case _, ok := <-stranger: + t.Fatalf("stranger channel should remain untouched, got ok=%v", ok) + default: + } + }) + + t.Run("slow subscriber is dropped and channel is closed", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + defer b.Close() + + fast := b.Subscribe(4) + slow := b.Subscribe(1) + + // First publish fits both buffers. + require.Equal(t, 0, b.Publish(1)) + // Second publish: fast still has room, slow's buffer is full → dropped. + require.Equal(t, 1, b.Publish(2)) + + // Fast subscriber sees both values. + expectReceive(t, fast, 1) + expectReceive(t, fast, 2) + + // Slow subscriber received the first value before its channel was closed + // asynchronously by the broadcaster. + expectReceive(t, slow, 1) + expectClosed(t, slow) + }) + + t.Run("publish returns count of dropped listeners across the fleet", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + defer b.Close() + + ok1 := b.Subscribe(4) + ok2 := b.Subscribe(4) + slow1 := b.Subscribe(1) + slow2 := b.Subscribe(1) + + // Fill the slow ones' buffers so the next publish overflows both. + require.Equal(t, 0, b.Publish(1)) + require.Equal(t, 2, b.Publish(2), "two slow subscribers should be dropped on the second publish") + + // Healthy subscribers still get both. + expectReceive(t, ok1, 1) + expectReceive(t, ok1, 2) + expectReceive(t, ok2, 1) + expectReceive(t, ok2, 2) + + // Slow ones got #1 before being dropped, then their channels close. + expectReceive(t, slow1, 1) + expectClosed(t, slow1) + expectReceive(t, slow2, 1) + expectClosed(t, slow2) + }) + + t.Run("close closes all subscriber channels", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + + ch1 := b.Subscribe(4) + ch2 := b.Subscribe(4) + + b.Close() + + expectClosed(t, ch1) + expectClosed(t, ch2) + }) + + t.Run("subscribe after close returns an already-closed channel", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + b.Close() + + ch := b.Subscribe(4) + expectClosed(t, ch) + }) + + t.Run("publish after close drops nothing and does not panic", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + b.Close() + + require.NotPanics(t, func() { + require.Equal(t, 0, b.Publish(1)) + }) + }) + + t.Run("close is idempotent", func(t *testing.T) { + b := utils.NewBroadcaster[int]() + _ = b.Subscribe(4) + + require.NotPanics(t, func() { + b.Close() + b.Close() + }) + }) + + t.Run("concurrent publishers and subscribers", func(t *testing.T) { + // Stress test under the race detector: many concurrent producers, + // subscribers, unsubscribers. The contract we assert: no panics, no + // deadlocks, and the broadcaster terminates cleanly via Close. + b := utils.NewBroadcaster[int]() + + const ( + producers = 8 + subscribers = 16 + publishes = 200 + ) + + var ( + wg sync.WaitGroup + startSignal = make(chan struct{}) + received atomic.Int64 + ) + + // Subscribers consume until their channel closes. + for i := 0; i < subscribers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ch := b.Subscribe(8) + <-startSignal + for range ch { + received.Add(1) + } + }() + } + + // Producers publish in parallel. + for i := 0; i < producers; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + <-startSignal + for j := 0; j < publishes; j++ { + b.Publish(id*1000 + j) + } + }(i) + } + + close(startSignal) + + // Give producers a moment to actually push some values, then close + // the broadcaster. All subscribers should drain and exit. + time.Sleep(50 * time.Millisecond) + b.Close() + wg.Wait() + + require.Greater(t, received.Load(), int64(0), + "at least some values should have been received before Close") + }) +} diff --git a/pkg/client-lib/internal/utils/reconnect.go b/pkg/client-lib/internal/utils/reconnect.go deleted file mode 100644 index 82ebace07..000000000 --- a/pkg/client-lib/internal/utils/reconnect.go +++ /dev/null @@ -1,73 +0,0 @@ -package utils - -import ( - "strings" - "time" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -var GrpcReconnectConfig = struct { - InitialDelay time.Duration - MaxDelay time.Duration - Multiplier float64 - Jitter float64 -}{ - InitialDelay: 1 * time.Second, - MaxDelay: 10 * time.Second, - Multiplier: 2.0, - Jitter: 0.2, // + or - 20% randomness -} - -const grpcHTTPFallbackError = "unexpected HTTP status code received from server" - -func ShouldReconnect(err error) (bool, time.Duration) { - if err == nil { - return false, 0 - } - // During arkd restart/shutdown windows, gRPC calls may briefly hit the HTTP gateway - // on the same port and return a plain HTTP response (e.g. 200 with no gRPC content-type). - if strings.Contains(err.Error(), grpcHTTPFallbackError) { - msg := err.Error() - switch { - case strings.Contains(msg, "429"): // rate limited - return true, 30 * time.Second - case strings.Contains(msg, "404"): // not found, maybe redeployment - return true, 30 * time.Second - default: - return true, time.Second - } - } - - st, ok := status.FromError(err) - if !ok { - if strings.Contains(err.Error(), "524") { - return true, 5 * time.Second - } - return true, time.Second - } - - switch st.Code() { - case codes.Unknown: - if strings.Contains(st.Message(), "524") { - return true, 5 * time.Second - } - return false, 0 - case codes.ResourceExhausted: - return true, 5 * time.Second - case codes.Unavailable, codes.Internal, codes.DeadlineExceeded, codes.Aborted: - return true, time.Second - case codes.FailedPrecondition: - // Arkd service may return this while wallet is still locked/syncing after restart. - return true, 5 * time.Second - case codes.Canceled, - codes.InvalidArgument, - codes.PermissionDenied, - codes.Unauthenticated, - codes.Unimplemented: - return false, 0 - default: - return false, 0 - } -} diff --git a/pkg/client-lib/internal/utils/stream_retry.go b/pkg/client-lib/internal/utils/stream_retry.go index db6899d7f..ce23dcbb0 100644 --- a/pkg/client-lib/internal/utils/stream_retry.go +++ b/pkg/client-lib/internal/utils/stream_retry.go @@ -9,9 +9,24 @@ import ( "sync" "time" + "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) +var GrpcReconnectConfig = struct { + InitialDelay time.Duration + MaxDelay time.Duration + Multiplier float64 + Jitter float64 +}{ + InitialDelay: 1 * time.Second, + MaxDelay: 10 * time.Second, + Multiplier: 2.0, + Jitter: 0.2, // + or - 20% randomness +} + +const grpcHTTPFallbackError = "unexpected HTTP status code received from server" + type grpcClientStream interface { CloseSend() error } @@ -278,7 +293,7 @@ func StartReconnectingStream[S grpcClientStream, R any, E any]( if result.err != nil { // Classify receive errors as retryable/non-retryable. - shouldRetry, retryDelay := ShouldReconnect(result.err) + shouldRetry, retryDelay := shouldReconnect(result.err) if !shouldRetry { sendTerminalErr(result.err) return @@ -325,7 +340,7 @@ func StartReconnectingStream[S grpcClientStream, R any, E any]( _, newStream, dialErr := cfg.Reconnect(ctx) if dialErr != nil { - shouldRetryDial, dialRetryDelay := ShouldReconnect(dialErr) + shouldRetryDial, dialRetryDelay := shouldReconnect(dialErr) if !shouldRetryDial { sendTerminalErr(dialErr) return @@ -422,3 +437,53 @@ func applyJitter(d time.Duration, jitter float64) time.Duration { jitterFactor := 1.0 + jitter*randomFactor return time.Duration(float64(d) * jitterFactor) } + +func shouldReconnect(err error) (bool, time.Duration) { + if err == nil { + return false, 0 + } + // During arkd restart/shutdown windows, gRPC calls may briefly hit the HTTP gateway + // on the same port and return a plain HTTP response (e.g. 200 with no gRPC content-type). + if strings.Contains(err.Error(), grpcHTTPFallbackError) { + msg := err.Error() + switch { + case strings.Contains(msg, "429"): // rate limited + return true, 30 * time.Second + case strings.Contains(msg, "404"): // not found, maybe redeployment + return true, 30 * time.Second + default: + return true, time.Second + } + } + + st, ok := status.FromError(err) + if !ok { + if strings.Contains(err.Error(), "524") { + return true, 5 * time.Second + } + return true, time.Second + } + + switch st.Code() { + case codes.Unknown: + if strings.Contains(st.Message(), "524") { + return true, 5 * time.Second + } + return false, 0 + case codes.ResourceExhausted: + return true, 5 * time.Second + case codes.Unavailable, codes.Internal, codes.DeadlineExceeded, codes.Aborted: + return true, time.Second + case codes.FailedPrecondition: + // Arkd service may return this while wallet is still locked/syncing after restart. + return true, 5 * time.Second + case codes.Canceled, + codes.InvalidArgument, + codes.PermissionDenied, + codes.Unauthenticated, + codes.Unimplemented: + return false, 0 + default: + return false, 0 + } +} diff --git a/pkg/client-lib/internal/utils/stream_retry_test.go b/pkg/client-lib/internal/utils/stream_retry_test.go index d59b983e6..79cc30254 100644 --- a/pkg/client-lib/internal/utils/stream_retry_test.go +++ b/pkg/client-lib/internal/utils/stream_retry_test.go @@ -1,4 +1,4 @@ -package utils +package utils_test import ( "context" @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -33,12 +34,12 @@ func TestStartReconnectingStream(t *testing.T) { }, ) - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() evs := collectEvents(t, ctx, ch, 1) - require.Equal(t, ReconnectingStreamStateReady, evs[0].state) + require.Equal(t, utils.ReconnectingStreamStateReady, evs[0].state) }) // Verifies that READY is emitted after the 2-second timedOutRecv timeout when the stream @@ -56,14 +57,14 @@ func TestStartReconnectingStream(t *testing.T) { }, ) - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() start := time.Now() evs := collectEvents(t, ctx, ch, 1) - require.Equal(t, ReconnectingStreamStateReady, evs[0].state) + require.Equal(t, utils.ReconnectingStreamStateReady, evs[0].state) require.GreaterOrEqual(t, time.Since(start), 2*time.Second, "READY for a quiet stream should arrive after the ~2s timedOutRecv timeout") }) @@ -71,9 +72,9 @@ func TestStartReconnectingStream(t *testing.T) { // Verifies that DISCONNECTED is emitted and OnDisconnect is called when Recv returns a // retryable error after READY. t.Run("emits DISCONNECTED and triggers OnDisconnect", func(t *testing.T) { - saved := GrpcReconnectConfig - GrpcReconnectConfig.InitialDelay = 0 - defer func() { GrpcReconnectConfig = saved }() + saved := utils.GrpcReconnectConfig + utils.GrpcReconnectConfig.InitialDelay = 0 + defer func() { utils.GrpcReconnectConfig = saved }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -99,13 +100,13 @@ func TestStartReconnectingStream(t *testing.T) { ) cfg.OnDisconnect = func(err error) { disconnectCh <- err } - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() evs := collectEvents(t, ctx, ch, 2) - require.Equal(t, ReconnectingStreamStateReady, evs[0].state, "event[0]") - require.Equal(t, ReconnectingStreamStateDisconnected, evs[1].state, "event[1]") + require.Equal(t, utils.ReconnectingStreamStateReady, evs[0].state, "event[0]") + require.Equal(t, utils.ReconnectingStreamStateDisconnected, evs[1].state, "event[1]") select { case callErr := <-disconnectCh: @@ -118,9 +119,9 @@ func TestStartReconnectingStream(t *testing.T) { // Verifies that DISCONNECTED and OnDisconnect are NOT emitted when the error contains // "service not ready". t.Run("suppresses DISCONNECTED for notReady error", func(t *testing.T) { - saved := GrpcReconnectConfig - GrpcReconnectConfig.InitialDelay = 0 - defer func() { GrpcReconnectConfig = saved }() + saved := utils.GrpcReconnectConfig + utils.GrpcReconnectConfig.InitialDelay = 0 + defer func() { utils.GrpcReconnectConfig = saved }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -146,16 +147,16 @@ func TestStartReconnectingStream(t *testing.T) { ) cfg.OnDisconnect = func(err error) { disconnectCalled <- struct{}{} } - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() evs := collectEvents(t, ctx, ch, 1) - require.Equal(t, ReconnectingStreamStateReady, evs[0].state) + require.Equal(t, utils.ReconnectingStreamStateReady, evs[0].state) select { case ev := <-ch: - require.NotEqual(t, ReconnectingStreamStateDisconnected, ev.state, + require.NotEqual(t, utils.ReconnectingStreamStateDisconnected, ev.state, "DISCONNECTED must not be emitted for 'service not ready'") case <-disconnectCalled: t.Error("OnDisconnect must not be called for 'service not ready'") @@ -171,9 +172,9 @@ func TestStartReconnectingStream(t *testing.T) { // (the old broken stream) instead of newStream, producing a spurious extra // READY between DISCONNECTED and RECONNECTED. t.Run("emits correct Connection event sequence after reconnection", func(t *testing.T) { - saved := GrpcReconnectConfig - GrpcReconnectConfig.InitialDelay = 0 - defer func() { GrpcReconnectConfig = saved }() + saved := utils.GrpcReconnectConfig + utils.GrpcReconnectConfig.InitialDelay = 0 + defer func() { utils.GrpcReconnectConfig = saved }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -204,23 +205,23 @@ func TestStartReconnectingStream(t *testing.T) { }, ) - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() evs := collectEvents(t, ctx, ch, 4) - require.Equal(t, ReconnectingStreamStateReady, evs[0].state, "event[0]") - require.Equal(t, ReconnectingStreamStateDisconnected, evs[1].state, "event[1]") - require.Equal(t, ReconnectingStreamStateReconnected, evs[2].state, "event[2]") - require.Equal(t, ReconnectingStreamStateReady, evs[3].state, "event[3]") + require.Equal(t, utils.ReconnectingStreamStateReady, evs[0].state, "event[0]") + require.Equal(t, utils.ReconnectingStreamStateDisconnected, evs[1].state, "event[1]") + require.Equal(t, utils.ReconnectingStreamStateReconnected, evs[2].state, "event[2]") + require.Equal(t, utils.ReconnectingStreamStateReady, evs[3].state, "event[3]") }) // Verifies that OnReconnectSuccess is called with the first message from the new // stream after a successful reconnect. t.Run("triggers OnReconnectSuccess after reconnection", func(t *testing.T) { - saved := GrpcReconnectConfig - GrpcReconnectConfig.InitialDelay = 0 - defer func() { GrpcReconnectConfig = saved }() + saved := utils.GrpcReconnectConfig + utils.GrpcReconnectConfig.InitialDelay = 0 + defer func() { utils.GrpcReconnectConfig = saved }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -252,7 +253,7 @@ func TestStartReconnectingStream(t *testing.T) { ) cfg.OnReconnectSuccess = func(r string) { reconnectSuccessCh <- r } - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() @@ -278,9 +279,9 @@ func TestStartReconnectingStream(t *testing.T) { // RED: currently panics at L276 — cfg.OnReconnectSuccess(*recvResp) with // recvResp == nil when timedOutRecv returns (nil, nil) on timeout. t.Run("no panic when probe timeout", func(t *testing.T) { - saved := GrpcReconnectConfig - GrpcReconnectConfig.InitialDelay = 0 - defer func() { GrpcReconnectConfig = saved }() + saved := utils.GrpcReconnectConfig + utils.GrpcReconnectConfig.InitialDelay = 0 + defer func() { utils.GrpcReconnectConfig = saved }() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -307,7 +308,7 @@ func TestStartReconnectingStream(t *testing.T) { ) cfg.OnReconnectSuccess = func(r string) {} - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() @@ -343,7 +344,7 @@ func TestStartReconnectingStream(t *testing.T) { }, ) - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() @@ -385,7 +386,7 @@ func TestStartReconnectingStream(t *testing.T) { }, ) - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) collectEvents(t, ctx, ch, 1) // wait for READY (after 2s timedOutRecv timeout) @@ -414,9 +415,9 @@ func TestStartReconnectingStream(t *testing.T) { // reconnect loop: a failed Reconnect must not return to the outer loop with // a dead recvCh. t.Run("reconnects after repeated Reconnect failures", func(t *testing.T) { - saved := GrpcReconnectConfig - GrpcReconnectConfig.InitialDelay = 0 - defer func() { GrpcReconnectConfig = saved }() + saved := utils.GrpcReconnectConfig + utils.GrpcReconnectConfig.InitialDelay = 0 + defer func() { utils.GrpcReconnectConfig = saved }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -452,15 +453,15 @@ func TestStartReconnectingStream(t *testing.T) { }, ) - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() evs := collectEvents(t, ctx, ch, 4) - require.Equal(t, ReconnectingStreamStateReady, evs[0].state, "event[0]") - require.Equal(t, ReconnectingStreamStateDisconnected, evs[1].state, "event[1]") - require.Equal(t, ReconnectingStreamStateReconnected, evs[2].state, "event[2]") - require.Equal(t, ReconnectingStreamStateReady, evs[3].state, "event[3]") + require.Equal(t, utils.ReconnectingStreamStateReady, evs[0].state, "event[0]") + require.Equal(t, utils.ReconnectingStreamStateDisconnected, evs[1].state, "event[1]") + require.Equal(t, utils.ReconnectingStreamStateReconnected, evs[2].state, "event[2]") + require.Equal(t, utils.ReconnectingStreamStateReady, evs[3].state, "event[3]") require.GreaterOrEqual(t, reconnectAttempts.Load(), int32(3), "Reconnect must have been retried at least 3 times") }) @@ -468,9 +469,9 @@ func TestStartReconnectingStream(t *testing.T) { // Verifies that a non-retryable error from Reconnect emits a terminal error // event and closes the channel. t.Run("terminates on non-retryable Reconnect error", func(t *testing.T) { - saved := GrpcReconnectConfig - GrpcReconnectConfig.InitialDelay = 0 - defer func() { GrpcReconnectConfig = saved }() + saved := utils.GrpcReconnectConfig + utils.GrpcReconnectConfig.InitialDelay = 0 + defer func() { utils.GrpcReconnectConfig = saved }() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -489,7 +490,7 @@ func TestStartReconnectingStream(t *testing.T) { }, ) - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() @@ -520,7 +521,7 @@ func TestStartReconnectingStream(t *testing.T) { msg := "hello" - cfg := ReconnectingStreamConfig[*mockStream, string, testEvent]{ + cfg := utils.ReconnectingStreamConfig[*mockStream, string, testEvent]{ Connect: func(ctx context.Context) (*mockStream, error) { return newMockStream(), nil }, Reconnect: func(ctx context.Context) (string, *mockStream, error) { return "", newMockStream(), nil }, Recv: func(ms *mockStream) (*string, error) { return &msg, nil }, @@ -528,12 +529,12 @@ func TestStartReconnectingStream(t *testing.T) { return fatalErr() }, ErrorEvent: func(err error) testEvent { return testEvent{err: err} }, - ConnectionEvent: func(e ReconnectingStreamStateEvent) testEvent { + ConnectionEvent: func(e utils.ReconnectingStreamStateEvent) testEvent { return testEvent{state: e.State} }, } - ch, closeFn, err := StartReconnectingStream(ctx, cfg) + ch, closeFn, err := utils.StartReconnectingStream(ctx, cfg) require.NoError(t, err) defer closeFn() @@ -575,7 +576,7 @@ func newMockStream() *mockStream { // testEvent is the concrete domain event type used in tests. type testEvent struct { - state ReconnectingStreamState + state utils.ReconnectingStreamState err error } @@ -604,8 +605,8 @@ func makeConfig( connect func(context.Context) (*mockStream, error), reconnect func(context.Context) (*mockStream, error), recv func(*mockStream) (*string, error), -) ReconnectingStreamConfig[*mockStream, string, testEvent] { - return ReconnectingStreamConfig[*mockStream, string, testEvent]{ +) utils.ReconnectingStreamConfig[*mockStream, string, testEvent] { + return utils.ReconnectingStreamConfig[*mockStream, string, testEvent]{ Connect: connect, Reconnect: func(ctx context.Context) (string, *mockStream, error) { stream, err := reconnect(ctx) @@ -614,7 +615,7 @@ func makeConfig( Recv: recv, HandleResp: func(_ context.Context, _ chan<- testEvent, _ string) error { return nil }, ErrorEvent: func(err error) testEvent { return testEvent{err: err} }, - ConnectionEvent: func(e ReconnectingStreamStateEvent) testEvent { + ConnectionEvent: func(e utils.ReconnectingStreamStateEvent) testEvent { return testEvent{state: e.State} }, } diff --git a/pkg/client-lib/internal/utils/types.go b/pkg/client-lib/internal/utils/types.go deleted file mode 100644 index 6d7a97aeb..000000000 --- a/pkg/client-lib/internal/utils/types.go +++ /dev/null @@ -1,55 +0,0 @@ -package utils - -import ( - "strings" - "sync" - - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" -) - -type SupportedType[V any] map[string]V - -func (t SupportedType[V]) String() string { - types := make([]string, 0, len(t)) - for tt := range t { - types = append(types, tt) - } - return strings.Join(types, " | ") -} - -func (t SupportedType[V]) Supports(typeStr string) bool { - _, ok := t[typeStr] - return ok -} - -type ClientFactory func(string, bool) (client.Client, error) - -type IndexerFactory func(string, bool) (indexer.Indexer, error) - -type Cache[V any] struct { - mapping map[string]V - lock *sync.RWMutex -} - -func NewCache[V any]() *Cache[V] { - return &Cache[V]{ - mapping: make(map[string]V), - lock: &sync.RWMutex{}, - } -} - -func (c Cache[V]) Set(key string, value V) { - c.lock.Lock() - defer c.lock.Unlock() - - c.mapping[key] = value -} - -func (c Cache[V]) Get(key string) (V, bool) { - c.lock.RLock() - defer c.lock.RUnlock() - - val, ok := c.mapping[key] - return val, ok -} diff --git a/pkg/client-lib/internal/utils/utils.go b/pkg/client-lib/internal/utils/utils.go deleted file mode 100644 index 777a86499..000000000 --- a/pkg/client-lib/internal/utils/utils.go +++ /dev/null @@ -1,444 +0,0 @@ -package utils - -import ( - "bufio" - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "fmt" - "io" - "net/http" - "sort" - "sync" - "time" - - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/txscript" - "golang.org/x/crypto/pbkdf2" -) - -// CoinSelect selects among boarding utxos and vtxos to cover the total amount of the outputs -// it includes fee computation of the input and output thanks to feeEstimator -// the change is expressed in btc sats -func CoinSelect( - boardingUtxos []types.Utxo, vtxos []types.VtxoWithTapTree, - outputs []types.Receiver, dust uint64, withoutExpirySorting bool, - feeEstimator *arkfee.Estimator, -) ([]types.Utxo, []types.VtxoWithTapTree, uint64, error) { - selected, notSelected := make([]types.VtxoWithTapTree, 0), make([]types.VtxoWithTapTree, 0) - selectedBoarding, notSelectedBoarding := make([]types.Utxo, 0), make([]types.Utxo, 0) - selectedAmount := uint64(0) - - amount := uint64(0) - for _, output := range outputs { - amount += output.Amount - if feeEstimator != nil { - var fees arkfee.FeeAmount - var err error - arkFeeOutput := output.ToArkFeeOutput() - if output.IsOnchain() { - fees, err = feeEstimator.EvalOnchainOutput(arkFeeOutput) - } else { - fees, err = feeEstimator.EvalOffchainOutput(arkFeeOutput) - } - if err != nil { - return nil, nil, 0, err - } - amount += uint64(fees.ToSatoshis()) - } - } - - if !withoutExpirySorting { - // sort vtxos by expiration (oldest last) - sort.SliceStable(vtxos, func(i, j int) bool { - return !vtxos[i].ExpiresAt.Before(vtxos[j].ExpiresAt) - }) - - sort.SliceStable(boardingUtxos, func(i, j int) bool { - return boardingUtxos[i].SpendableAt.Before(boardingUtxos[j].SpendableAt) - }) - } - - for _, boardingUtxo := range boardingUtxos { - if selectedAmount >= amount { - notSelectedBoarding = append(notSelectedBoarding, boardingUtxo) - break - } - - selectedBoarding = append(selectedBoarding, boardingUtxo) - selectedAmount += boardingUtxo.Amount - - if feeEstimator != nil { - fees, err := feeEstimator.EvalOnchainInput(boardingUtxo.ToArkFeeInput()) - if err != nil { - return nil, nil, 0, err - } - amount += uint64(fees.ToSatoshis()) - } - } - - for _, vtxo := range vtxos { - if selectedAmount >= amount { - notSelected = append(notSelected, vtxo) - break - } - - selected = append(selected, vtxo) - selectedAmount += vtxo.Amount - - if feeEstimator != nil { - feesForInput, err := feeEstimator.EvalOffchainInput(vtxo.ToArkFeeInput()) - if err != nil { - return nil, nil, 0, err - } - amount += uint64(feesForInput.ToSatoshis()) - } - } - - if selectedAmount < amount { - return nil, nil, 0, fmt.Errorf("not enough funds to cover amount %d", amount) - } - - change := selectedAmount - amount - - if feeEstimator != nil { - fees, err := feeEstimator.EvalOffchainOutput(arkfee.Output{ - Amount: change, - }) - if err != nil { - return nil, nil, 0, err - } - change -= uint64(fees.ToSatoshis()) - } - - if change < dust { - if len(notSelected) > 0 { - selected = append(selected, notSelected[0]) - change += notSelected[0].Amount - - if feeEstimator != nil { - fees, err := feeEstimator.EvalOffchainInput(notSelected[0].ToArkFeeInput()) - if err != nil { - return nil, nil, 0, err - } - change -= uint64(fees.ToSatoshis()) - } - } else if len(notSelectedBoarding) > 0 { - selectedBoarding = append(selectedBoarding, notSelectedBoarding[0]) - change += notSelectedBoarding[0].Amount - - if feeEstimator != nil { - fees, err := feeEstimator.EvalOnchainInput(notSelectedBoarding[0].ToArkFeeInput()) - if err != nil { - return nil, nil, 0, err - } - change -= uint64(fees.ToSatoshis()) - } - } else { - change = 0 - } - } - - return selectedBoarding, selected, change, nil -} - -// CoinSelectAsset selects a set of vtxos holding a specific asset amount -// the change is expressed in asset sats -func CoinSelectAsset( - vtxos []types.VtxoWithTapTree, amount uint64, - assetID string, withoutExpirySorting bool, -) ([]types.VtxoWithTapTree, uint64, error) { - selected := make([]types.VtxoWithTapTree, 0) - selectedAmount := uint64(0) - - filteredVtxos := make([]types.VtxoWithTapTree, 0) - - // filter out vtxos holding other assets (or no assets) - for _, vtxo := range vtxos { - if len(vtxo.Assets) > 0 { - for _, asset := range vtxo.Assets { - if asset.AssetId == assetID { - filteredVtxos = append(filteredVtxos, vtxo) - break - } - } - } - } - - vtxos = filteredVtxos - - if !withoutExpirySorting { - // sort vtxos by expiration (older first) - sort.SliceStable(vtxos, func(i, j int) bool { - return vtxos[i].ExpiresAt.Before(vtxos[j].ExpiresAt) - }) - } - - for _, vtxo := range vtxos { - if selectedAmount >= amount { - break - } - selected = append(selected, vtxo) - for _, asset := range vtxo.Assets { - if asset.AssetId == assetID { - selectedAmount += asset.Amount - break - } - } - } - - if selectedAmount < amount { - return nil, 0, fmt.Errorf("not enough funds to cover amount %d", amount) - } - - change := selectedAmount - amount - return selected, change, nil -} - -func ParseBitcoinAddress(addr string, net chaincfg.Params) ( - bool, []byte, error, -) { - btcAddr, err := btcutil.DecodeAddress(addr, &net) - if err != nil { - return false, nil, nil - } - - onchainScript, err := txscript.PayToAddrScript(btcAddr) - if err != nil { - return false, nil, err - } - return true, onchainScript, nil -} - -func IsOnchainOnly(receivers []types.Receiver) bool { - for _, receiver := range receivers { - if !receiver.IsOnchain() { - return false - } - } - - return true -} - -func NetworkFromString(net string) arklib.Network { - switch net { - case arklib.BitcoinTestNet.Name: - return arklib.BitcoinTestNet - case arklib.BitcoinTestNet4.Name: - return arklib.BitcoinTestNet4 - case arklib.BitcoinSigNet.Name: - return arklib.BitcoinSigNet - case arklib.BitcoinMutinyNet.Name: - return arklib.BitcoinMutinyNet - case arklib.BitcoinRegTest.Name: - return arklib.BitcoinRegTest - case arklib.Bitcoin.Name: - fallthrough - default: - return arklib.Bitcoin - } -} - -func ToBitcoinNetwork(net arklib.Network) chaincfg.Params { - switch net.Name { - case arklib.Bitcoin.Name: - return chaincfg.MainNetParams - case arklib.BitcoinTestNet.Name: - return chaincfg.TestNet3Params - //case arklib.BitcoinTestNet4.Name: //TODO uncomment once supported - // return chaincfg.TestNet4Params - case arklib.BitcoinSigNet.Name: - return chaincfg.SigNetParams - case arklib.BitcoinMutinyNet.Name: - return arklib.MutinyNetSigNetParams - case arklib.BitcoinRegTest.Name: - return chaincfg.RegressionNetParams - default: - return chaincfg.MainNetParams - } -} - -func GenerateRandomPrivateKey() (*btcec.PrivateKey, error) { - prvkey, err := btcec.NewPrivateKey() - if err != nil { - return nil, err - } - return prvkey, nil -} - -func HashPassword(password []byte) []byte { - hash := sha256.Sum256(password) - return hash[:] -} - -func EncryptAES256(privateKey, password []byte) ([]byte, error) { - if len(privateKey) == 0 { - return nil, fmt.Errorf("missing plaintext private key") - } - if len(password) == 0 { - return nil, fmt.Errorf("missing encryption password") - } - - key, salt, err := deriveKey(password, nil) - if err != nil { - return nil, err - } - - blockCipher, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - gcm, err := cipher.NewGCM(blockCipher) - if err != nil { - return nil, err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err = rand.Read(nonce); err != nil { - return nil, err - } - - ciphertext := gcm.Seal(nonce, nonce, privateKey, nil) - ciphertext = append(ciphertext, salt...) - - return ciphertext, nil -} - -func DecryptAES256(encrypted, password []byte) ([]byte, error) { - if len(encrypted) == 0 { - return nil, fmt.Errorf("missing encrypted mnemonic") - } - if len(password) == 0 { - return nil, fmt.Errorf("missing decryption password") - } - - salt := encrypted[len(encrypted)-32:] - data := encrypted[:len(encrypted)-32] - - key, _, err := deriveKey(password, salt) - if err != nil { - return nil, err - } - - blockCipher, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - gcm, err := cipher.NewGCM(blockCipher) - if err != nil { - return nil, err - } - nonce, text := data[:gcm.NonceSize()], data[gcm.NonceSize():] - // #nosec G407 - plaintext, err := gcm.Open(nil, nonce, text, nil) - if err != nil { - return nil, fmt.Errorf("invalid password") - } - return plaintext, nil -} - -var lock = &sync.Mutex{} - -// deriveKey derives a 32 byte array key from a custom passhprase -func deriveKey(password, salt []byte) ([]byte, []byte, error) { - lock.Lock() - defer lock.Unlock() - - if salt == nil { - salt = make([]byte, 32) - if _, err := rand.Read(salt); err != nil { - return nil, nil, err - } - } - iterations := 10000 - keySize := 32 - key := pbkdf2.Key(password, salt, iterations, keySize, sha256.New) - return key, salt, nil -} - -type ChunkJSONStream struct { - Msg []byte - Err error -} - -func ListenToJSONStream(url string, chunkCh chan ChunkJSONStream) { - defer close(chunkCh) - - httpClient := &http.Client{Timeout: time.Second * 0} - - var resp *http.Response - - for resp == nil { - var err error - resp, err = httpClient.Get(url) - if err != nil { - chunkCh <- ChunkJSONStream{Err: err} - return - } - - // nolint:errcheck - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // handle 524 error by retrying - if resp.StatusCode == 524 { - //nolint:errcheck - resp.Body.Close() - - resp = nil - continue - } - - chunkCh <- ChunkJSONStream{Err: fmt.Errorf("got unexpected status %d code", resp.StatusCode)} - return - } - } - - reader := bufio.NewReader(resp.Body) - for { - msg, err := reader.ReadBytes('\n') - if err != nil { - if err == io.EOF { - err = client.ErrConnectionClosedByServer - } - chunkCh <- ChunkJSONStream{Err: err} - return - } - msg = bytes.Trim(msg, "\n") - chunkCh <- ChunkJSONStream{Msg: msg} - } -} - -func FilterVtxosByExpiry( - vtxos []types.VtxoWithTapTree, expiryThreshold int64, -) []types.VtxoWithTapTree { - now := time.Now() - threshold := time.Duration(expiryThreshold) * time.Second - - nearExpiry := make([]types.VtxoWithTapTree, 0, len(vtxos)) - for _, vtxo := range vtxos { - // time until expiry - timeLeft := vtxo.ExpiresAt.Sub(now) - - // if already expired or within threshold - if timeLeft <= threshold { - nearExpiry = append(nearExpiry, vtxo) - } - } - - return nearExpiry -} - -func SortVtxosByExpiry(vtxos []types.Vtxo) []types.Vtxo { - sort.SliceStable(vtxos, func(i, j int) bool { - return vtxos[i].ExpiresAt.Before(vtxos[j].ExpiresAt) - }) - return vtxos -} diff --git a/pkg/client-lib/internal/utils/utils_test.go b/pkg/client-lib/internal/utils/utils_test.go deleted file mode 100644 index 2c0cb2213..000000000 --- a/pkg/client-lib/internal/utils/utils_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package utils_test - -import ( - "testing" - "time" - - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/stretchr/testify/require" -) - -func TestFilterVtxosByExpiry(t *testing.T) { - now := time.Now() - - const threshold int64 = 3 * 24 * 60 * 60 // 3 days in seconds - - vtxoExpiring1Day := types.VtxoWithTapTree{ - Vtxo: types.Vtxo{ExpiresAt: now.Add(24 * time.Hour)}, - } - vtxoExpiring3Days := types.VtxoWithTapTree{ - Vtxo: types.Vtxo{ExpiresAt: now.Add(time.Duration(threshold) * time.Second)}, - } - vtxoExpiring5Days := types.VtxoWithTapTree{ - Vtxo: types.Vtxo{ExpiresAt: now.Add(5 * 24 * time.Hour)}, - } - vtxoAlreadyExpired := types.VtxoWithTapTree{ - Vtxo: types.Vtxo{ExpiresAt: now.Add(-1 * time.Hour)}, - } - - testCases := []struct { - name string - vtxos []types.VtxoWithTapTree - expected []types.VtxoWithTapTree - }{ - { - name: "vtxo expiring within threshold is kept", - vtxos: []types.VtxoWithTapTree{vtxoExpiring1Day}, - expected: []types.VtxoWithTapTree{vtxoExpiring1Day}, - }, - { - name: "vtxo expiring at exactly threshold boundary is kept", - vtxos: []types.VtxoWithTapTree{vtxoExpiring3Days}, - expected: []types.VtxoWithTapTree{vtxoExpiring3Days}, - }, - { - name: "vtxo expiring beyond threshold is excluded", - vtxos: []types.VtxoWithTapTree{vtxoExpiring5Days}, - expected: []types.VtxoWithTapTree{}, - }, - { - name: "already expired vtxo is kept", - vtxos: []types.VtxoWithTapTree{vtxoAlreadyExpired}, - expected: []types.VtxoWithTapTree{vtxoAlreadyExpired}, - }, - { - name: "mixed vtxos: only within-threshold ones are kept", - vtxos: []types.VtxoWithTapTree{vtxoExpiring1Day, vtxoExpiring5Days, vtxoAlreadyExpired}, - expected: []types.VtxoWithTapTree{vtxoExpiring1Day, vtxoAlreadyExpired}, - }, - { - name: "empty input returns empty result", - vtxos: []types.VtxoWithTapTree{}, - expected: []types.VtxoWithTapTree{}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - got := utils.FilterVtxosByExpiry(tc.vtxos, threshold) - require.Equal(t, tc.expected, got) - }) - } -} diff --git a/pkg/client-lib/offchain-tx/args.go b/pkg/client-lib/offchain-tx/args.go new file mode 100644 index 000000000..cbcd121a8 --- /dev/null +++ b/pkg/client-lib/offchain-tx/args.go @@ -0,0 +1,341 @@ +package offchaintx + +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcec/v2" +) + +// BuildAndSignTxArgs configures the BuildAndSignTx primitive. Receivers are +// the outputs of the offchain payment; the rest of the configuration comes +// from BaseArgs (server info, vtxos to spend, change address, SignTx). +type BuildAndSignTxArgs struct { + BaseArgs + Receivers []clientlib.Receiver +} + +func (a *BuildAndSignTxArgs) validate() error { + if err := a.validateBase(); err != nil { + return err + } + if len(a.Receivers) == 0 { + return fmt.Errorf("missing receivers") + } + return nil +} + +// SendArgs configures the Send orchestrator. It carries the Client used to +// submit and finalize the tx plus every input needed to build it: ServerInfo, +// SignTx, the Vtxos to spend, the change address and the payment Receivers. +type SendArgs struct { + Client clientlib.Client + ServerInfo clientlib.Info + SignTx clientlib.SignFn + Vtxos []clientlib.Vtxo + ChangeAddr string + Receivers []clientlib.Receiver +} + +func (a SendArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + buildArgs := a.toBuildArgs() + return buildArgs.validate() +} + +func (a SendArgs) toBuildArgs() BuildAndSignTxArgs { + return BuildAndSignTxArgs{ + BaseArgs: BaseArgs{ + ServerInfo: a.ServerInfo, + SignTx: a.SignTx, + Vtxos: a.Vtxos, + ChangeAddr: a.ChangeAddr, + }, + Receivers: a.Receivers, + } +} + +// BuildAndSignIssuanceTxArgs configures the BuildAndSignIssuanceTx primitive. +// Amount is the quantity of the new asset to issue. ControlAsset is optional: +// pass NewControlAsset to mint a fresh control asset alongside the issuance, +// ExistingControlAsset to authorize via a control asset already held, or nil +// for an unauthorized issuance. Metadata is attached to the new asset group. +type BuildAndSignIssuanceTxArgs struct { + BaseArgs + Amount uint64 + ControlAsset clientlib.ControlAsset + Metadata []asset.Metadata +} + +func (a BuildAndSignIssuanceTxArgs) validate() error { + if err := a.validateBase(); err != nil { + return err + } + if a.Amount == 0 { + return fmt.Errorf("amount must be > 0") + } + return nil +} + +// IssueAssetArgs configures the IssueAsset orchestrator. It carries the Client +// used to submit and finalize the tx plus every input needed to build it: +// ServerInfo, SignTx, the Vtxos to spend, the change address, the Amount of the +// new asset to issue, the optional ControlAsset and the asset Metadata. See +// BuildAndSignIssuanceTxArgs for the ControlAsset semantics. +type IssueAssetArgs struct { + Client clientlib.Client + ServerInfo clientlib.Info + SignTx clientlib.SignFn + Vtxos []clientlib.Vtxo + ChangeAddr string + Amount uint64 + ControlAsset clientlib.ControlAsset + Metadata []asset.Metadata +} + +func (a IssueAssetArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + buildArgs := a.toBuildArgs() + return buildArgs.validate() +} + +func (a IssueAssetArgs) toBuildArgs() BuildAndSignIssuanceTxArgs { + return BuildAndSignIssuanceTxArgs{ + BaseArgs: BaseArgs{ + ServerInfo: a.ServerInfo, + SignTx: a.SignTx, + Vtxos: a.Vtxos, + ChangeAddr: a.ChangeAddr, + }, + Amount: a.Amount, + ControlAsset: a.ControlAsset, + Metadata: a.Metadata, + } +} + +// BuildAndSignReissuanceTxArgs configures the BuildAndSignReissuanceTx +// primitive. AssetId is the existing asset to mint more of; ControlAssetId +// identifies the control asset that authorizes the reissuance (caller is +// expected to resolve it from the indexer); Amount is the quantity to mint. +type BuildAndSignReissuanceTxArgs struct { + BaseArgs + Asset clientlib.Asset + ControlAsset clientlib.Asset +} + +func (a BuildAndSignReissuanceTxArgs) validate() error { + if err := a.validateBase(); err != nil { + return err + } + if len(a.Asset.AssetId) <= 0 { + return fmt.Errorf("missing asset id") + } + if a.Asset.Amount == 0 { + return fmt.Errorf("missing asset amount") + } + if len(a.ControlAsset.AssetId) <= 0 { + return fmt.Errorf("missing control asset id") + } + if a.ControlAsset.Amount == 0 { + return fmt.Errorf("missing control asset amount") + } + return nil +} + +// ReissueAssetArgs configures the ReissueAsset orchestrator. It carries the +// Client used to submit and finalize the tx plus every input needed to build +// it: ServerInfo, SignTx, the Vtxos to spend, the change address, the Asset to +// mint more of and the ControlAsset that authorizes the reissuance. See +// BuildAndSignReissuanceTxArgs for the Asset/ControlAsset semantics. +type ReissueAssetArgs struct { + Client clientlib.Client + ServerInfo clientlib.Info + SignTx clientlib.SignFn + Vtxos []clientlib.Vtxo + ChangeAddr string + Asset clientlib.Asset + ControlAsset clientlib.Asset +} + +func (a ReissueAssetArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + buildArgs := a.toBuildArgs() + return buildArgs.validate() +} + +func (a ReissueAssetArgs) toBuildArgs() BuildAndSignReissuanceTxArgs { + return BuildAndSignReissuanceTxArgs{ + BaseArgs: BaseArgs{ + ServerInfo: a.ServerInfo, + SignTx: a.SignTx, + Vtxos: a.Vtxos, + ChangeAddr: a.ChangeAddr, + }, + Asset: a.Asset, + ControlAsset: a.ControlAsset, + } +} + +// BuildAndSignBurnTxArgs configures the BuildAndSignBurnTx primitive: which +// asset to destroy (AssetId) and how much of it (Amount). Any remaining +// balance is returned to the caller's change address. +type BuildAndSignBurnTxArgs struct { + BaseArgs + Asset clientlib.Asset +} + +func (a BuildAndSignBurnTxArgs) validate() error { + if err := a.validateBase(); err != nil { + return err + } + if len(a.Asset.AssetId) <= 0 { + return fmt.Errorf("missing asset id") + } + if a.Asset.Amount == 0 { + return fmt.Errorf("amount must be > 0") + } + return nil +} + +// BurnAssetArgs configures the BurnAsset orchestrator. It carries the Client +// used to submit and finalize the tx plus every input needed to build it: +// ServerInfo, SignTx, the Vtxos to spend, the change address and the Asset to +// destroy. See BuildAndSignBurnTxArgs for the Asset semantics. +type BurnAssetArgs struct { + Client clientlib.Client + ServerInfo clientlib.Info + SignTx clientlib.SignFn + Vtxos []clientlib.Vtxo + ChangeAddr string + Asset clientlib.Asset +} + +func (a BurnAssetArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + buildArgs := a.toBuildArgs() + return buildArgs.validate() +} + +func (a BurnAssetArgs) toBuildArgs() BuildAndSignBurnTxArgs { + return BuildAndSignBurnTxArgs{ + BaseArgs: BaseArgs{ + ServerInfo: a.ServerInfo, + SignTx: a.SignTx, + Vtxos: a.Vtxos, + ChangeAddr: a.ChangeAddr, + }, + Asset: a.Asset, + } +} + +// FinalizePendingTxsArgs configures the FinalizePendingTxs orchestrator. +// Vtxos lists the pending vtxos whose pending offchain txs should be +// fetched, signed, and finalized; the caller has already filtered them. +// CreatedAfter is informational only — used by the caller to track which +// txs were considered. +type FinalizePendingTxsArgs struct { + Client clientlib.Client + SignTx clientlib.SignFn + Vtxos []clientlib.Vtxo + CreatedAfter *time.Time // informational only; caller already filtered Vtxos +} + +func (a FinalizePendingTxsArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx") + } + if len(a.Vtxos) == 0 { + return fmt.Errorf("missing vtxos") + } + return nil +} + +// BaseArgs is the input shared by every BuildAndSign...Tx primitive +// and the orchestrators that wrap them. +type BaseArgs struct { + ServerInfo clientlib.Info // provides Dust, SignerPubKey (hex), CheckpointTapscript (hex) + SignTx clientlib.SignFn // signs ark tx + checkpoint txs + Vtxos []clientlib.Vtxo // pre-fetched spendable vtxos (selection runs inside the primitive) + ChangeAddr string // pre-derived offchain change address + + signerPubkey *btcec.PublicKey + checkpointTapscript []byte +} + +func (a *BaseArgs) validateBase() error { + if a.SignTx == nil { + return fmt.Errorf("missing sign tx") + } + if a.ServerInfo.Dust == 0 { + return fmt.Errorf("missing server info") + } + if a.ServerInfo.SignerPubKey == "" { + return fmt.Errorf("missing signer pubkey") + } + if a.ChangeAddr == "" { + return fmt.Errorf("missing change address") + } + for _, v := range a.Vtxos { + if v.IsRecoverable() { + return fmt.Errorf("invalid funds: vtxo %s is recoverable", v.String()) + } + if v.Spent { + return fmt.Errorf("invalid funds: vtxo %s is spent", v.String()) + } + if v.Unrolled { + return fmt.Errorf("invalid funds: vtxo %s is unrolled", v.String()) + } + } + signerPubkey, err := parsePubkey(a.ServerInfo.SignerPubKey) + if err != nil { + return fmt.Errorf("invalid signer pubkey: %w", err) + } + a.signerPubkey = signerPubkey + return nil +} + +func (a *BaseArgs) signerPubKey() (*btcec.PublicKey, error) { + if a.signerPubkey != nil { + return a.signerPubkey, nil + } + + signerPubkey, err := parsePubkey(a.ServerInfo.SignerPubKey) + if err != nil { + return nil, err + } + a.signerPubkey = signerPubkey + return signerPubkey, nil +} + +func (a *BaseArgs) checkpointExitPath() ([]byte, error) { + if len(a.checkpointTapscript) > 0 { + return a.checkpointTapscript, nil + } + + if len(a.ServerInfo.CheckpointTapscript) <= 0 { + return nil, fmt.Errorf("missing checkpoint tapscript") + } + buf, err := hex.DecodeString(a.ServerInfo.CheckpointTapscript) + if err != nil { + return nil, fmt.Errorf( + "invalid checkpoint tapscript format: expected hex, got %s", + a.ServerInfo.CheckpointTapscript, + ) + } + a.checkpointTapscript = buf + return buf, nil +} diff --git a/pkg/client-lib/offchain-tx/asset.go b/pkg/client-lib/offchain-tx/asset.go new file mode 100644 index 000000000..f897b87bb --- /dev/null +++ b/pkg/client-lib/offchain-tx/asset.go @@ -0,0 +1,192 @@ +package offchaintx + +import ( + "context" + "fmt" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" +) + +// IssueAsset builds, signs, submits, verifies, and finalizes an offchain ark +// transaction that issues one (or two, when a new control asset is created) +// asset groups. Returns the finalized tx along with the IDs of the newly +// minted assets. +func IssueAsset( + ctx context.Context, args IssueAssetArgs, opts ...Option, +) (*IssueAssetRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + buildArgs := args.toBuildArgs() + signerPubKey, err := buildArgs.signerPubKey() + if err != nil { + return nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + + build, err := BuildAndSignIssuanceTx(ctx, buildArgs, opts...) + if err != nil { + return nil, err + } + + txid, tx, checkpointTxs, err := submitAndFinalize( + ctx, args.Client, args.SignTx, signerPubKey, &build.BuildAndSignTxRes, + ) + if err != nil { + return nil, err + } + + // The result receiver is the caller's change address decorated with the + // newly minted asset IDs. For the ExistingControlAsset case the receiver + // also carries one unit of the existing control asset. + receiver := clientlib.Receiver{ + To: args.ChangeAddr, + Amount: args.ServerInfo.Dust, + } + if existing, ok := args.ControlAsset.(clientlib.ExistingControlAsset); ok { + receiver.Assets = append(receiver.Assets, clientlib.Asset{ + AssetId: existing.Id, + Amount: existing.Amount, + }) + } + for i, id := range build.IssuedAssets { + receiver.Assets = append(receiver.Assets, clientlib.Asset{ + AssetId: id.String(), + Amount: assetGroupOutputAmount(build, i), + }) + } + + outs := []clientlib.Receiver{receiver} + if build.ChangeReceiver != nil { + outs = append(outs, *build.ChangeReceiver) + } + + return &IssueAssetRes{ + OffchainTxRes: OffchainTxRes{ + Txid: txid, + Tx: tx, + CheckpointTxs: checkpointTxs, + Inputs: build.SelectedCoins, + Outputs: outs, + Extension: build.Extension, + }, + IssuedAssets: build.IssuedAssets, + }, nil +} + +// assetGroupOutputAmount reads the (single-output-per-group) amount the +// primitive recorded for the i-th issued asset. +func assetGroupOutputAmount(build *BuildAndSignIssuanceTxRes, i int) uint64 { + if i >= len(build.AssetPacket) { + return 0 + } + if len(build.AssetPacket[i].Outputs) == 0 { + return 0 + } + return build.AssetPacket[i].Outputs[0].Amount +} + +// ReissueAsset builds, signs, submits, verifies, and finalizes an offchain ark +// transaction that mints additional units of an existing asset, authorized by +// the control asset vtxo held by the caller. +func ReissueAsset( + ctx context.Context, args ReissueAssetArgs, opts ...Option, +) (*OffchainTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + buildArgs := args.toBuildArgs() + signerPubKey, err := buildArgs.signerPubKey() + if err != nil { + return nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + + build, err := BuildAndSignReissuanceTx(ctx, buildArgs, opts...) + if err != nil { + return nil, err + } + + txid, tx, checkpointTxs, err := submitAndFinalize( + ctx, args.Client, args.SignTx, signerPubKey, build, + ) + if err != nil { + return nil, err + } + + receiver := clientlib.Receiver{ + To: args.ChangeAddr, + Amount: args.ServerInfo.Dust, + Assets: []clientlib.Asset{args.ControlAsset, args.Asset}, + } + + outs := []clientlib.Receiver{receiver} + if build.ChangeReceiver != nil { + outs = append(outs, *build.ChangeReceiver) + } + + return &OffchainTxRes{ + Txid: txid, + Tx: tx, + CheckpointTxs: checkpointTxs, + Inputs: build.SelectedCoins, + Outputs: outs, + Extension: build.Extension, + }, nil +} + +// BurnAsset builds, signs, submits, verifies, and finalizes an offchain ark +// transaction that destroys a given amount of an asset, returning any +// remaining asset balance and BTC change to the caller's change address. +func BurnAsset(ctx context.Context, args BurnAssetArgs, opts ...Option) (*OffchainTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + buildArgs := args.toBuildArgs() + signerPubKey, err := buildArgs.signerPubKey() + if err != nil { + return nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + + build, err := BuildAndSignBurnTx(ctx, buildArgs, opts...) + if err != nil { + return nil, err + } + + txid, tx, checkpointTxs, err := submitAndFinalize( + ctx, args.Client, args.SignTx, signerPubKey, build, + ) + if err != nil { + return nil, err + } + + // Two-output layout: + // first output: burn receiver at Dust, carrying change's assets if any + // second output (optional): plain BTC change at change amount + burnAssets := []clientlib.Asset(nil) + if build.ChangeReceiver != nil { + burnAssets = build.ChangeReceiver.Assets + } + + outs := []clientlib.Receiver{{ + To: args.ChangeAddr, + Amount: args.ServerInfo.Dust, + Assets: burnAssets, + }} + if build.ChangeReceiver != nil { + outs = append(outs, clientlib.Receiver{ + To: build.ChangeReceiver.To, + Amount: build.ChangeReceiver.Amount, + }) + } + + return &OffchainTxRes{ + Txid: txid, + Tx: tx, + CheckpointTxs: checkpointTxs, + Inputs: build.SelectedCoins, + Outputs: outs, + Extension: build.Extension, + }, nil +} diff --git a/pkg/client-lib/offchain-tx/asset_test.go b/pkg/client-lib/offchain-tx/asset_test.go new file mode 100644 index 000000000..93a929cc1 --- /dev/null +++ b/pkg/client-lib/offchain-tx/asset_test.go @@ -0,0 +1,471 @@ +package offchaintx + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +func TestIssueAsset(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*IssueAssetArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *IssueAssetArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing sign tx", + mutate: func(a *IssueAssetArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + { + name: "missing server info", + mutate: func(a *IssueAssetArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + { + name: "missing signer pubkey", + mutate: func(a *IssueAssetArgs) { a.ServerInfo.SignerPubKey = "" }, + errSubstr: "missing signer pubkey", + }, + { + name: "invalid signer pubkey hex", + mutate: func(a *IssueAssetArgs) { a.ServerInfo.SignerPubKey = "zz" }, + errSubstr: "invalid signer pubkey", + }, + { + name: "missing change addr", + mutate: func(a *IssueAssetArgs) { a.ChangeAddr = "" }, + errSubstr: "missing change address", + }, + { + name: "zero amount", + mutate: func(a *IssueAssetArgs) { a.Amount = 0 }, + errSubstr: "amount must be > 0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestIssueAssetArgs() + tc.mutate(&args) + + _, err := IssueAsset(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +func TestBuildAndSignIssuanceTx(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*BuildAndSignIssuanceTxArgs) + errSubstr string + }{ + { + name: "missing sign tx", + mutate: func(a *BuildAndSignIssuanceTxArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + { + name: "missing server info", + mutate: func(a *BuildAndSignIssuanceTxArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + { + name: "missing signer pubkey", + mutate: func(a *BuildAndSignIssuanceTxArgs) { a.ServerInfo.SignerPubKey = "" }, + errSubstr: "missing signer pubkey", + }, + { + name: "invalid signer pubkey hex", + mutate: func(a *BuildAndSignIssuanceTxArgs) { a.ServerInfo.SignerPubKey = "zz" }, + errSubstr: "invalid signer pubkey", + }, + { + name: "missing change addr", + mutate: func(a *BuildAndSignIssuanceTxArgs) { a.ChangeAddr = "" }, + errSubstr: "missing change address", + }, + { + name: "zero amount", + mutate: func(a *BuildAndSignIssuanceTxArgs) { a.Amount = 0 }, + errSubstr: "amount must be > 0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestIssueAssetBuildArgs() + tc.mutate(&args) + + _, err := BuildAndSignIssuanceTx(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +func TestReissueAsset(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*ReissueAssetArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *ReissueAssetArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing sign tx", + mutate: func(a *ReissueAssetArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + { + name: "missing server info", + mutate: func(a *ReissueAssetArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + { + name: "missing signer pubkey", + mutate: func(a *ReissueAssetArgs) { a.ServerInfo.SignerPubKey = "" }, + errSubstr: "missing signer pubkey", + }, + { + name: "invalid signer pubkey hex", + mutate: func(a *ReissueAssetArgs) { a.ServerInfo.SignerPubKey = "zz" }, + errSubstr: "invalid signer pubkey", + }, + { + name: "missing change addr", + mutate: func(a *ReissueAssetArgs) { a.ChangeAddr = "" }, + errSubstr: "missing change address", + }, + { + name: "missing asset id", + mutate: func(a *ReissueAssetArgs) { a.Asset.AssetId = "" }, + errSubstr: "missing asset id", + }, + { + name: "missing control asset id", + mutate: func(a *ReissueAssetArgs) { a.ControlAsset.AssetId = "" }, + errSubstr: "missing control asset id", + }, + { + name: "missing asset amount", + mutate: func(a *ReissueAssetArgs) { a.Asset.Amount = 0 }, + errSubstr: "missing asset amount", + }, + { + name: "missing control asset amount", + mutate: func(a *ReissueAssetArgs) { a.ControlAsset.Amount = 0 }, + errSubstr: "missing control asset amount", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestReissueAssetArgs() + tc.mutate(&args) + + _, err := ReissueAsset(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +func TestBuildAndSignReissuanceTx(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*BuildAndSignReissuanceTxArgs) + errSubstr string + }{ + { + name: "missing sign tx", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + { + name: "missing server info", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + { + name: "missing signer pubkey", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ServerInfo.SignerPubKey = "" }, + errSubstr: "missing signer pubkey", + }, + { + name: "invalid signer pubkey hex", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ServerInfo.SignerPubKey = "zz" }, + errSubstr: "invalid signer pubkey", + }, + { + name: "missing change addr", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ChangeAddr = "" }, + errSubstr: "missing change address", + }, + { + name: "missing asset id", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.Asset.AssetId = "" }, + errSubstr: "missing asset id", + }, + { + name: "missing control asset id", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ControlAsset.AssetId = "" }, + errSubstr: "missing control asset id", + }, + { + name: "missing asset amount", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.Asset.Amount = 0 }, + errSubstr: "missing asset amount", + }, + { + name: "missing control asset amount", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ControlAsset.Amount = 0 }, + errSubstr: "missing control asset amount", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestReissueAssetBuildArgs() + tc.mutate(&args) + + _, err := BuildAndSignReissuanceTx(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +func TestBurnAsset(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*BurnAssetArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *BurnAssetArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing sign tx", + mutate: func(a *BurnAssetArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + { + name: "missing server info", + mutate: func(a *BurnAssetArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + { + name: "missing signer pubkey", + mutate: func(a *BurnAssetArgs) { a.ServerInfo.SignerPubKey = "" }, + errSubstr: "missing signer pubkey", + }, + { + name: "invalid signer pubkey hex", + mutate: func(a *BurnAssetArgs) { a.ServerInfo.SignerPubKey = "zz" }, + errSubstr: "invalid signer pubkey", + }, + { + name: "missing change addr", + mutate: func(a *BurnAssetArgs) { a.ChangeAddr = "" }, + errSubstr: "missing change address", + }, + { + name: "missing asset id", + mutate: func(a *BurnAssetArgs) { a.Asset = clientlib.Asset{Amount: a.Asset.Amount} }, + errSubstr: "missing asset id", + }, + { + name: "zero amount", + mutate: func(a *BurnAssetArgs) { a.Asset = clientlib.Asset{AssetId: a.Asset.AssetId} }, + errSubstr: "amount must be > 0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestBurnAssetArgs() + tc.mutate(&args) + + _, err := BurnAsset(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +func TestBuildAndSignBurnTx(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*BuildAndSignBurnTxArgs) + errSubstr string + }{ + { + name: "missing sign tx", + mutate: func(a *BuildAndSignBurnTxArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + { + name: "missing server info", + mutate: func(a *BuildAndSignBurnTxArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + { + name: "missing signer pubkey", + mutate: func(a *BuildAndSignBurnTxArgs) { a.ServerInfo.SignerPubKey = "" }, + errSubstr: "missing signer pubkey", + }, + { + name: "invalid signer pubkey hex", + mutate: func(a *BuildAndSignBurnTxArgs) { a.ServerInfo.SignerPubKey = "zz" }, + errSubstr: "invalid signer pubkey", + }, + { + name: "missing change addr", + mutate: func(a *BuildAndSignBurnTxArgs) { a.ChangeAddr = "" }, + errSubstr: "missing change address", + }, + { + name: "missing asset id", + mutate: func(a *BuildAndSignBurnTxArgs) { a.Asset = clientlib.Asset{Amount: a.Asset.Amount} }, + errSubstr: "missing asset id", + }, + { + name: "zero amount", + mutate: func(a *BuildAndSignBurnTxArgs) { a.Asset = clientlib.Asset{AssetId: a.Asset.AssetId} }, + errSubstr: "amount must be > 0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestBurnAssetBuildArgs() + tc.mutate(&args) + + _, err := BuildAndSignBurnTx(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +// newTestIssueAssetArgs returns a valid baseline IssueAssetArgs. Tests in this +// file mutate a single field to exercise the corresponding validation error. +func newTestIssueAssetArgs() IssueAssetArgs { + b := newTestIssueAssetBuildArgs() + return IssueAssetArgs{ + Client: mockClient{}, + ServerInfo: b.ServerInfo, + SignTx: b.SignTx, + Vtxos: b.Vtxos, + ChangeAddr: b.ChangeAddr, + Amount: b.Amount, + ControlAsset: b.ControlAsset, + Metadata: b.Metadata, + } +} + +// newTestIssueAssetBuildArgs returns a valid baseline IssueAssetBuildArgs. +// Tests mutate a single field to exercise validation errors from the +// BuildAndSignIssuanceTx primitive. +func newTestIssueAssetBuildArgs() BuildAndSignIssuanceTxArgs { + return BuildAndSignIssuanceTxArgs{ + BaseArgs: BaseArgs{ + ServerInfo: clientlib.Info{Dust: 1000, SignerPubKey: testSignerPubKey}, + SignTx: mockSignTx, + ChangeAddr: "tark1qexample", + }, + Amount: 100, + ControlAsset: clientlib.NewControlAsset{Amount: 1}, + } +} + +// newTestReissueAssetArgs returns a valid baseline ReissueAssetArgs. +func newTestReissueAssetArgs() ReissueAssetArgs { + b := newTestReissueAssetBuildArgs() + return ReissueAssetArgs{ + Client: mockClient{}, + ServerInfo: b.ServerInfo, + SignTx: b.SignTx, + Vtxos: b.Vtxos, + ChangeAddr: b.ChangeAddr, + Asset: b.Asset, + ControlAsset: b.ControlAsset, + } +} + +// newTestReissueAssetBuildArgs returns a valid baseline +// ReissueAssetBuildArgs. Tests mutate one field to exercise the primitive's +// validation errors. +func newTestReissueAssetBuildArgs() BuildAndSignReissuanceTxArgs { + return BuildAndSignReissuanceTxArgs{ + BaseArgs: BaseArgs{ + ServerInfo: clientlib.Info{Dust: 1000, SignerPubKey: testSignerPubKey}, + SignTx: mockSignTx, + ChangeAddr: "tark1qexample", + }, + Asset: clientlib.Asset{ + AssetId: "fakeassetid", + Amount: 100, + }, + ControlAsset: clientlib.Asset{ + AssetId: "fakecontrolassetid", + Amount: 2, + }, + } +} + +// newTestBurnAssetArgs returns a valid baseline BurnAssetArgs. +func newTestBurnAssetArgs() BurnAssetArgs { + b := newTestBurnAssetBuildArgs() + return BurnAssetArgs{ + Client: mockClient{}, + ServerInfo: b.ServerInfo, + SignTx: b.SignTx, + Vtxos: b.Vtxos, + ChangeAddr: b.ChangeAddr, + Asset: b.Asset, + } +} + +// newTestBurnAssetBuildArgs returns a valid baseline BurnAssetBuildArgs. +// Tests mutate one field to exercise the primitive's validation errors. +func newTestBurnAssetBuildArgs() BuildAndSignBurnTxArgs { + return BuildAndSignBurnTxArgs{ + BaseArgs: BaseArgs{ + ServerInfo: clientlib.Info{Dust: 1000, SignerPubKey: testSignerPubKey}, + SignTx: mockSignTx, + ChangeAddr: "tark1qexample", + }, + Asset: clientlib.Asset{ + AssetId: "fakeassetid", + Amount: 100, + }, + } +} diff --git a/pkg/client-lib/offchain-tx/build.go b/pkg/client-lib/offchain-tx/build.go new file mode 100644 index 000000000..1bb65bb58 --- /dev/null +++ b/pkg/client-lib/offchain-tx/build.go @@ -0,0 +1,462 @@ +package offchaintx + +import ( + "context" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcutil/psbt" +) + +// BuildAndSignTx builds and signs an offchain transaction (plus its checkpoint transactions) +// ready for submission. It does NOT submit the txs to the server — callers can use the result +// with a custom submit flow, while SendOffChain wraps the full lifecycle. +func BuildAndSignTx( + ctx context.Context, args BuildAndSignTxArgs, opts ...Option, +) (*BuildAndSignTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + o := newOptions() + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, err + } + } + + baseArkTx, checkpointTxs, selectedCoins, changeReceiver, err := createOffchainTx( + ctx, args.BaseArgs, args.Receivers, + ) + if err != nil { + return nil, err + } + + arkPtx, err := psbt.NewFromRawBytes(strings.NewReader(baseArkTx), true) + if err != nil { + return nil, err + } + + // Pass the ORIGINAL receivers (without change) to createAssetPacket and + // hand changeReceiver as a separate argument — createOffchainTx already + // appended the change to its own copy of the receivers slice. + assetPacket, err := createAssetPacket( + selectedCoinsToAssetInputs(selectedCoins), + args.Receivers, + changeReceiver, + ) + if err != nil { + return nil, err + } + + if err := addExtension(arkPtx, assetPacket, o.extraPackets); err != nil { + return nil, err + } + + if err := addOutputsTaprootTree(arkPtx, o.outputsTapTree); err != nil { + return nil, err + } + + arkTx, err := arkPtx.B64Encode() + if err != nil { + return nil, err + } + + signedArkTx, err := args.SignTx(ctx, arkTx) + if err != nil { + return nil, err + } + + ext := make(extension.Extension, 0, 1+len(o.extraPackets)) + if len(assetPacket) > 0 { + ext = append(ext, assetPacket) + } + ext = append(ext, o.extraPackets...) + + return &BuildAndSignTxRes{ + Txid: arkPtx.UnsignedTx.TxID(), + ArkTx: arkTx, + SignedArkTx: signedArkTx, + CheckpointTxs: checkpointTxs, + SelectedCoins: selectedCoins, + ChangeReceiver: changeReceiver, + AssetPacket: assetPacket, + Extension: ext, + }, nil +} + +// BuildAndSignIssuanceTx builds and signs an offchain ark transaction that +// issues a new asset (and, optionally, a fresh control asset). It does NOT +// submit the tx to the server — IssueAsset wraps the full lifecycle. +func BuildAndSignIssuanceTx( + ctx context.Context, args BuildAndSignIssuanceTxArgs, opts ...Option, +) (*BuildAndSignIssuanceTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + o := newOptions() + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, err + } + } + + receiverAsset := make([]clientlib.Asset, 0) + if existing, ok := args.ControlAsset.(clientlib.ExistingControlAsset); ok { + receiverAsset = append(receiverAsset, clientlib.Asset{ + AssetId: existing.Id, + Amount: existing.Amount, + }) + } + + receiver := clientlib.Receiver{ + To: args.ChangeAddr, + Amount: args.ServerInfo.Dust, + Assets: receiverAsset, + } + + baseArkTx, checkpointTxs, selectedCoins, changeReceiver, err := createOffchainTx( + ctx, args.BaseArgs, []clientlib.Receiver{receiver}, + ) + if err != nil { + return nil, err + } + + arkPtx, err := psbt.NewFromRawBytes(strings.NewReader(baseArkTx), true) + if err != nil { + return nil, err + } + + assetGroups := make([]asset.AssetGroup, 0) + var assetRef *asset.AssetRef + + packet, err := createAssetPacket( + selectedCoinsToAssetInputs(selectedCoins), + []clientlib.Receiver{receiver}, + changeReceiver, + ) + if err != nil { + return nil, err + } + + switch ca := args.ControlAsset.(type) { + case clientlib.NewControlAsset: + controlAssetOutput, err := asset.NewAssetOutput(0, ca.Amount) + if err != nil { + return nil, err + } + controlAssetGroup, err := asset.NewAssetGroup( + nil, nil, nil, + []asset.AssetOutput{*controlAssetOutput}, args.Metadata, + ) + if err != nil { + return nil, err + } + assetGroups = append(assetGroups, *controlAssetGroup) + assetRef = &asset.AssetRef{Type: asset.AssetRefByGroup, GroupIndex: 0} + case clientlib.ExistingControlAsset: + controlAssetId, err := asset.NewAssetIdFromString(ca.Id) + if err != nil { + return nil, err + } + assetRef = &asset.AssetRef{Type: asset.AssetRefByID, AssetId: *controlAssetId} + } + + issuedAssetOutput, err := asset.NewAssetOutput(0, args.Amount) + if err != nil { + return nil, err + } + issuedAssetGroup, err := asset.NewAssetGroup( + nil, assetRef, nil, + []asset.AssetOutput{*issuedAssetOutput}, args.Metadata, + ) + if err != nil { + return nil, err + } + assetGroups = append(assetGroups, *issuedAssetGroup) + + assetPacket, err := asset.NewPacket(append(assetGroups, packet...)) + if err != nil { + return nil, err + } + + if err := addExtension(arkPtx, assetPacket, o.extraPackets); err != nil { + return nil, err + } + + arkTx, err := arkPtx.B64Encode() + if err != nil { + return nil, err + } + + txid := arkPtx.UnsignedTx.TxID() + + // Derive asset IDs from the (now stable) txid + group index. + issuedAssets := make([]asset.AssetId, 0, len(assetGroups)) + groupIdx := uint16(0) + if _, ok := args.ControlAsset.(clientlib.NewControlAsset); ok { + controlId, err := asset.NewAssetId(txid, groupIdx) + if err != nil { + return nil, err + } + issuedAssets = append(issuedAssets, *controlId) + groupIdx++ + } + issuedId, err := asset.NewAssetId(txid, groupIdx) + if err != nil { + return nil, err + } + issuedAssets = append(issuedAssets, *issuedId) + + signedArkTx, err := args.SignTx(ctx, arkTx) + if err != nil { + return nil, err + } + + ext := append(extension.Extension{assetPacket}, o.extraPackets...) + + return &BuildAndSignIssuanceTxRes{ + BuildAndSignTxRes: BuildAndSignTxRes{ + Txid: txid, + ArkTx: arkTx, + SignedArkTx: signedArkTx, + CheckpointTxs: checkpointTxs, + SelectedCoins: selectedCoins, + ChangeReceiver: changeReceiver, + AssetPacket: assetPacket, + Extension: ext, + }, + IssuedAssets: issuedAssets, + }, nil +} + +// BuildAndSignReissuanceTx builds and signs an offchain ark transaction that +// mints additional units of an existing asset, authorized by the control +// asset. It does NOT submit the tx to the server — ReissueAsset wraps the +// full lifecycle. +func BuildAndSignReissuanceTx( + ctx context.Context, args BuildAndSignReissuanceTxArgs, opts ...Option, +) (*BuildAndSignTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + o := newOptions() + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, err + } + } + + receiver := clientlib.Receiver{ + To: args.ChangeAddr, + Amount: args.ServerInfo.Dust, + Assets: []clientlib.Asset{args.ControlAsset}, + } + + receivers := []clientlib.Receiver{receiver} + + baseArkTx, checkpointTxs, selectedCoins, changeReceiver, err := createOffchainTx( + ctx, args.BaseArgs, receivers, + ) + if err != nil { + return nil, err + } + + arkPtx, err := psbt.NewFromRawBytes(strings.NewReader(baseArkTx), true) + if err != nil { + return nil, err + } + + assetPacket, err := createAssetPacket( + selectedCoinsToAssetInputs(selectedCoins), receivers, changeReceiver, + ) + if err != nil { + return nil, err + } + if len(assetPacket) == 0 { + return nil, fmt.Errorf("failed to create asset packet") + } + + issuedAssetOutput, err := asset.NewAssetOutput(0, args.Asset.Amount) + if err != nil { + return nil, err + } + + groupIndex := -1 + for i, g := range assetPacket { + if g.AssetId == nil { + continue + } + if g.AssetId.String() == args.Asset.AssetId { + groupIndex = i + } + } + + if groupIndex == -1 { + reissueAssetId, err := asset.NewAssetIdFromString(args.Asset.AssetId) + if err != nil { + return nil, err + } + issuedAssetGroup, err := asset.NewAssetGroup( + reissueAssetId, nil, nil, []asset.AssetOutput{*issuedAssetOutput}, nil, + ) + if err != nil { + return nil, err + } + assetPacket = append(assetPacket, *issuedAssetGroup) + } else { + assetPacket[groupIndex].Outputs = append( + assetPacket[groupIndex].Outputs, *issuedAssetOutput, + ) + } + + if err := addExtension(arkPtx, assetPacket, o.extraPackets); err != nil { + return nil, err + } + + arkTx, err := arkPtx.B64Encode() + if err != nil { + return nil, err + } + + signedArkTx, err := args.SignTx(ctx, arkTx) + if err != nil { + return nil, err + } + + ext := append(extension.Extension{assetPacket}, o.extraPackets...) + + return &BuildAndSignTxRes{ + Txid: arkPtx.UnsignedTx.TxID(), + ArkTx: arkTx, + SignedArkTx: signedArkTx, + CheckpointTxs: checkpointTxs, + SelectedCoins: selectedCoins, + ChangeReceiver: changeReceiver, + AssetPacket: assetPacket, + Extension: ext, + }, nil +} + +// BuildAndSignBurnTx builds and signs an offchain ark transaction that +// destroys a given amount of an asset, carrying any remaining asset change +// and BTC change back to the caller. It does NOT submit the tx to the +// server — BurnAsset wraps the full lifecycle. +func BuildAndSignBurnTx( + ctx context.Context, args BuildAndSignBurnTxArgs, opts ...Option, +) (*BuildAndSignTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + o := newOptions() + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, err + } + } + + burnReceiver := clientlib.Receiver{ + To: args.ChangeAddr, + Amount: args.ServerInfo.Dust, + Assets: []clientlib.Asset{args.Asset}, + } + + receivers := []clientlib.Receiver{burnReceiver} + baseArkTx, checkpointTxs, selectedCoins, changeReceiver, err := createOffchainTx( + ctx, args.BaseArgs, receivers, + ) + if err != nil { + return nil, err + } + + arkPtx, err := psbt.NewFromRawBytes(strings.NewReader(baseArkTx), true) + if err != nil { + return nil, err + } + + // remove the burned asset from receivers; carry the change's assets back + if changeReceiver != nil { + receivers[0].Assets = changeReceiver.Assets + receivers[0].Amount += changeReceiver.Amount + } else { + receivers[0].Assets = nil + } + + assetPacket, err := createAssetPacket( + selectedCoinsToAssetInputs(selectedCoins), receivers, nil, + ) + if err != nil { + return nil, err + } + + if err := addExtension(arkPtx, assetPacket, o.extraPackets); err != nil { + return nil, err + } + + arkTx, err := arkPtx.B64Encode() + if err != nil { + return nil, err + } + + signedArkTx, err := args.SignTx(ctx, arkTx) + if err != nil { + return nil, err + } + + ext := append(extension.Extension{assetPacket}, o.extraPackets...) + + return &BuildAndSignTxRes{ + Txid: arkPtx.UnsignedTx.TxID(), + ArkTx: arkTx, + SignedArkTx: signedArkTx, + CheckpointTxs: checkpointTxs, + SelectedCoins: selectedCoins, + ChangeReceiver: changeReceiver, + AssetPacket: assetPacket, + Extension: ext, + }, nil +} + +// addOutputsTaprootTree sets the BIP-371 TaprootTapTree field on every PSBT output whose +// hex-encoded pkScript matches a key in byPkScript. An error is returned when a key matches no +// output. +func addOutputsTaprootTree(ptx *psbt.Packet, taprootTrees map[string][]byte) error { + if len(taprootTrees) <= 0 { + return nil + } + if len(ptx.UnsignedTx.TxOut) != len(ptx.Outputs) { + return fmt.Errorf( + "output count mismatch: unsigned tx has %d outputs but ptx has %d", + len(ptx.UnsignedTx.TxOut), len(ptx.Outputs), + ) + } + matched := make(map[string]bool, len(taprootTrees)) + for i, out := range ptx.UnsignedTx.TxOut { + pkHex := hex.EncodeToString(out.PkScript) + tapTree, ok := taprootTrees[pkHex] + if !ok { + continue + } + ptx.Outputs[i].TaprootTapTree = tapTree + matched[pkHex] = true + } + if len(matched) == len(taprootTrees) { + return nil + } + unmatched := make([]string, 0, len(taprootTrees)-len(matched)) + for k := range taprootTrees { + if !matched[k] { + unmatched = append(unmatched, k) + } + } + sort.Strings(unmatched) + return fmt.Errorf("no matching output for pkScript(s): %s", strings.Join(unmatched, ", ")) +} diff --git a/pkg/client-lib/offchain-tx/opts.go b/pkg/client-lib/offchain-tx/opts.go new file mode 100644 index 000000000..dcc969e8c --- /dev/null +++ b/pkg/client-lib/offchain-tx/opts.go @@ -0,0 +1,90 @@ +package offchaintx + +import ( + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" +) + +// Option customizes the behavior of an offchain-tx operation (Send, +// IssueAsset, ReissueAsset, BurnAsset, and their BuildAndSign* primitives). +// Use the With* helpers in this package to construct instances. +type Option interface { + apply(*options) error +} + +// WithExtraPacket appends extra extension.Packet values to the OP_RETURN +// extension blob included in the ark transaction alongside the asset packet +// (type 0x00). Type 0x00 is reserved and rejected. Duplicate packet types are +// not permitted. +func WithExtraPacket(packets ...extension.Packet) Option { + return optFn(func(o *options) error { + if len(packets) <= 0 { + return fmt.Errorf("missing packet(s)") + } + seen := make(map[uint8]bool) + for _, existing := range o.extraPackets { + seen[existing.Type()] = true + } + for _, p := range packets { + if p == nil { + return fmt.Errorf("extension packet must not be nil") + } + if p.Type() == asset.PacketType { + return fmt.Errorf( + "packet type 0x%02x is reserved for the asset packet", asset.PacketType, + ) + } + if seen[p.Type()] { + return fmt.Errorf("duplicated packet type 0x%02x", p.Type()) + } + seen[p.Type()] = true + } + o.extraPackets = append(o.extraPackets, packets...) + return nil + }) +} + +// WithTxOutsTaprootTree sets the PSBT BIP-371 TaprootTapTree field on +// every output whose hex-encoded pkScript matches a key in the map. Callers +// pass the BIP-371-encoded tap tree bytes (via txutils.TapTree(scripts).Encode()). +// SendOffChain returns an error if any pkScript key matches no output of the +// ark tx, surfacing what would otherwise be a silent footgun for protocol- +// critical VTXO spending. +func WithTxOutsTaprootTree(tapTrees map[string][]byte) Option { + return optFn(func(o *options) error { + if len(tapTrees) <= 0 { + return fmt.Errorf("missing taproot trees") + } + if o.outputsTapTree == nil { + o.outputsTapTree = make(map[string][]byte, len(tapTrees)) + } + for k, v := range tapTrees { + if len(v) == 0 { + return fmt.Errorf("receiver tap tree must not be empty") + } + if _, err := txutils.DecodeTapTree(v); err != nil { + return fmt.Errorf("invalid bip-371 tap tree for tx out with script %s: %w", k, err) + } + cp := make([]byte, len(v)) + copy(cp, v) + o.outputsTapTree[k] = cp + } + return nil + }) +} + +type optFn func(*options) error + +func (f optFn) apply(o *options) error { return f(o) } + +type options struct { + extraPackets []extension.Packet + outputsTapTree map[string][]byte // pkScript (hex) -> bip371 taptree +} + +func newOptions() *options { + return &options{} +} diff --git a/pkg/client-lib/offchain-tx/opts_test.go b/pkg/client-lib/offchain-tx/opts_test.go new file mode 100644 index 000000000..35a48d4a9 --- /dev/null +++ b/pkg/client-lib/offchain-tx/opts_test.go @@ -0,0 +1,183 @@ +package offchaintx + +import ( + "testing" + + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + "github.com/stretchr/testify/require" +) + +// sampleTapTreeBytes is a BIP-371-encoded tap tree taken from the BIP-371 +// test vector; used as a known-good blob across tap-tree tests. +const sampleTapTreeHex = "01c02220736e572900fe1252589a2143c8f3c79f71a0412d2353af755e9701c782694a02ac" + +func TestWithExtraPacket(t *testing.T) { + t.Run("valid", func(t *testing.T) { + p1 := extension.UnknownPacket{PacketType: 0x03, Data: []byte{0xde, 0xad}} + p2 := extension.UnknownPacket{PacketType: 0x04, Data: []byte{0xbe, 0xef}} + p1A := extension.UnknownPacket{PacketType: 0x03, Data: []byte{0x01}} + p2A := extension.UnknownPacket{PacketType: 0x04, Data: []byte{0x02}} + + testCases := []struct { + name string + applyPackets [][]extension.Packet + expectTypes []uint8 + }{ + { + name: "appends valid packets", + applyPackets: [][]extension.Packet{[]extension.Packet{p1, p2}}, + expectTypes: []uint8{0x03, 0x04}, + }, + { + name: "multiple calls accumulate", + applyPackets: [][]extension.Packet{ + []extension.Packet{p1A}, + []extension.Packet{p2A}, + }, + expectTypes: []uint8{0x03, 0x04}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := newOptions() + for _, callPackets := range tc.applyPackets { + require.NoError(t, WithExtraPacket(callPackets...).apply(opts)) + } + require.Len(t, opts.extraPackets, len(tc.expectTypes)) + for i, wantType := range tc.expectTypes { + require.Equal(t, wantType, opts.extraPackets[i].Type()) + } + }) + } + }) + + t.Run("invalid", func(t *testing.T) { + testCases := []struct { + name string + packets []extension.Packet + expectErrorContains string + }{ + { + name: "rejects type 0x00", + packets: []extension.Packet{ + extension.UnknownPacket{PacketType: asset.PacketType, Data: []byte{0x01}}, + }, + expectErrorContains: "reserved", + }, + { + name: "rejects nil packet", + packets: []extension.Packet{nil}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := newOptions() + err := WithExtraPacket(tc.packets...).apply(opts) + require.Error(t, err) + if tc.expectErrorContains != "" { + require.Contains(t, err.Error(), tc.expectErrorContains) + } + require.Empty(t, opts.extraPackets) + }) + } + }) +} + +func TestWithTxOutsTaprootTree(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("populates state and defensively copies values", func(t *testing.T) { + tree := sampleTapTreeBytes(t) + caller := map[string][]byte{"abcd": tree} + + opts := newOptions() + require.NoError(t, WithTxOutsTaprootTree(caller).apply(opts)) + + stored, ok := opts.outputsTapTree["abcd"] + require.True(t, ok) + require.Equal(t, tree, stored) + + // mutating the caller's slice must not leak into the stored copy + tree[0] ^= 0xff + require.NotEqual(t, tree[0], stored[0]) + }) + + t.Run("multiple calls merge keys", func(t *testing.T) { + opts := newOptions() + require.NoError(t, WithTxOutsTaprootTree(map[string][]byte{ + "aa": sampleTapTreeBytes(t), + }).apply(opts)) + require.NoError(t, WithTxOutsTaprootTree(map[string][]byte{ + "bb": sampleTapTreeBytes(t), + }).apply(opts)) + + require.Len(t, opts.outputsTapTree, 2) + require.Contains(t, opts.outputsTapTree, "aa") + require.Contains(t, opts.outputsTapTree, "bb") + }) + + t.Run("later call overwrites same key", func(t *testing.T) { + first := encodedTapTree(t, + "20736e572900fe1252589a2143c8f3c79f71a0412d2353af755e9701c782694a02ac", + ) + second := encodedTapTree(t, + "20631c5f3b5832b8fbdebfb19704ceeb323c21f40f7a24f43d68ef0cc26b125969ac", + ) + + opts := newOptions() + require.NoError(t, WithTxOutsTaprootTree( + map[string][]byte{"aa": first}, + ).apply(opts)) + require.NoError(t, WithTxOutsTaprootTree( + map[string][]byte{"aa": second}, + ).apply(opts)) + + require.Equal(t, second, opts.outputsTapTree["aa"]) + }) + }) + + t.Run("invalid", func(t *testing.T) { + validTree := sampleTapTreeBytes(t) + testCases := []struct { + name string + input map[string][]byte + expectErrorContains string + }{ + { + name: "missing trees", + input: map[string][]byte{}, + expectErrorContains: "missing taproot trees", + }, + { + name: "empty tree", + input: map[string][]byte{"deadbeef": {}}, + expectErrorContains: "must not be empty", + }, + { + name: "malformed bip-371 tree", + // Header advertises a 0xff-byte script but no payload follows. + input: map[string][]byte{"deadbeef": {0x01, 0xc0, 0xff}}, + expectErrorContains: "invalid bip-371 tap tree", + }, + { + name: "many trees with one invalid", + input: map[string][]byte{ + "aa": validTree, + "bb": {}, + }, + expectErrorContains: "must not be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := newOptions() + err := WithTxOutsTaprootTree(tc.input).apply(opts) + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectErrorContains) + }) + } + }) +} diff --git a/pkg/client-lib/offchain-tx/pending.go b/pkg/client-lib/offchain-tx/pending.go new file mode 100644 index 000000000..844454ef1 --- /dev/null +++ b/pkg/client-lib/offchain-tx/pending.go @@ -0,0 +1,47 @@ +package offchaintx + +import ( + "context" + "fmt" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsession "github.com/arkade-os/arkd/pkg/client-lib/batch-session" + log "github.com/sirupsen/logrus" +) + +// FinalizePendingTxs asks the server for pending offchain txs tied to +// args.Vtxos via the intent proof, then signs and finalizes each. +func FinalizePendingTxs( + ctx context.Context, args FinalizePendingTxsArgs, +) ([]string, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + proofTx, message, err := batchsession.BuildAndSignGetPendingTxIntent( + ctx, batchsession.IntentArgs{BaseArgs: batchsession.BaseArgs{ + Vtxos: args.Vtxos, + SignTx: clientlib.SignFn(args.SignTx), + }}, + ) + if err != nil { + return nil, err + } + + pendingTxs, err := args.Client.GetPendingTx(ctx, proofTx, message) + if err != nil { + return nil, err + } + + txids := make([]string, 0, len(pendingTxs)) + for _, tx := range pendingTxs { + txid, _, err := finalizeTx(ctx, args.Client, args.SignTx, tx) + if err != nil { + log.WithError(err).Errorf("failed to finalize pending tx: %s", tx.Txid) + continue + } + txids = append(txids, txid) + } + + return txids, nil +} diff --git a/pkg/client-lib/offchain-tx/pending_test.go b/pkg/client-lib/offchain-tx/pending_test.go new file mode 100644 index 000000000..5768e3db9 --- /dev/null +++ b/pkg/client-lib/offchain-tx/pending_test.go @@ -0,0 +1,55 @@ +package offchaintx + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +func TestFinalizePendingTxs(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*FinalizePendingTxsArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *FinalizePendingTxsArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing sign tx", + mutate: func(a *FinalizePendingTxsArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestFinalizePendingTxsArgs() + tc.mutate(&args) + + _, err := FinalizePendingTxs(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +// newTestFinalizePendingTxsArgs returns a valid baseline +// FinalizePendingTxsArgs. Tests mutate a single field on the returned value to +// exercise the corresponding validation error. +func newTestFinalizePendingTxsArgs() FinalizePendingTxsArgs { + return FinalizePendingTxsArgs{ + Client: mockClient{}, + SignTx: mockSignTx, + Vtxos: []clientlib.Vtxo{{ + Outpoint: clientlib.Outpoint{Txid: "deadbeef", VOut: 0}, + Amount: 10000, + }}, + } +} diff --git a/pkg/client-lib/offchain-tx/send.go b/pkg/client-lib/offchain-tx/send.go new file mode 100644 index 000000000..bede853f0 --- /dev/null +++ b/pkg/client-lib/offchain-tx/send.go @@ -0,0 +1,48 @@ +package offchaintx + +import ( + "context" + "fmt" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" +) + +// Send builds, signs, submits, verifies, and finalizes an offchain +// payment transaction. +func Send(ctx context.Context, args SendArgs, opts ...Option) (*OffchainTxRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + buildArgs := args.toBuildArgs() + signerPubKey, err := buildArgs.signerPubKey() + if err != nil { + return nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + + build, err := BuildAndSignTx(ctx, buildArgs, opts...) + if err != nil { + return nil, err + } + + txid, signedArk, finalCps, err := submitAndFinalize( + ctx, args.Client, args.SignTx, signerPubKey, build, + ) + if err != nil { + return nil, err + } + + outs := make([]clientlib.Receiver, 0) + if build.ChangeReceiver != nil { + outs = append(outs, *build.ChangeReceiver) + } + + return &OffchainTxRes{ + Txid: txid, + Tx: signedArk, + CheckpointTxs: finalCps, + Inputs: build.SelectedCoins, + Outputs: outs, + Extension: build.Extension, + }, nil +} diff --git a/pkg/client-lib/offchain-tx/send_test.go b/pkg/client-lib/offchain-tx/send_test.go new file mode 100644 index 000000000..d3978546f --- /dev/null +++ b/pkg/client-lib/offchain-tx/send_test.go @@ -0,0 +1,161 @@ +package offchaintx + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/stretchr/testify/require" +) + +// testSignerPubKey is a real compressed pubkey hex used so that parsePubkey() +// succeeds when a test is not exercising the pubkey error path. Reused across +// every offchain-tx invalid-path test file. +const testSignerPubKey = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" + +func TestSend(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*SendArgs) + errSubstr string + }{ + { + name: "missing client", + mutate: func(a *SendArgs) { a.Client = nil }, + errSubstr: "missing client", + }, + { + name: "missing receivers", + mutate: func(a *SendArgs) { a.Receivers = nil }, + errSubstr: "missing receivers", + }, + { + name: "missing sign tx", + mutate: func(a *SendArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + { + name: "missing server info", + mutate: func(a *SendArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + { + name: "missing signer pubkey", + mutate: func(a *SendArgs) { a.ServerInfo.SignerPubKey = "" }, + errSubstr: "missing signer pubkey", + }, + { + name: "invalid signer pubkey hex", + mutate: func(a *SendArgs) { a.ServerInfo.SignerPubKey = "zz" }, + errSubstr: "invalid signer pubkey", + }, + { + name: "missing change addr", + mutate: func(a *SendArgs) { a.ChangeAddr = "" }, + errSubstr: "missing change address", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestSendArgs() + tc.mutate(&args) + + _, err := Send(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +func TestBuildAndSignTx(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + tests := []struct { + name string + mutate func(*BuildAndSignTxArgs) + errSubstr string + }{ + { + name: "missing receivers", + mutate: func(a *BuildAndSignTxArgs) { a.Receivers = nil }, + errSubstr: "missing receivers", + }, + { + name: "missing sign tx", + mutate: func(a *BuildAndSignTxArgs) { a.SignTx = nil }, + errSubstr: "missing sign tx", + }, + { + name: "missing server info", + mutate: func(a *BuildAndSignTxArgs) { a.ServerInfo.Dust = 0 }, + errSubstr: "missing server info", + }, + { + name: "missing signer pubkey", + mutate: func(a *BuildAndSignTxArgs) { a.ServerInfo.SignerPubKey = "" }, + errSubstr: "missing signer pubkey", + }, + { + name: "invalid signer pubkey hex", + mutate: func(a *BuildAndSignTxArgs) { a.ServerInfo.SignerPubKey = "zz" }, + errSubstr: "invalid signer pubkey", + }, + { + name: "missing change addr", + mutate: func(a *BuildAndSignTxArgs) { a.ChangeAddr = "" }, + errSubstr: "missing change address", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := newTestSendBuildArgs() + tc.mutate(&args) + + _, err := BuildAndSignTx(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSubstr) + }) + } + }) +} + +// mockClient is the smallest non-nil clientlib.Client that satisfies validation. +// Validation rejects requests before any method on Client is invoked, so the +// embedded nil interface is sufficient. +type mockClient struct{ clientlib.Client } + +// mockSignTx is a valid SignFn baseline used everywhere a non-nil signer is +// required without exercising the signing path. +func mockSignTx(context.Context, string) (string, error) { return "", nil } + +// newTestSendArgs returns a valid baseline SendArgs. Tests in this file mutate +// a single field on the returned value to exercise the corresponding +// validation error. +func newTestSendArgs() SendArgs { + b := newTestSendBuildArgs() + return SendArgs{ + Client: mockClient{}, + ServerInfo: b.ServerInfo, + SignTx: b.SignTx, + Vtxos: b.Vtxos, + ChangeAddr: b.ChangeAddr, + Receivers: b.Receivers, + } +} + +// newTestSendBuildArgs returns a valid baseline BuildTxArgs. Tests mutate a +// single field on the returned value to exercise the corresponding validation +// error from BuildAndSignTx. +func newTestSendBuildArgs() BuildAndSignTxArgs { + return BuildAndSignTxArgs{ + BaseArgs: BaseArgs{ + ServerInfo: clientlib.Info{Dust: 1000, SignerPubKey: testSignerPubKey}, + SignTx: mockSignTx, + ChangeAddr: "tark1qexample", + }, + Receivers: []clientlib.Receiver{{To: "tark1qexample", Amount: 10000}}, + } +} diff --git a/pkg/client-lib/offchain-tx/types.go b/pkg/client-lib/offchain-tx/types.go new file mode 100644 index 000000000..57d2bf9a9 --- /dev/null +++ b/pkg/client-lib/offchain-tx/types.go @@ -0,0 +1,52 @@ +package offchaintx + +import ( + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" +) + +// BuildAndSignTxRes is the output of every BuildAndSign...Tx primitive +// except BuildAndSignIssuanceTx (which also adds the derived asset IDs). +type BuildAndSignTxRes struct { + // Txid of the resulting ark tx, computed from arkPtx.UnsignedTx.TxID() + // after all outputs (including the extension OP_RETURN) are attached but + // before any witnesses are added. Witness data does not affect the txid. + Txid string + // ArkTx is the unsigned PSBT (base64) used for post-submit verification. + ArkTx string + // SignedArkTx is the client-signed PSBT (base64) ready for SubmitTx. + SignedArkTx string + // CheckpointTxs are the unsigned checkpoint PSBTs (base64). They are + // signed by the client only after the server signs them in SubmitTx; + // finalization passes them through args.SignTx. + CheckpointTxs []string + SelectedCoins []clientlib.Vtxo + ChangeReceiver *clientlib.Receiver + AssetPacket asset.Packet + Extension extension.Extension +} + +// BuildAndSignIssuanceTxRes extends BuildAndSignTxRes with the asset IDs derived +// inside the primitive from the unsigned tx's txid plus the asset-group index. +type BuildAndSignIssuanceTxRes struct { + BuildAndSignTxRes + IssuedAssets []asset.AssetId +} + +// OffchainTxRes is the result of a full-lifecycle orchestrator call. +type OffchainTxRes struct { + Txid string + Tx string + CheckpointTxs []string + Inputs []clientlib.Vtxo + Outputs []clientlib.Receiver + Extension extension.Extension +} + +// IssueAssetRes carries the new asset IDs alongside the standard offchain +// transaction result. +type IssueAssetRes struct { + OffchainTxRes + IssuedAssets []asset.AssetId +} diff --git a/pkg/client-lib/offchain-tx/utils.go b/pkg/client-lib/offchain-tx/utils.go new file mode 100644 index 000000000..4321e7fda --- /dev/null +++ b/pkg/client-lib/offchain-tx/utils.go @@ -0,0 +1,668 @@ +package offchaintx + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" + "github.com/arkade-os/arkd/pkg/ark-lib/offchain" + "github.com/arkade-os/arkd/pkg/ark-lib/script" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" +) + +type arkTxInput struct { + clientlib.Vtxo + ForfeitLeafHash chainhash.Hash +} + +func buildOffchainTx( + vtxos []arkTxInput, receivers []clientlib.Receiver, serverUnrollScript []byte, dustLimit uint64, +) (string, []string, error) { + if len(vtxos) <= 0 { + return "", nil, fmt.Errorf("missing vtxos") + } + + ins := make([]offchain.VtxoInput, 0, len(vtxos)) + for _, vtxo := range vtxos { + if len(vtxo.Tapscripts) <= 0 { + return "", nil, fmt.Errorf("missing tapscripts for vtxo %s", vtxo.Txid) + } + + vtxoTxID, err := chainhash.NewHashFromStr(vtxo.Txid) + if err != nil { + return "", nil, err + } + + vtxoOutpoint := &wire.OutPoint{ + Hash: *vtxoTxID, + Index: vtxo.VOut, + } + + vtxoScript, err := script.ParseVtxoScript(vtxo.Tapscripts) + if err != nil { + return "", nil, err + } + + _, vtxoTree, err := vtxoScript.TapTree() + if err != nil { + return "", nil, err + } + + leafProof, err := vtxoTree.GetTaprootMerkleProof(vtxo.ForfeitLeafHash) + if err != nil { + return "", nil, err + } + + ctrlBlock, err := txscript.ParseControlBlock(leafProof.ControlBlock) + if err != nil { + return "", nil, err + } + + tapscript := &waddrmgr.Tapscript{ + RevealedScript: leafProof.Script, + ControlBlock: ctrlBlock, + } + + ins = append(ins, offchain.VtxoInput{ + Outpoint: vtxoOutpoint, + Tapscript: tapscript, + Amount: int64(vtxo.Amount), + RevealedTapscripts: vtxo.Tapscripts, + }) + } + + outs := make([]*wire.TxOut, 0, len(receivers)) + + for i, receiver := range receivers { + if receiver.IsOnchain() { + return "", nil, fmt.Errorf("receiver %d is onchain", i) + } + + addr, err := arklib.DecodeAddressV0(receiver.To) + if err != nil { + return "", nil, err + } + + var newVtxoScript []byte + + if receiver.Amount < dustLimit { + newVtxoScript, err = script.SubDustScript(addr.VtxoTapKey) + } else { + newVtxoScript, err = script.P2TRScript(addr.VtxoTapKey) + } + if err != nil { + return "", nil, err + } + + outs = append(outs, &wire.TxOut{ + Value: int64(receiver.Amount), + PkScript: newVtxoScript, + }) + } + + arkPtx, checkpointPtxs, err := offchain.BuildTxs(ins, outs, serverUnrollScript) + if err != nil { + return "", nil, err + } + + arkTx, err := arkPtx.B64Encode() + if err != nil { + return "", nil, err + } + + checkpointTxs := make([]string, 0, len(checkpointPtxs)) + for _, ptx := range checkpointPtxs { + tx, err := ptx.B64Encode() + if err != nil { + return "", nil, err + } + checkpointTxs = append(checkpointTxs, tx) + } + + return arkTx, checkpointTxs, nil +} + +func selectedCoinsToAssetInputs(selectedCoins []clientlib.Vtxo) map[int][]clientlib.Asset { + assetInputs := make(map[int][]clientlib.Asset) + for inputIndex, coin := range selectedCoins { + if len(coin.Assets) == 0 { + continue + } + assetInputs[inputIndex] = coin.Assets + } + return assetInputs +} + +// createAssetPacket computes the right packet for the given asset inputs and receivers +func createAssetPacket( + assetInputs map[int][]clientlib.Asset, receivers []clientlib.Receiver, changeReceiver *clientlib.Receiver, +) (asset.Packet, error) { + if changeReceiver != nil { + receivers = append(receivers, *changeReceiver) + } + + type assetTransfer struct { + inputs []asset.AssetInput + outputs []asset.AssetOutput + } + + assetTransfers := make(map[string]*assetTransfer) + for inputIndex, assets := range assetInputs { + for _, a := range assets { + if _, exists := assetTransfers[a.AssetId]; !exists { + assetTransfers[a.AssetId] = &assetTransfer{ + inputs: make([]asset.AssetInput, 0), + outputs: make([]asset.AssetOutput, 0), + } + } + + input, err := asset.NewAssetInput(uint16(inputIndex), a.Amount) + if err != nil { + return nil, err + } + assetTransfers[a.AssetId].inputs = append( + assetTransfers[a.AssetId].inputs, + *input, + ) + } + } + + for receiverIndex, receiver := range receivers { + if len(receiver.Assets) == 0 { + continue + } + + for _, ass := range receiver.Assets { + if _, exists := assetTransfers[ass.AssetId]; !exists { + return nil, fmt.Errorf("asset %s not found", ass.AssetId) + } + + output, err := asset.NewAssetOutput(uint16(receiverIndex), ass.Amount) + if err != nil { + return nil, err + } + assetTransfers[ass.AssetId].outputs = append( + assetTransfers[ass.AssetId].outputs, + *output, + ) + } + } + + assetGroups := make([]asset.AssetGroup, 0) + for assetId, inputsOutputs := range assetTransfers { + assetId, err := asset.NewAssetIdFromString(assetId) + if err != nil { + return nil, err + } + + assetGroup, err := asset.NewAssetGroup( + assetId, + nil, + inputsOutputs.inputs, + inputsOutputs.outputs, + nil, + ) + if err != nil { + return nil, err + } + assetGroups = append(assetGroups, *assetGroup) + } + + if len(assetGroups) == 0 { + return nil, nil + } + + return asset.NewPacket(assetGroups) +} + +// addExtension inserts an extension OP_RETURN (asset packet + extras) right +// before the P2A anchor output, which remains last. If both assetPacket and +// extraPkts are empty it is a no-op. Duplicate packet types are rejected. +func addExtension( + ptx *psbt.Packet, assetPacket asset.Packet, extraPkts []extension.Packet, +) error { + // Nothing to add when we have neither an asset packet nor extras. + if len(assetPacket) == 0 && len(extraPkts) == 0 { + return nil + } + + pkts := make([]extension.Packet, 0, 1+len(extraPkts)) + if len(assetPacket) > 0 { + pkts = append(pkts, assetPacket) + } + pkts = append(pkts, extraPkts...) + + ext, err := extension.NewExtensionFromPackets(pkts...) + if err != nil { + return err + } + + packetOut, err := ext.TxOut() + if err != nil { + return fmt.Errorf("building extension txout: %w", err) + } + // Insert the extension output immediately before the P2A anchor, keeping + // ptx.Outputs[i] aligned with ptx.UnsignedTx.TxOut[i]. The anchor's own + // PSBT-level metadata must follow its TxOut to the new last index; the + // fresh empty POutput goes next to the EXT TxOut. + lastIdx := len(ptx.UnsignedTx.TxOut) - 1 + p2aTxOut := ptx.UnsignedTx.TxOut[lastIdx] + p2aPOutput := ptx.Outputs[lastIdx] + ptx.UnsignedTx.TxOut[lastIdx] = packetOut + ptx.Outputs[lastIdx] = psbt.POutput{} + ptx.UnsignedTx.TxOut = append(ptx.UnsignedTx.TxOut, p2aTxOut) + ptx.Outputs = append(ptx.Outputs, p2aPOutput) + return nil +} + +// verifyOffchainTx verifies the signer signatures of the given transaction +func verifyOffchainTx(original, signed *psbt.Packet, signerPubkey *btcec.PublicKey) error { + xonlySigner := schnorr.SerializePubKey(signerPubkey) + + if original.UnsignedTx.TxID() != signed.UnsignedTx.TxID() { + return fmt.Errorf("invalid offchain tx : txids mismatch") + } + + if len(original.Inputs) != len(signed.Inputs) { + return fmt.Errorf( + "input count mismatch: expected %d, got %d", + len(original.Inputs), + len(signed.Inputs), + ) + } + + if len(original.UnsignedTx.TxIn) != len(signed.UnsignedTx.TxIn) { + return fmt.Errorf( + "transaction input count mismatch: expected %d, got %d", + len(original.UnsignedTx.TxIn), + len(signed.UnsignedTx.TxIn), + ) + } + + prevouts := make(map[wire.OutPoint]*wire.TxOut) + + for inputIndex, signedInput := range signed.Inputs { + if signedInput.WitnessUtxo == nil { + return fmt.Errorf("witness utxo not found for input %d", inputIndex) + } + + // fill prevouts map with the original witness data + previousOutpoint := original.UnsignedTx.TxIn[inputIndex].PreviousOutPoint + prevouts[previousOutpoint] = original.Inputs[inputIndex].WitnessUtxo + } + + prevoutFetcher := txscript.NewMultiPrevOutFetcher(prevouts) + txsigHashes := txscript.NewTxSigHashes(original.UnsignedTx, prevoutFetcher) + + // loop over every input and check that the signer's signature is present and valid + for inputIndex, signedInput := range signed.Inputs { + originalInput := original.Inputs[inputIndex] + if len(originalInput.TaprootLeafScript) == 0 { + return fmt.Errorf( + "original input %d has no taproot leaf script, cannot verify signature", + inputIndex, + ) + } + + // check that every input has the signer's signature + var signerSig *psbt.TaprootScriptSpendSig + + for _, sig := range signedInput.TaprootScriptSpendSig { + if bytes.Equal(sig.XOnlyPubKey, xonlySigner) { + signerSig = sig + break + } + } + + if signerSig == nil { + return fmt.Errorf("signer signature not found for input %d", inputIndex) + } + + sig, err := schnorr.ParseSignature(signerSig.Signature) + if err != nil { + return fmt.Errorf("failed to parse signer signature for input %d: %s", inputIndex, err) + } + + // verify the signature + message, err := txscript.CalcTapscriptSignaturehash( + txsigHashes, + signedInput.SighashType, + original.UnsignedTx, + inputIndex, + prevoutFetcher, + txscript.NewBaseTapLeaf(originalInput.TaprootLeafScript[0].Script), + ) + if err != nil { + return err + } + + if !sig.Verify(message, signerPubkey) { + return fmt.Errorf("invalid signer signature for input %d", inputIndex) + } + } + return nil +} + +// createOffchainTx selects coins, computes change, and assembles the base +// (unsigned, no asset packet) ark tx + checkpoint txs. +func createOffchainTx( + _ context.Context, args BaseArgs, receivers []clientlib.Receiver, +) (string, []string, []clientlib.Vtxo, *clientlib.Receiver, error) { + if len(receivers) <= 0 { + return "", nil, nil, nil, fmt.Errorf("missing receivers") + } + + checkpointExitPath, err := args.checkpointExitPath() + if err != nil { + return "", nil, nil, nil, err + } + + signerPubKey, err := args.signerPubKey() + if err != nil { + return "", nil, nil, nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + expectedSignerPubkey := schnorr.SerializePubKey(signerPubKey) + + for _, receiver := range receivers { + if receiver.IsOnchain() { + return "", nil, nil, nil, fmt.Errorf( + "all receiver addresses must be offchain addresses", + ) + } + + addr, err := arklib.DecodeAddressV0(receiver.To) + if err != nil { + return "", nil, nil, nil, fmt.Errorf("invalid receiver address: %s", err) + } + + rcvSignerPubkey := schnorr.SerializePubKey(addr.Signer) + if !bytes.Equal(expectedSignerPubkey, rcvSignerPubkey) { + return "", nil, nil, nil, fmt.Errorf( + "invalid receiver address '%s': expected signer pubkey %x, got %x", + receiver.To, expectedSignerPubkey, rcvSignerPubkey, + ) + } + } + + btcAmountToSelect := int64(0) + selectedCoins := make([]clientlib.Vtxo, 0) + assetChanges := make(map[string]uint64) + selectedVtxos := make(map[string]bool) + + for _, receiver := range receivers { + btcAmountToSelect += int64(receiver.Amount) + + if len(receiver.Assets) > 0 { + for _, asset := range receiver.Assets { + amountToSelect := asset.Amount + existingChangeAmount := assetChanges[asset.AssetId] + if existingChangeAmount > 0 { + if amountToSelect <= existingChangeAmount { + // change covers the needed amount, no need to select any more coins + assetChanges[asset.AssetId] -= amountToSelect + if assetChanges[asset.AssetId] == 0 { + delete(assetChanges, asset.AssetId) + } + continue + } else { + // change does not cover the needed amount, select the remaining amount + amountToSelect -= existingChangeAmount + delete(assetChanges, asset.AssetId) + } + } + + availableVtxos := make([]clientlib.Vtxo, 0, len(args.Vtxos)) + for _, v := range args.Vtxos { + if !selectedVtxos[v.Outpoint.String()] { + availableVtxos = append(availableVtxos, v) + } + } + + assetCoins, assetChangeAmount, err := clientlib.CoinSelectAsset( + availableVtxos, amountToSelect, asset.AssetId, false, + ) + if err != nil { + return "", nil, nil, nil, err + } + + for _, coin := range assetCoins { + coinID := coin.Outpoint.String() + selectedVtxos[coinID] = true + selectedCoins = append(selectedCoins, coin) + + // asset coins contain btc, subtract it from the total amount to select + btcAmountToSelect -= int64(coin.Amount) + + // coin may contain other assets, add them to the asset changes + for _, a := range coin.Assets { + if a.AssetId == asset.AssetId { + continue + } + assetChanges[a.AssetId] += a.Amount + } + } + if assetChangeAmount > 0 { + assetChanges[asset.AssetId] += assetChangeAmount + } + } + } + } + + changeAmount := uint64(0) + + if btcAmountToSelect >= 0 { + isZero := btcAmountToSelect == 0 + + // filter out already-selected vtxos + availableVtxos := make([]clientlib.Vtxo, 0, len(args.Vtxos)) + for _, v := range args.Vtxos { + if !selectedVtxos[v.Outpoint.String()] { + availableVtxos = append(availableVtxos, v) + } + } + + // skip BTC coin selection if all BTC was covered by asset coins + // and there are no more available vtxos (send-all scenario) + if isZero && len(availableVtxos) == 0 { + changeAmount = 0 + } else { + if isZero { + btcAmountToSelect = int64(args.ServerInfo.Dust) + } + + _, selectedBtcCoins, changeBtcAmount, err := clientlib.CoinSelect( + nil, availableVtxos, + // use a "fake" receiver to select only the remaining btc amount + // it works for offchain tx because feeEstimator is nil (no offchain fee) + []clientlib.Receiver{{Amount: uint64(btcAmountToSelect)}}, + args.ServerInfo.Dust, nil, + ) + if err != nil { + return "", nil, nil, nil, err + } + + // some coins may contain assets, add them to the asset changes + for _, coin := range selectedBtcCoins { + for _, asset := range coin.Assets { + if asset.Amount > 0 { + assetChanges[asset.AssetId] += asset.Amount + } + } + } + + selectedCoins = append(selectedCoins, selectedBtcCoins...) + changeAmount = changeBtcAmount + if isZero { + changeAmount = changeBtcAmount + args.ServerInfo.Dust + } + } + } else { + changeAmount = uint64(-btcAmountToSelect) + } + + var changeReceiver *clientlib.Receiver + + // enforce a minimum change amount when there are asset changes + if len(assetChanges) > 0 && changeAmount == 0 { + // build a set of already-selected coin outpoints to avoid double-selection + selectedOutpoints := make(map[string]struct{}) + for _, coin := range selectedCoins { + selectedOutpoints[coin.Txid+fmt.Sprintf(":%d", coin.VOut)] = struct{}{} + } + + availableVtxos := make([]clientlib.Vtxo, 0) + for _, vtxo := range args.Vtxos { + outpoint := vtxo.Outpoint.String() + if _, selected := selectedOutpoints[outpoint]; selected { + continue + } + // only include vtxos without assets + if len(vtxo.Assets) == 0 { + availableVtxos = append(availableVtxos, vtxo) + } + } + + _, selectedBtcCoins, changeBtcAmount, err := clientlib.CoinSelect( + nil, availableVtxos, []clientlib.Receiver{{Amount: args.ServerInfo.Dust}}, + args.ServerInfo.Dust, nil, + ) + if err != nil { + return "", nil, nil, nil, fmt.Errorf( + "failed to select coins for asset change output: %w", + err, + ) + } + + selectedCoins = append(selectedCoins, selectedBtcCoins...) + changeAmount = changeBtcAmount + args.ServerInfo.Dust + } + + if changeAmount > 0 { + changeReceiver = &clientlib.Receiver{ + To: args.ChangeAddr, Amount: changeAmount, + } + if len(assetChanges) > 0 { + for assetID, amount := range assetChanges { + if amount > 0 { + changeReceiver.Assets = append(changeReceiver.Assets, clientlib.Asset{ + AssetId: assetID, + Amount: amount, + }) + } + } + } + + receivers = append(receivers, *changeReceiver) + } + + inputs := make([]arkTxInput, 0, len(selectedCoins)) + + for _, coin := range selectedCoins { + vtxoScript, err := script.ParseVtxoScript(coin.Tapscripts) + if err != nil { + return "", nil, nil, nil, err + } + + forfeitClosures := vtxoScript.ForfeitClosures() + if len(forfeitClosures) == 0 { + return "", nil, nil, nil, fmt.Errorf("no forfeit closures found") + } + forfeitClosure := forfeitClosures[0] + + forfeitScript, err := forfeitClosure.Script() + if err != nil { + return "", nil, nil, nil, err + } + + forfeitLeafHash := txscript.NewBaseTapLeaf(forfeitScript).TapHash() + + inputs = append(inputs, arkTxInput{coin, forfeitLeafHash}) + } + + arkTx, checkpointTxs, err := buildOffchainTx( + inputs, receivers, checkpointExitPath, args.ServerInfo.Dust, + ) + if err != nil { + return "", nil, nil, nil, err + } + + return arkTx, checkpointTxs, selectedCoins, changeReceiver, nil +} + +// submitAndFinalize submits the signed ark + unsigned checkpoint txs to the server, +// verifies the server's counter-signatures, and finalizes by sending the fully signed checkpoints. +// Returns the final ark txid, the fully-signed ark tx, and checkpoint txs. +// Shared by every orchestrator (Send, IssueAsset, ReissueAsset, BurnAsset). +func submitAndFinalize( + ctx context.Context, c clientlib.Client, signTx clientlib.SignFn, + signerPubKey *btcec.PublicKey, build *BuildAndSignTxRes, +) (string, string, []string, error) { + arkTxid, signedArk, signedCps, err := c.SubmitTx( + ctx, build.SignedArkTx, build.CheckpointTxs, + ) + if err != nil { + return "", "", nil, err + } + + if err := VerifySignedTx(build.ArkTx, signedArk, signerPubKey); err != nil { + return "", "", nil, err + } + if err := VerifySignedCheckpointTxs(build.CheckpointTxs, signedCps, signerPubKey); err != nil { + return "", "", nil, err + } + + txid, finalCps, err := finalizeTx(ctx, c, signTx, clientlib.AcceptedOffchainTx{ + Txid: arkTxid, + FinalArkTx: signedArk, + SignedCheckpointTxs: signedCps, + }) + if err != nil { + return "", "", nil, err + } + + return txid, signedArk, finalCps, nil +} + +// finalizeTx signs the server-returned checkpoint txs with signTx, calls +// FinalizeTx on the client, and returns the ark txid plus the finalized +// checkpoint txs. +func finalizeTx( + ctx context.Context, c clientlib.Client, signTx clientlib.SignFn, + acceptedTx clientlib.AcceptedOffchainTx, +) (string, []string, error) { + finalCheckpoints := make([]string, 0, len(acceptedTx.SignedCheckpointTxs)) + + for _, checkpoint := range acceptedTx.SignedCheckpointTxs { + signedTx, err := signTx(ctx, checkpoint) + if err != nil { + return "", nil, err + } + finalCheckpoints = append(finalCheckpoints, signedTx) + } + + if err := c.FinalizeTx(ctx, acceptedTx.Txid, finalCheckpoints); err != nil { + return "", nil, err + } + + return acceptedTx.Txid, finalCheckpoints, nil +} + +// parsePubkey converts a hex-encoded pubkey to a btcec.PublicKey. +func parsePubkey(pubkey string) (*btcec.PublicKey, error) { + buf, err := hex.DecodeString(pubkey) + if err != nil { + return nil, err + } + return btcec.ParsePubKey(buf) +} diff --git a/pkg/client-lib/send_opts_test.go b/pkg/client-lib/offchain-tx/utils_test.go similarity index 68% rename from pkg/client-lib/send_opts_test.go rename to pkg/client-lib/offchain-tx/utils_test.go index 89d8aef12..f3f11db27 100644 --- a/pkg/client-lib/send_opts_test.go +++ b/pkg/client-lib/offchain-tx/utils_test.go @@ -1,4 +1,4 @@ -package wallet +package offchaintx import ( "bytes" @@ -13,97 +13,18 @@ import ( "github.com/stretchr/testify/require" ) -// sampleTapTreeBytes is a BIP-371-encoded tap tree taken from the BIP-371 -// test vector; used as a known-good blob across tap-tree tests. -const sampleTapTreeHex = "01c02220736e572900fe1252589a2143c8f3c79f71a0412d2353af755e9701c782694a02ac" - var ( // anchorMarkerKey / anchorMarkerValue stamp a distinctive Unknowns entry on // the P2A anchor's POutput so tests can verify ptx.Outputs[i] stays aligned // with ptx.UnsignedTx.TxOut[i] across addExtension. anchorMarkerKey = []byte{0xaa, 0xbb} anchorMarkerValue = []byte{0xde, 0xad, 0xbe, 0xef} - // receiverPkScript is a stand-in segwit-v1-shaped pkScript: 0x51 0x20 <32B>. // Two distinct values let the apply tests target specific outputs by hex key. receiverPkScriptA = append([]byte{0x51, 0x20}, bytes.Repeat([]byte{0xa1}, 32)...) receiverPkScriptB = append([]byte{0x51, 0x20}, bytes.Repeat([]byte{0xb2}, 32)...) ) -func TestWithExtraPacket(t *testing.T) { - t.Run("invalid", func(t *testing.T) { - testCases := []struct { - name string - packets []extension.Packet - expectErrorContains string - }{ - { - name: "rejects type 0x00", - packets: []extension.Packet{ - extension.UnknownPacket{PacketType: asset.PacketType, Data: []byte{0x01}}, - }, - expectErrorContains: "reserved", - }, - { - name: "rejects nil packet", - packets: []extension.Packet{nil}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - opts := newDefaultSendOptions() - err := WithExtraPacket(tc.packets...).applySend(opts) - require.Error(t, err) - if tc.expectErrorContains != "" { - require.Contains(t, err.Error(), tc.expectErrorContains) - } - require.Empty(t, opts.extraPackets) - }) - } - }) - - t.Run("valid", func(t *testing.T) { - p1 := extension.UnknownPacket{PacketType: 0x03, Data: []byte{0xde, 0xad}} - p2 := extension.UnknownPacket{PacketType: 0x04, Data: []byte{0xbe, 0xef}} - p1A := extension.UnknownPacket{PacketType: 0x03, Data: []byte{0x01}} - p2A := extension.UnknownPacket{PacketType: 0x04, Data: []byte{0x02}} - - testCases := []struct { - name string - applyPackets [][]extension.Packet - expectTypes []uint8 - }{ - { - name: "appends valid packets", - applyPackets: [][]extension.Packet{[]extension.Packet{p1, p2}}, - expectTypes: []uint8{0x03, 0x04}, - }, - { - name: "multiple calls accumulate", - applyPackets: [][]extension.Packet{ - []extension.Packet{p1A}, - []extension.Packet{p2A}, - }, - expectTypes: []uint8{0x03, 0x04}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - opts := newDefaultSendOptions() - for _, callPackets := range tc.applyPackets { - require.NoError(t, WithExtraPacket(callPackets...).applySend(opts)) - } - require.Len(t, opts.extraPackets, len(tc.expectTypes)) - for i, wantType := range tc.expectTypes { - require.Equal(t, wantType, opts.extraPackets[i].Type()) - } - }) - } - }) -} - // TestAddExtension exercises the refactored addExtension helper. It covers // the no-op, asset-only, asset+extra, extras-only, duplicate detection, and // nil-packet cases, and asserts that the resulting PSBT's output layout has @@ -268,107 +189,11 @@ func TestAddExtension(t *testing.T) { }) } -func TestWithTxOutsTaprootTree(t *testing.T) { - t.Run("valid", func(t *testing.T) { - t.Run("populates state and defensively copies values", func(t *testing.T) { - tree := sampleTapTreeBytes(t) - caller := map[string][]byte{"abcd": tree} - - opts := newDefaultSendOptions() - require.NoError(t, WithTxOutsTaprootTree(caller).applySend(opts)) - - stored, ok := opts.outputsTapTree["abcd"] - require.True(t, ok) - require.Equal(t, tree, stored) - - // mutating the caller's slice must not leak into the stored copy - tree[0] ^= 0xff - require.NotEqual(t, tree[0], stored[0]) - }) - - t.Run("multiple calls merge keys", func(t *testing.T) { - opts := newDefaultSendOptions() - require.NoError(t, WithTxOutsTaprootTree(map[string][]byte{ - "aa": sampleTapTreeBytes(t), - }).applySend(opts)) - require.NoError(t, WithTxOutsTaprootTree(map[string][]byte{ - "bb": sampleTapTreeBytes(t), - }).applySend(opts)) - - require.Len(t, opts.outputsTapTree, 2) - require.Contains(t, opts.outputsTapTree, "aa") - require.Contains(t, opts.outputsTapTree, "bb") - }) - - t.Run("later call overwrites same key", func(t *testing.T) { - first := encodedTapTree(t, - "20736e572900fe1252589a2143c8f3c79f71a0412d2353af755e9701c782694a02ac", - ) - second := encodedTapTree(t, - "20631c5f3b5832b8fbdebfb19704ceeb323c21f40f7a24f43d68ef0cc26b125969ac", - ) - - opts := newDefaultSendOptions() - require.NoError(t, WithTxOutsTaprootTree( - map[string][]byte{"aa": first}, - ).applySend(opts)) - require.NoError(t, WithTxOutsTaprootTree( - map[string][]byte{"aa": second}, - ).applySend(opts)) - - require.Equal(t, second, opts.outputsTapTree["aa"]) - }) - }) - - t.Run("invalid", func(t *testing.T) { - validTree := sampleTapTreeBytes(t) - testCases := []struct { - name string - input map[string][]byte - expectErrorContains string - }{ - { - name: "missing trees", - input: map[string][]byte{}, - expectErrorContains: "missing taproot trees", - }, - { - name: "empty tree", - input: map[string][]byte{"deadbeef": {}}, - expectErrorContains: "must not be empty", - }, - { - name: "malformed bip-371 tree", - // Header advertises a 0xff-byte script but no payload follows. - input: map[string][]byte{"deadbeef": {0x01, 0xc0, 0xff}}, - expectErrorContains: "invalid bip-371 tap tree", - }, - { - name: "many trees with one invalid", - input: map[string][]byte{ - "aa": validTree, - "bb": {}, - }, - expectErrorContains: "must not be empty", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - opts := newDefaultSendOptions() - err := WithTxOutsTaprootTree(tc.input).applySend(opts) - require.Error(t, err) - require.Contains(t, err.Error(), tc.expectErrorContains) - }) - } - }) -} - -func TestApplyOutputTapTrees(t *testing.T) { +func TestAddOutputsTaprootTree(t *testing.T) { t.Run("no-op when empty", func(t *testing.T) { ptx := newTestPsbtWithReceiversAndAnchor(t) - require.NoError(t, applyOutputTapTrees(ptx, nil)) - require.NoError(t, applyOutputTapTrees(ptx, map[string][]byte{})) + require.NoError(t, addOutputsTaprootTree(ptx, nil)) + require.NoError(t, addOutputsTaprootTree(ptx, map[string][]byte{})) for _, po := range ptx.Outputs { require.Empty(t, po.TaprootTapTree) } @@ -378,7 +203,7 @@ func TestApplyOutputTapTrees(t *testing.T) { ptx := newTestPsbtWithReceiversAndAnchor(t) tree := sampleTapTreeBytes(t) - err := applyOutputTapTrees(ptx, map[string][]byte{ + err := addOutputsTaprootTree(ptx, map[string][]byte{ hex.EncodeToString(receiverPkScriptA): tree, }) require.NoError(t, err) @@ -397,7 +222,7 @@ func TestApplyOutputTapTrees(t *testing.T) { "2044faa49a0338de488c8dfffecdfb6f329f380bd566ef20c8df6d813eab1c4273ac", ) - err := applyOutputTapTrees(ptx, map[string][]byte{ + err := addOutputsTaprootTree(ptx, map[string][]byte{ hex.EncodeToString(receiverPkScriptA): treeA, hex.EncodeToString(receiverPkScriptB): treeB, }) @@ -411,7 +236,7 @@ func TestApplyOutputTapTrees(t *testing.T) { ptx := newTestPsbtWithReceiversAndAnchor(t) bogus := hex.EncodeToString(bytes.Repeat([]byte{0xcc}, 34)) - err := applyOutputTapTrees(ptx, map[string][]byte{ + err := addOutputsTaprootTree(ptx, map[string][]byte{ hex.EncodeToString(receiverPkScriptA): sampleTapTreeBytes(t), bogus: sampleTapTreeBytes(t), }) @@ -430,7 +255,7 @@ func TestApplyOutputTapTrees(t *testing.T) { // Anchor moved to last; receivers should still be findable by pkScript. tree := sampleTapTreeBytes(t) - err := applyOutputTapTrees(ptx, map[string][]byte{ + err := addOutputsTaprootTree(ptx, map[string][]byte{ hex.EncodeToString(receiverPkScriptA): tree, hex.EncodeToString(receiverPkScriptB): tree, }) @@ -460,7 +285,7 @@ func TestApplyOutputTapTrees(t *testing.T) { // Desync UnsignedTx.TxOut from Outputs to simulate a malformed PSBT. ptx.Outputs = ptx.Outputs[:len(ptx.Outputs)-1] - err := applyOutputTapTrees(ptx, map[string][]byte{ + err := addOutputsTaprootTree(ptx, map[string][]byte{ hex.EncodeToString(receiverPkScriptA): sampleTapTreeBytes(t), }) require.Error(t, err) diff --git a/pkg/client-lib/offchain-tx/verify.go b/pkg/client-lib/offchain-tx/verify.go new file mode 100644 index 000000000..555a530cc --- /dev/null +++ b/pkg/client-lib/offchain-tx/verify.go @@ -0,0 +1,59 @@ +package offchaintx + +import ( + "fmt" + "strings" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" +) + +func VerifySignedCheckpointTxs( + originalCheckpoints, signedCheckpoints []string, signerpubkey *btcec.PublicKey, +) error { + // index by txid + indexedOriginalCheckpoints := make(map[string]*psbt.Packet) + indexedSignedCheckpoints := make(map[string]*psbt.Packet) + + for _, cp := range originalCheckpoints { + originalPtx, err := psbt.NewFromRawBytes(strings.NewReader(cp), true) + if err != nil { + return err + } + indexedOriginalCheckpoints[originalPtx.UnsignedTx.TxID()] = originalPtx + } + + for _, cp := range signedCheckpoints { + signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(cp), true) + if err != nil { + return err + } + indexedSignedCheckpoints[signedPtx.UnsignedTx.TxID()] = signedPtx + } + + for txid, originalPtx := range indexedOriginalCheckpoints { + signedPtx, ok := indexedSignedCheckpoints[txid] + if !ok { + return fmt.Errorf("signed checkpoint %s not found", txid) + } + if err := verifyOffchainTx(originalPtx, signedPtx, signerpubkey); err != nil { + return err + } + } + + return nil +} + +func VerifySignedTx(original, signed string, signerPubKey *btcec.PublicKey) error { + originalPtx, err := psbt.NewFromRawBytes(strings.NewReader(original), true) + if err != nil { + return err + } + + signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(signed), true) + if err != nil { + return err + } + + return verifyOffchainTx(originalPtx, signedPtx, signerPubKey) +} diff --git a/pkg/client-lib/offchain-tx/verify_test.go b/pkg/client-lib/offchain-tx/verify_test.go new file mode 100644 index 000000000..103c45415 --- /dev/null +++ b/pkg/client-lib/offchain-tx/verify_test.go @@ -0,0 +1,147 @@ +package offchaintx + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// newTestVerifyPSBT builds a minimal base64-encoded PSBT with one input +// referencing the given outpoint plus the supplied witness data on its input +// and an attached TaprootLeafScript. It is used by the verify tests to +// exercise the structural paths of VerifySignedTx / +// VerifySignedCheckpointTxs without crafting actual Schnorr signatures. +func newTestVerifyPSBT( + t *testing.T, prevTxidHex string, prevVOut uint32, withLeafScript, withWitnessUtxo bool, +) string { + t.Helper() + tx := wire.NewMsgTx(2) + hash, err := chainhash.NewHashFromStr(prevTxidHex) + require.NoError(t, err) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: *wire.NewOutPoint(hash, prevVOut), + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(wire.NewTxOut(330, []byte{0x51, 0x02, 0x4e, 0x73})) + + ptx, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + if withWitnessUtxo { + ptx.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 1000, + PkScript: []byte{0x51, 0x02, 0x4e, 0x73}, + } + } + if withLeafScript { + // A minimal-but-valid control block: leaf version byte + 32-byte + // internal pubkey. The internal pubkey value is arbitrary because the + // verify path that consumes it ("missing signer signature") never + // reaches actual script execution. + controlBlock := make([]byte, 33) + controlBlock[0] = byte(txscript.BaseLeafVersion) + for i := 1; i < 33; i++ { + controlBlock[i] = 0x01 + } + ptx.Inputs[0].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: controlBlock, + Script: []byte{0x51}, + LeafVersion: txscript.BaseLeafVersion, + }} + } + + encoded, err := ptx.B64Encode() + require.NoError(t, err) + return encoded +} + +// signerPubKey parses the canonical testSignerPubKey constant. +func signerPubKey(t *testing.T) *btcec.PublicKey { + t.Helper() + key, err := parsePubkey(testSignerPubKey) + require.NoError(t, err) + return key +} + +func TestVerifySignedTx(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + txidA := "1111111111111111111111111111111111111111111111111111111111111111" + txidB := "2222222222222222222222222222222222222222222222222222222222222222" + + validOriginal := newTestVerifyPSBT(t, txidA, 0, true, true) + validSigned := newTestVerifyPSBT(t, txidA, 0, true, true) + differentTxid := newTestVerifyPSBT(t, txidB, 0, true, true) + + tests := []struct { + name string + original string + signed string + errSubstr string + }{ + {"bad original psbt", "!!not-psbt", validSigned, ""}, + {"bad signed psbt", validOriginal, "!!not-psbt", ""}, + {"txid mismatch", validOriginal, differentTxid, "txids mismatch"}, + {"missing signer signature", validOriginal, validSigned, "signer signature not found"}, + } + + pubKey := signerPubKey(t) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := VerifySignedTx(tc.original, tc.signed, pubKey) + require.Error(t, err) + if tc.errSubstr != "" { + require.Contains(t, err.Error(), tc.errSubstr) + } + }) + } + }) +} + +func TestVerifySignedCheckpointTxs(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + txidA := "1111111111111111111111111111111111111111111111111111111111111111" + txidB := "2222222222222222222222222222222222222222222222222222222222222222" + + validOriginal := newTestVerifyPSBT(t, txidA, 0, true, true) + differentTxid := newTestVerifyPSBT(t, txidB, 0, true, true) + + tests := []struct { + name string + original []string + signed []string + errSubstr string + }{ + { + name: "bad original element", + original: []string{"!!not-psbt"}, + signed: []string{validOriginal}, + }, + { + name: "bad signed element", + original: []string{validOriginal}, + signed: []string{"!!not-psbt"}, + }, + { + name: "signed checkpoint missing for original txid", + original: []string{validOriginal}, + signed: []string{differentTxid}, + errSubstr: "not found", + }, + } + + pubKey := signerPubKey(t) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := VerifySignedCheckpointTxs(tc.original, tc.signed, pubKey) + require.Error(t, err) + if tc.errSubstr != "" { + require.Contains(t, err.Error(), tc.errSubstr) + } + }) + } + }) +} diff --git a/pkg/client-lib/receiver_opts.go b/pkg/client-lib/receiver_opts.go deleted file mode 100644 index e3a1830d4..000000000 --- a/pkg/client-lib/receiver_opts.go +++ /dev/null @@ -1,115 +0,0 @@ -package wallet - -import ( - "fmt" - - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/btcsuite/btcd/btcutil" -) - -// ReceiverOption is the intersection of every option family that accepts a -// destination/change address override. A value satisfying ReceiverOption can -// be passed to any method taking SendOption, BatchSessionOption, or -// UnrollOption — so WithReceiver is defined once here instead of duplicated -// per family. See SignOption in sign_opts.go for the same pattern. -type ReceiverOption interface { - SendOption - BatchSessionOption - UnrollOption -} - -type receiverOpt struct { - addr string -} - -func (r receiverOpt) applySend(o *sendOptions) error { - if r.addr == "" { - return fmt.Errorf("missing receiver address") - } - if o.receiver != "" { - return fmt.Errorf("receiver already set") - } - o.receiver = r.addr - return nil -} - -func (r receiverOpt) applyBatch(o *batchSessionOptions) error { - if r.addr == "" { - return fmt.Errorf("missing receiver address") - } - if o.receiver != "" { - return fmt.Errorf("receiver already set") - } - o.receiver = r.addr - return nil -} - -func (r receiverOpt) applyUnroll(o *unrollOptions) error { - if r.addr == "" { - return fmt.Errorf("missing receiver address") - } - if o.receiver != "" { - return fmt.Errorf("receiver already set") - } - o.receiver = r.addr - return nil -} - -// WithReceiver overrides the destination/change address that the method would -// otherwise freshly derive via identity.NewKey. Accepts an offchain ark address -// or an onchain bitcoin address; the consuming method validates which kinds -// are permitted (e.g. SendOffChain requires offchain; OnboardAgainAllExpiredBoardings -// requires onchain; Settle / CollaborativeExit accept either). -// -// Note: directing change to a known address weakens unlinkability — caller's -// choice. Skipping the identity.NewKey call also means no new key is recorded in -// the wallet for the change output. -func WithReceiver(addr string) ReceiverOption { - return receiverOpt{addr: addr} -} - -// validateOffchainAddress rejects everything that is not a valid offchain ark -// address. Used by methods whose receiver MUST be a vtxo destination -// (SendOffChain change, asset ops, RedeemNotes). -func validateOffchainAddress(addr string) error { - if addr == "" { - return fmt.Errorf("missing receiver address") - } - if _, err := arklib.DecodeAddressV0(addr); err != nil { - return fmt.Errorf("invalid offchain receiver address: %w", err) - } - return nil -} - -// validateOnchainAddress rejects everything that is not a valid onchain -// bitcoin address on the given network. Used by OnboardAgainAllExpiredBoardings. -func validateOnchainAddress(addr string, network arklib.Network) error { - if addr == "" { - return fmt.Errorf("missing receiver address") - } - netParams := utils.ToBitcoinNetwork(network) - if _, err := btcutil.DecodeAddress(addr, &netParams); err != nil { - return fmt.Errorf("invalid onchain receiver address: %w", err) - } - return nil -} - -// validateOffchainOrOnchainAddress accepts either an ark offchain address or -// a bitcoin onchain address on the given network. Used by Settle / -// CollaborativeExit, where batch-session outputs may legally be either. -func validateOffchainOrOnchainAddress(addr string, network arklib.Network) error { - if addr == "" { - return fmt.Errorf("missing receiver address") - } - if _, offErr := arklib.DecodeAddressV0(addr); offErr == nil { - return nil - } - netParams := utils.ToBitcoinNetwork(network) - if _, onErr := btcutil.DecodeAddress(addr, &netParams); onErr == nil { - return nil - } - return fmt.Errorf( - "invalid receiver address: not a valid offchain or onchain bitcoin address", - ) -} diff --git a/pkg/client-lib/receiver_opts_test.go b/pkg/client-lib/receiver_opts_test.go deleted file mode 100644 index 7c23536d8..000000000 --- a/pkg/client-lib/receiver_opts_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package wallet - -import ( - "testing" - - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/stretchr/testify/require" -) - -func TestWithReceiver(t *testing.T) { - const addr = "tark1qfaketestaddressgoesherenoadditionalvalidationhere" - - t.Run("invalid", func(t *testing.T) { - t.Run("rejects empty addr - sendOptions", func(t *testing.T) { - opts := newDefaultSendOptions() - err := WithReceiver("").applySend(opts) - require.Error(t, err) - require.Contains(t, err.Error(), "missing") - require.Empty(t, opts.receiver) - }) - - t.Run("rejects empty addr - batchSessionOptions", func(t *testing.T) { - opts := newDefaultSettleOptions() - err := WithReceiver("").applyBatch(opts) - require.Error(t, err) - require.Contains(t, err.Error(), "missing") - require.Empty(t, opts.receiver) - }) - - t.Run("rejects empty addr - unrollOptions", func(t *testing.T) { - opts := newDefaultUnrollOptions() - err := WithReceiver("").applyUnroll(opts) - require.Error(t, err) - require.Contains(t, err.Error(), "missing") - require.Empty(t, opts.receiver) - }) - - t.Run("rejects double-set - sendOptions", func(t *testing.T) { - opts := newDefaultSendOptions() - require.NoError(t, WithReceiver(addr).applySend(opts)) - err := WithReceiver(addr).applySend(opts) - require.Error(t, err) - require.Contains(t, err.Error(), "already set") - }) - - t.Run("rejects double-set - batchSessionOptions", func(t *testing.T) { - opts := newDefaultSettleOptions() - require.NoError(t, WithReceiver(addr).applyBatch(opts)) - err := WithReceiver(addr).applyBatch(opts) - require.Error(t, err) - require.Contains(t, err.Error(), "already set") - }) - - t.Run("rejects double-set - unrollOptions", func(t *testing.T) { - opts := newDefaultUnrollOptions() - require.NoError(t, WithReceiver(addr).applyUnroll(opts)) - err := WithReceiver(addr).applyUnroll(opts) - require.Error(t, err) - require.Contains(t, err.Error(), "already set") - }) - }) - - t.Run("valid", func(t *testing.T) { - t.Run("stores addr - sendOptions", func(t *testing.T) { - opts := newDefaultSendOptions() - require.NoError(t, WithReceiver(addr).applySend(opts)) - require.Equal(t, addr, opts.receiver) - }) - - t.Run("stores addr - batchSessionOptions", func(t *testing.T) { - opts := newDefaultSettleOptions() - require.NoError(t, WithReceiver(addr).applyBatch(opts)) - require.Equal(t, addr, opts.receiver) - }) - - t.Run("stores addr - unrollOptions", func(t *testing.T) { - opts := newDefaultUnrollOptions() - require.NoError(t, WithReceiver(addr).applyUnroll(opts)) - require.Equal(t, addr, opts.receiver) - }) - - t.Run("usable as SendOption", func(t *testing.T) { - var _ SendOption = WithReceiver(addr) - }) - - t.Run("usable as BatchSessionOption", func(t *testing.T) { - var _ BatchSessionOption = WithReceiver(addr) - }) - - t.Run("usable as UnrollOption", func(t *testing.T) { - var _ UnrollOption = WithReceiver(addr) - }) - }) -} - -func TestValidateOffchainAddress(t *testing.T) { - t.Run("rejects empty", func(t *testing.T) { - err := validateOffchainAddress("") - require.Error(t, err) - }) - t.Run("rejects malformed", func(t *testing.T) { - err := validateOffchainAddress("not-an-address") - require.Error(t, err) - }) - t.Run("rejects onchain bitcoin address", func(t *testing.T) { - // regtest p2tr-style — should fail offchain decoding (HRP not in allowed set) - err := validateOffchainAddress( - "bcrt1pqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2skvg4", - ) - require.Error(t, err) - }) -} - -func TestValidateOnchainAddress(t *testing.T) { - t.Run("rejects empty", func(t *testing.T) { - err := validateOnchainAddress("", arklib.BitcoinRegTest) - require.Error(t, err) - }) - t.Run("rejects malformed", func(t *testing.T) { - err := validateOnchainAddress("not-an-address", arklib.BitcoinRegTest) - require.Error(t, err) - }) -} - -func TestValidateOffchainOrOnchainAddress(t *testing.T) { - t.Run("rejects empty", func(t *testing.T) { - err := validateOffchainOrOnchainAddress("", arklib.BitcoinRegTest) - require.Error(t, err) - }) - t.Run("rejects malformed", func(t *testing.T) { - err := validateOffchainOrOnchainAddress("not-an-address", arklib.BitcoinRegTest) - require.Error(t, err) - }) -} diff --git a/pkg/client-lib/send.go b/pkg/client-lib/send.go deleted file mode 100644 index dd4cec65d..000000000 --- a/pkg/client-lib/send.go +++ /dev/null @@ -1,551 +0,0 @@ -package wallet - -import ( - "bytes" - "context" - "encoding/hex" - "fmt" - "math" - "slices" - "sort" - "strings" - "time" - - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/ark-lib/extension" - "github.com/arkade-os/arkd/pkg/ark-lib/script" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/txscript" - log "github.com/sirupsen/logrus" -) - -func (a *service) SendOffChain( - ctx context.Context, receivers []types.Receiver, opts ...SendOption, -) (*SendOffChainRes, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - - o := newDefaultSendOptions() - for _, opt := range opts { - if err := opt.applySend(o); err != nil { - return nil, err - } - } - - a.txLock.Lock() - defer a.txLock.Unlock() - - baseArkTx, checkpointTxs, selectedCoins, changeReceiver, err := a.createOffchainTx( - ctx, receivers, o, - ) - if err != nil { - return nil, err - } - - arkPtx, err := psbt.NewFromRawBytes(strings.NewReader(baseArkTx), true) - if err != nil { - return nil, err - } - - assetPacket, err := createAssetPacket( - selectedCoinsToAssetInputs(selectedCoins), receivers, changeReceiver, - ) - if err != nil { - return nil, err - } - - if err := addExtension(arkPtx, assetPacket, o.extraPackets); err != nil { - return nil, err - } - - if err := applyOutputTapTrees(arkPtx, o.outputsTapTree); err != nil { - return nil, err - } - - arkTx, err := arkPtx.B64Encode() - if err != nil { - return nil, err - } - - signedArkTx, err := a.identity.SignTransaction(ctx, arkTx, o.signingKeys) - if err != nil { - return nil, err - } - - arkTxid, signedArkTx, signedCheckpointTxs, err := a.client.SubmitTx( - ctx, signedArkTx, checkpointTxs, - ) - if err != nil { - return nil, err - } - - // validate and verify transactions returned by the server - if err := verifySignedArk(arkTx, signedArkTx, a.SignerPubKey); err != nil { - return nil, err - } - - if err := verifySignedCheckpoints(checkpointTxs, signedCheckpointTxs, a.SignerPubKey); err != nil { - return nil, err - } - - txid, checkpointTxs, err := a.finalizeTx(ctx, client.AcceptedOffchainTx{ - Txid: arkTxid, - FinalArkTx: signedArkTx, - SignedCheckpointTxs: signedCheckpointTxs, - }, o.signingKeys) - if err != nil { - return nil, err - } - - ins := make([]types.Vtxo, 0, len(selectedCoins)) - for _, c := range selectedCoins { - ins = append(ins, c.Vtxo) - } - outs := make([]types.Receiver, 0) - if changeReceiver != nil { - outs = append(outs, *changeReceiver) - } - - ext := make(extension.Extension, 0, 1+len(o.extraPackets)) - if len(assetPacket) > 0 { - ext = append(ext, assetPacket) - } - ext = append(ext, o.extraPackets...) - - return &SendOffChainRes{ - Txid: txid, - Tx: signedArkTx, - Checkpoints: checkpointTxs, - Inputs: ins, - Outputs: outs, - Extension: ext, - }, nil -} - -func (a *service) FinalizePendingTxs( - ctx context.Context, createdAfter *time.Time, opts ...SendOption, -) ([]string, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - - o := newDefaultSendOptions() - for _, opt := range opts { - if err := opt.applySend(o); err != nil { - return nil, err - } - } - - return a.finalizePendingTxs(ctx, createdAfter, o.vtxos, o.signingKeys) -} - -func (a *service) createOffchainTx( - ctx context.Context, receivers []types.Receiver, opts *sendOptions, -) (string, []string, []types.VtxoWithTapTree, *types.Receiver, error) { - if len(receivers) <= 0 { - return "", nil, nil, nil, fmt.Errorf("missing receivers") - } - - expectedSignerPubkey := schnorr.SerializePubKey(a.SignerPubKey) - - for _, receiver := range receivers { - if receiver.IsOnchain() { - return "", nil, nil, nil, fmt.Errorf( - "all receiver addresses must be offchain addresses", - ) - } - - addr, err := arklib.DecodeAddressV0(receiver.To) - if err != nil { - return "", nil, nil, nil, fmt.Errorf("invalid receiver address: %s", err) - } - - rcvSignerPubkey := schnorr.SerializePubKey(addr.Signer) - if !bytes.Equal(expectedSignerPubkey, rcvSignerPubkey) { - return "", nil, nil, nil, fmt.Errorf( - "invalid receiver address '%s': expected signer pubkey %x, got %x", - receiver.To, expectedSignerPubkey, rcvSignerPubkey, - ) - } - } - - vtxos := make([]types.VtxoWithTapTree, 0) - if len(opts.vtxos) > 0 { - vtxos = slices.Clone(opts.vtxos) - } else { - _, offchainAddrs, _, _, err := a.getAddresses(ctx) - if err != nil { - return "", nil, nil, nil, err - } - if len(offchainAddrs) == 0 { - return "", nil, nil, nil, fmt.Errorf("no offchain addresses") - } - - spendableVtxos, err := a.getSpendableVtxos(ctx, &getVtxosFilter{ - withoutExpirySorting: opts.withoutExpirySorting, - }) - if err != nil { - return "", nil, nil, nil, err - } - - for _, offchainAddr := range offchainAddrs { - for _, v := range spendableVtxos { - if v.IsRecoverable() { - continue - } - - vtxoAddr, err := v.Address(a.SignerPubKey, a.Network) - if err != nil { - return "", nil, nil, nil, err - } - - if vtxoAddr == offchainAddr.Address { - vtxos = append(vtxos, types.VtxoWithTapTree{ - Vtxo: v, - Tapscripts: offchainAddr.Tapscripts, - }) - } - } - } - } - - btcAmountToSelect := int64(0) - selectedCoins := make([]types.VtxoWithTapTree, 0) - assetChanges := make(map[string]uint64) - selectedVtxos := make(map[string]bool) - - for _, receiver := range receivers { - btcAmountToSelect += int64(receiver.Amount) - - if len(receiver.Assets) > 0 { - for _, asset := range receiver.Assets { - amountToSelect := asset.Amount - existingChangeAmount := assetChanges[asset.AssetId] - if existingChangeAmount > 0 { - if amountToSelect <= existingChangeAmount { - // change covers the needed amount, no need to select any more coins - assetChanges[asset.AssetId] -= amountToSelect - if assetChanges[asset.AssetId] == 0 { - delete(assetChanges, asset.AssetId) - } - continue - } else { - // change does not cover the needed amount, select the remaining amount - amountToSelect -= existingChangeAmount - delete(assetChanges, asset.AssetId) - } - } - - availableVtxos := make([]types.VtxoWithTapTree, 0, len(vtxos)) - for _, v := range vtxos { - if !selectedVtxos[v.Outpoint.String()] { - availableVtxos = append(availableVtxos, v) - } - } - - assetCoins, assetChangeAmount, err := utils.CoinSelectAsset( - availableVtxos, amountToSelect, asset.AssetId, opts.withoutExpirySorting, - ) - if err != nil { - return "", nil, nil, nil, err - } - - for _, coin := range assetCoins { - coinID := coin.Outpoint.String() - selectedVtxos[coinID] = true - selectedCoins = append(selectedCoins, coin) - - // asset coins contain btc, subtract it from the total amount to select - btcAmountToSelect -= int64(coin.Amount) - - // coin may contain other assets, add them to the asset changes - for _, a := range coin.Assets { - if a.AssetId == asset.AssetId { - continue - } - assetChanges[a.AssetId] += a.Amount - } - } - if assetChangeAmount > 0 { - assetChanges[asset.AssetId] += assetChangeAmount - } - } - } - } - - changeAmount := uint64(0) - - if btcAmountToSelect >= 0 { - isZero := btcAmountToSelect == 0 - - // filter out already-selected vtxos - availableVtxos := make([]types.VtxoWithTapTree, 0, len(vtxos)) - for _, v := range vtxos { - if !selectedVtxos[v.Outpoint.String()] { - availableVtxos = append(availableVtxos, v) - } - } - - // skip BTC coin selection if all BTC was covered by asset coins - // and there are no more available vtxos (send-all scenario) - if isZero && len(availableVtxos) == 0 { - changeAmount = 0 - } else { - if isZero { - btcAmountToSelect = int64(a.Dust) - } - - _, selectedBtcCoins, changeBtcAmount, err := utils.CoinSelect( - nil, availableVtxos, - // use a "fake" receiver to select only the remaining btc amount - // it works for offchain tx because feeEstimator is nil (no offchain fee) - []types.Receiver{{Amount: uint64(btcAmountToSelect)}}, - a.Dust, opts.withoutExpirySorting, nil, - ) - if err != nil { - return "", nil, nil, nil, err - } - - // some coins may contain assets, add them to the asset changes - for _, coin := range selectedBtcCoins { - for _, asset := range coin.Assets { - if asset.Amount > 0 { - assetChanges[asset.AssetId] += asset.Amount - } - } - } - - selectedCoins = append(selectedCoins, selectedBtcCoins...) - changeAmount = changeBtcAmount - if isZero { - changeAmount = changeBtcAmount + a.Dust - } - } - } else { - changeAmount = uint64(math.Abs(float64(btcAmountToSelect))) - } - - var changeReceiver *types.Receiver - - // enforce a minimum change amount when there are asset changes - if len(assetChanges) > 0 && changeAmount == 0 { - // build a set of already-selected coin outpoints to avoid double-selection - selectedOutpoints := make(map[string]struct{}) - for _, coin := range selectedCoins { - selectedOutpoints[coin.Txid+fmt.Sprintf(":%d", coin.VOut)] = struct{}{} - } - - availableVtxos := make([]types.VtxoWithTapTree, 0) - for _, vtxo := range vtxos { - outpoint := vtxo.Outpoint.String() - if _, selected := selectedOutpoints[outpoint]; selected { - continue - } - // only include vtxos without assets - if len(vtxo.Assets) == 0 { - availableVtxos = append(availableVtxos, vtxo) - } - } - - _, selectedBtcCoins, changeBtcAmount, err := utils.CoinSelect( - nil, availableVtxos, []types.Receiver{{Amount: a.Dust}}, - a.Dust, opts.withoutExpirySorting, nil, - ) - if err != nil { - return "", nil, nil, nil, fmt.Errorf( - "failed to select coins for asset change output: %w", - err, - ) - } - - selectedCoins = append(selectedCoins, selectedBtcCoins...) - changeAmount = changeBtcAmount + a.Dust - } - - if changeAmount > 0 { - addr, err := a.getReceiver(ctx, opts.receiver) - if err != nil { - return "", nil, nil, nil, err - } - - changeReceiver = &types.Receiver{ - To: addr, Amount: changeAmount, - } - if len(assetChanges) > 0 { - for assetID, amount := range assetChanges { - if amount > 0 { - changeReceiver.Assets = append(changeReceiver.Assets, types.Asset{ - AssetId: assetID, - Amount: amount, - }) - } - } - } - - receivers = append(receivers, *changeReceiver) - } - - inputs := make([]arkTxInput, 0, len(selectedCoins)) - - for _, coin := range selectedCoins { - vtxoScript, err := script.ParseVtxoScript(coin.Tapscripts) - if err != nil { - return "", nil, nil, nil, err - } - - forfeitClosures := vtxoScript.ForfeitClosures() - if len(forfeitClosures) == 0 { - return "", nil, nil, nil, fmt.Errorf("no forfeit closures found") - } - forfeitClosure := forfeitClosures[0] - - forfeitScript, err := forfeitClosure.Script() - if err != nil { - return "", nil, nil, nil, err - } - - forfeitLeafHash := txscript.NewBaseTapLeaf(forfeitScript).TapHash() - - inputs = append(inputs, arkTxInput{coin, forfeitLeafHash}) - } - - arkTx, checkpointTxs, err := buildOffchainTx(inputs, receivers, a.CheckpointExitPath(), a.Dust) - if err != nil { - return "", nil, nil, nil, err - } - - return arkTx, checkpointTxs, selectedCoins, changeReceiver, nil -} - -func (a *service) finalizePendingTxs( - ctx context.Context, createdAfter *time.Time, - vtxosWithTapscripts []types.VtxoWithTapTree, keysByScript map[string]string, -) ([]string, error) { - if len(vtxosWithTapscripts) <= 0 { - vtxos, err := a.fetchPendingSpentVtxos(ctx) - if err != nil { - return nil, err - } - vtxosWithTapscripts, err = a.populateVtxosWithTapscripts(ctx, vtxos) - if err != nil { - return nil, err - } - } - - filtered := make([]types.VtxoWithTapTree, 0, len(vtxosWithTapscripts)) - for _, vtxo := range vtxosWithTapscripts { - if createdAfter != nil && !createdAfter.IsZero() { - if !vtxo.CreatedAt.After(*createdAfter) { - continue - } - } - filtered = append(filtered, vtxo) - } - - if len(filtered) == 0 { - return nil, nil - } - - inputs, exitLeaves, arkFields, _, err := toIntentInputs(nil, filtered, nil) - if err != nil { - return nil, err - } - - txids := make([]string, 0) - const MAX_INPUTS_PER_INTENT = 20 - signingRequired := true - - for i := 0; i < len(inputs); i += MAX_INPUTS_PER_INTENT { - end := min(i+MAX_INPUTS_PER_INTENT, len(inputs)) - inputsSubset := inputs[i:end] - exitLeavesSubset := exitLeaves[i:end] - arkFieldsSubset := arkFields[i:end] - proofTx, message, err := a.makeGetPendingTxIntent( - inputsSubset, exitLeavesSubset, arkFieldsSubset, signingRequired, keysByScript, - ) - if err != nil { - return nil, err - } - - pendingTxs, err := a.client.GetPendingTx(ctx, proofTx, message) - if err != nil { - return nil, err - } - - for _, tx := range pendingTxs { - txid, _, err := a.finalizeTx(ctx, tx, keysByScript) - if err != nil { - log.WithError(err).Errorf("failed to finalize pending tx: %s", tx.Txid) - continue - } - txids = append(txids, txid) - } - } - - return txids, nil -} - -// applyOutputTapTrees sets the BIP-371 TaprootTapTree field on every PSBT -// output whose hex-encoded pkScript matches a key in byPkScript. An error is -// returned when a key matches no output: silently ignoring an unmatched key -// would let a caller think the tree was set on the wire while the PSBT goes -// out without it — a footgun in a VTXO-spending path. -func applyOutputTapTrees(ptx *psbt.Packet, taprootTrees map[string][]byte) error { - if len(taprootTrees) <= 0 { - return nil - } - if len(ptx.UnsignedTx.TxOut) != len(ptx.Outputs) { - return fmt.Errorf( - "output count mismatch: unsigned tx has %d outputs but ptx has %d", - len(ptx.UnsignedTx.TxOut), len(ptx.Outputs), - ) - } - matched := make(map[string]bool, len(taprootTrees)) - for i, out := range ptx.UnsignedTx.TxOut { - pkHex := hex.EncodeToString(out.PkScript) - tapTree, ok := taprootTrees[pkHex] - if !ok { - continue - } - ptx.Outputs[i].TaprootTapTree = tapTree - matched[pkHex] = true - } - if len(matched) == len(taprootTrees) { - return nil - } - unmatched := make([]string, 0, len(taprootTrees)-len(matched)) - for k := range taprootTrees { - if !matched[k] { - unmatched = append(unmatched, k) - } - } - sort.Strings(unmatched) - return fmt.Errorf( - "no matching output for pkScript(s): %s", strings.Join(unmatched, ", "), - ) -} - -func (a *service) finalizeTx( - ctx context.Context, acceptedTx client.AcceptedOffchainTx, keysByScript map[string]string, -) (string, []string, error) { - finalCheckpoints := make([]string, 0, len(acceptedTx.SignedCheckpointTxs)) - - for _, checkpoint := range acceptedTx.SignedCheckpointTxs { - signedTx, err := a.identity.SignTransaction(ctx, checkpoint, keysByScript) - if err != nil { - return "", nil, err - } - finalCheckpoints = append(finalCheckpoints, signedTx) - } - - if err := a.client.FinalizeTx(ctx, acceptedTx.Txid, finalCheckpoints); err != nil { - return "", nil, err - } - - return acceptedTx.Txid, finalCheckpoints, nil -} diff --git a/pkg/client-lib/send_opts.go b/pkg/client-lib/send_opts.go deleted file mode 100644 index 1679d6601..000000000 --- a/pkg/client-lib/send_opts.go +++ /dev/null @@ -1,96 +0,0 @@ -package wallet - -import ( - "fmt" - - "github.com/arkade-os/arkd/pkg/ark-lib/asset" - "github.com/arkade-os/arkd/pkg/ark-lib/extension" - "github.com/arkade-os/arkd/pkg/ark-lib/txutils" - "github.com/arkade-os/arkd/pkg/client-lib/types" -) - -// SendOption is satisfied by any value whose applySend method mutates a -// sendOptions. Interface-typed options let a single definition satisfy -// multiple option families — see WithKeys in sign_opts.go. -type SendOption interface { - applySend(*sendOptions) error -} - -type sendOptFn func(*sendOptions) error - -func (f sendOptFn) applySend(o *sendOptions) error { return f(o) } - -func WithoutExpirySorting() SendOption { - return sendOptFn(func(o *sendOptions) error { - o.withoutExpirySorting = true - return nil - }) -} - -// WithExtraPacket appends extra extension.Packet values to the -// OP_RETURN extension blob that is included in the ark transaction alongside -// the asset packet (type 0x00). -// -// Type 0x00 is reserved for the asset packet, automatically built depending on the Transaction. -// Passing type 0x00 returns an error. -// -// Duplicate packet types are not permitted. -func WithExtraPacket(packets ...extension.Packet) SendOption { - return sendOptFn(func(o *sendOptions) error { - for _, p := range packets { - if p == nil { - return fmt.Errorf("extension packet must not be nil") - } - if p.Type() == asset.PacketType { - return fmt.Errorf( - "packet type 0x%02x is reserved for the asset packet", - asset.PacketType, - ) - } - } - o.extraPackets = append(o.extraPackets, packets...) - return nil - }) -} - -// WithTxOutsTaprootTree sets the PSBT BIP-371 TaprootTapTree field on -// every output whose hex-encoded pkScript matches a key in the map. Callers -// pass the BIP-371-encoded tap tree bytes (via txutils.TapTree(scripts).Encode()). -// SendOffChain returns an error if any pkScript key matches no output of the -// ark tx, surfacing what would otherwise be a silent footgun for protocol- -// critical VTXO spending. -func WithTxOutsTaprootTree(tapTrees map[string][]byte) SendOption { - return sendOptFn(func(o *sendOptions) error { - if len(tapTrees) <= 0 { - return fmt.Errorf("missing taproot trees") - } - if o.outputsTapTree == nil { - o.outputsTapTree = make(map[string][]byte, len(tapTrees)) - } - for k, v := range tapTrees { - if len(v) == 0 { - return fmt.Errorf("receiver tap tree must not be empty") - } - if _, err := txutils.DecodeTapTree(v); err != nil { - return fmt.Errorf("invalid bip-371 tap tree for tx out with script %s: %w", k, err) - } - cp := make([]byte, len(v)) - copy(cp, v) - o.outputsTapTree[k] = cp - } - return nil - }) -} - -type sendOptions struct { - withoutExpirySorting bool - vtxos []types.VtxoWithTapTree - signingKeys map[string]string - extraPackets []extension.Packet - receiver string - outputsTapTree map[string][]byte // pkScript (hex) -> bip371 taptree -} - -func newDefaultSendOptions() *sendOptions { - return &sendOptions{} -} diff --git a/pkg/client-lib/service.go b/pkg/client-lib/service.go index faeb1e9e8..54bf0f120 100644 --- a/pkg/client-lib/service.go +++ b/pkg/client-lib/service.go @@ -1,451 +1,140 @@ -package wallet +package clientlib import ( - "context" - "encoding/hex" - "fmt" - "sync" + "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/client" - grpcclient "github.com/arkade-os/arkd/pkg/client-lib/client/grpc" - "github.com/arkade-os/arkd/pkg/client-lib/explorer" - mempoolexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" - "github.com/arkade-os/arkd/pkg/client-lib/identity" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" - grpcindexer "github.com/arkade-os/arkd/pkg/client-lib/indexer/grpc" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" - log "github.com/sirupsen/logrus" ) -const ( - // identity - SingleKeyIdentity = identity.SingleKeyIdentity - // store - FileStore = types.FileStore - InMemoryStore = types.InMemoryStore -) +// Explorer provides methods to interact with blockchain explorers (e.g., mempool.space, esplora). +// It supports both HTTP REST API calls and WebSocket connections for real-time address tracking. +// The implementation uses a connection pool architecture with multiple concurrent WebSocket connections +// to handle high-volume address subscriptions without overwhelming individual connections. +type Explorer interface { + // Start must be used when using the explorer with tracking enabled. + Start() -var ( - ErrAlreadyInitialized = fmt.Errorf("wallet already initialized") - ErrNotInitialized = fmt.Errorf("wallet not initialized") - ErrIsLocked = fmt.Errorf("wallet is locked") + // GetTxHex retrieves the raw transaction hex for a given transaction ID. + GetTxHex(txid string) (string, error) - supportedIdentities = utils.SupportedType[struct{}]{ - SingleKeyIdentity: struct{}{}, - } -) + // Broadcast broadcasts one or more raw transactions to the network. + // Returns the transaction ID of the first transaction on success. + Broadcast(txs ...string) (string, error) -type service struct { - *types.Config - identity identity.Identity - store types.Store - explorer explorer.Explorer - client client.Client - indexer indexer.Indexer + // GetTxs retrieves all transactions associated with a given address. + GetTxs(addr string) ([]Tx, error) - txLock *sync.RWMutex - verbose bool - withFinalizePendingTxs bool -} + // GetTxOutspends returns the spent status of all outputs for a given transaction. + GetTxOutspends(tx string) ([]SpentStatus, error) -func NewWallet(storeSvc types.Store, opts ...ServiceOption) (Wallet, error) { - if storeSvc == nil { - return nil, fmt.Errorf("missing store") - } + // GetUtxos retrieves all unspent transaction outputs (UTXOs) for the given addresses. + GetUtxos(addresses []string) ([]ExplorerUtxo, error) - cfgData, err := storeSvc.ConfigStore().GetData(context.Background()) - if err != nil { - return nil, err - } + // GetRedeemedVtxosBalance calculates the redeemed virtual UTXO balance for an address + // considering the unilateral exit delay. + GetRedeemedVtxosBalance( + addr string, unilateralExitDelay arklib.RelativeLocktime, + ) (uint64, map[int64]uint64, error) - if cfgData != nil { - return nil, ErrAlreadyInitialized - } + // GetTxBlockTime returns whether a transaction is confirmed and its block time. + GetTxBlockTime(txid string) (confirmed bool, blocktime int64, err error) - client := &service{ - store: storeSvc, - txLock: &sync.RWMutex{}, - withFinalizePendingTxs: true, - } - for _, opt := range opts { - opt(client) - } - - if client.identity == nil { - storeType := storeSvc.ConfigStore().GetType() - datadir := storeSvc.ConfigStore().GetDatadir() - identitySvc, err := getSingleKeyIdentity(datadir, storeType) - if err != nil { - return nil, fmt.Errorf("failed to setup identity: %s", err) - } - client.identity = identitySvc - } + // BaseUrl returns the base URL of the explorer service. + BaseUrl() string - return client, nil -} + // GetFeeRate retrieves the current recommended fee rate in sat/vB. + GetFeeRate() (float64, error) -func LoadWallet(storeSvc types.Store, opts ...ServiceOption) (Wallet, error) { - if storeSvc == nil { - return nil, fmt.Errorf("missing sdk repository") - } + // GetConnectionCount returns the number of active WebSocket connections. + GetConnectionCount() int - cfgData, err := storeSvc.ConfigStore().GetData(context.Background()) - if err != nil { - return nil, err - } - if cfgData == nil { - return nil, ErrNotInitialized - } - - client := &service{ - Config: cfgData, - store: storeSvc, - txLock: &sync.RWMutex{}, - withFinalizePendingTxs: true, - } - for _, opt := range opts { - opt(client) - } - - if client.identity == nil { - storeType := storeSvc.ConfigStore().GetType() - datadir := storeSvc.ConfigStore().GetDatadir() - identitySvc, err := getSingleKeyIdentity(datadir, storeType) - if err != nil { - return nil, fmt.Errorf("failed to setup identity: %s", err) - } - client.identity = identitySvc - } - - if client.explorer == nil { - explorerOpts := []mempoolexplorer.Option{mempoolexplorer.WithTracker(false)} - explorerSvc, err := mempoolexplorer.NewExplorer( - cfgData.ExplorerURL, cfgData.Network, explorerOpts..., - ) - if err != nil { - return nil, fmt.Errorf("failed to setup explorer: %s", err) - } - client.explorer = explorerSvc - } - - clientSvc, err := grpcclient.NewClient(cfgData.ServerUrl) - if err != nil { - return nil, fmt.Errorf("failed to setup transport client: %s", err) - } - indexerSvc, err := grpcindexer.NewClient(cfgData.ServerUrl) - if err != nil { - return nil, fmt.Errorf("failed to setup indexer: %s", err) - } - - client.client = clientSvc - client.indexer = indexerSvc - - return client, nil -} - -func (a *service) Identity() identity.Identity { - return a.identity -} - -func (a *service) Client() client.Client { - return a.client -} - -func (a *service) Indexer() indexer.Indexer { - return a.indexer -} - -func (a *service) Explorer() explorer.Explorer { - return a.explorer -} - -func (a *service) GetVersion() string { - return Version -} - -func (a *service) GetConfigData(_ context.Context) (*types.Config, error) { - if a.Config == nil { - return nil, fmt.Errorf("client sdk not initialized") - } - return a.Config, nil -} - -func (a *service) Unlock(ctx context.Context, password string) error { - if _, err := a.identity.Unlock(ctx, password); err != nil { - return err - } - - log.SetLevel(log.DebugLevel) - if !a.verbose { - log.SetLevel(log.WarnLevel) - } - - if a.withFinalizePendingTxs { - // TODO: @sekulicd shall we move this to go-sdk? Otherwise we would have to pass an extra - // option to Unlock to pass basically the keys ids for the whole vtxo set and that would - // look awkward. - txids, err := a.finalizePendingTxs(ctx, nil, nil, nil) - if err != nil { - return err - } - switch len(txids) { - case 0: - log.Debug("no pending txs to finalize") - case 1: - log.Debug("finalized 1 pending tx") - default: - log.Debugf("finalized %d pending txs", len(txids)) - } - } - - return nil -} + // GetSubscribedAddresses returns a list of all currently subscribed addresses. + GetSubscribedAddresses() []string -func (a *service) Lock(ctx context.Context) error { - if a.identity == nil { - return ErrNotInitialized - } - return a.identity.Lock(ctx) -} + // IsAddressSubscribed checks if a specific address is currently subscribed. + IsAddressSubscribed(address string) bool -func (a *service) IsLocked(ctx context.Context) bool { - if a.identity == nil { - return true - } - return a.identity.IsLocked() -} + // GetAddressesEvents returns a channel that receives onchain address events + // (new UTXOs, spent UTXOs, confirmed UTXOs) for all subscribed addresses. + GetAddressesEvents() <-chan OnchainAddressEvent -func (a *service) Dump(ctx context.Context) (string, error) { - if err := a.safeCheck(); err != nil { - return "", err - } - return a.identity.Dump(ctx) -} + // SubscribeForAddresses subscribes to address updates via WebSocket connections. + // Addresses are automatically distributed across multiple connections using hash-based routing. + // Subscriptions are batched to prevent overwhelming individual connections. + // Duplicate subscriptions are automatically prevented via instance-scoped deduplication. + SubscribeForAddresses(addresses []string) error -func (a *service) Reset(ctx context.Context) { - a.client.Close() - a.indexer.Close() + // UnsubscribeForAddresses removes address subscriptions and updates the WebSocket connections. + UnsubscribeForAddresses(addresses []string) error - if a.store != nil { - a.store.Clean(ctx) - } + // Stop gracefully shuts down the explorer, closing all WebSocket connections and channels. + Stop() } -func (a *service) Stop() { - a.client.Close() - a.indexer.Close() - - if a.store != nil { - a.store.Close() - } +type SpentStatus struct { + Spent bool + SpentBy string } -func (a *service) SignTransaction( - ctx context.Context, tx string, opts ...SignOption, -) (string, error) { - if err := a.safeCheck(); err != nil { - return "", err - } - o := newDefaultSendOptions() - for _, opt := range opts { - if err := opt.applySend(o); err != nil { - return "", err - } - } - - return a.identity.SignTransaction(ctx, tx, o.signingKeys) +type Output struct { + Script string + Address string + Amount uint64 } -func (a *service) safeCheck() error { - if a.identity == nil { - return ErrNotInitialized - } - if a.identity.IsLocked() { - return ErrIsLocked - } - return nil +type Input struct { + Output + Txid string + Vout uint32 } -func (a *service) getVtxos( - ctx context.Context, extraOpts ...indexer.GetVtxosOption, -) (spendableVtxos, spentVtxos []types.Vtxo, err error) { - if a.identity == nil { - return nil, nil, ErrNotInitialized - } - - _, offchainAddrs, _, _, err := a.getAddresses(ctx) - if err != nil { - return - } - - scripts := make([]string, 0, len(offchainAddrs)) - for _, addr := range offchainAddrs { - decoded, err := arklib.DecodeAddressV0(addr.Address) - if err != nil { - return nil, nil, err - } - vtxoScript, err := script.P2TRScript(decoded.VtxoTapKey) - if err != nil { - return nil, nil, err - } - scripts = append(scripts, hex.EncodeToString(vtxoScript)) - } - opts := append([]indexer.GetVtxosOption{indexer.WithScripts(scripts)}, extraOpts...) - resp, err := a.indexer.GetVtxos(ctx, opts...) - if err != nil { - return nil, nil, err - } - - for _, vtxo := range resp.Vtxos { - if vtxo.Spent || vtxo.Unrolled { - spentVtxos = append(spentVtxos, vtxo) - continue - } - - if vtxo.IsRecoverable() { - spendableVtxos = append(spendableVtxos, vtxo) - continue - } - - spendableVtxos = append(spendableVtxos, vtxo) - } - return +type Tx struct { + Txid string + Vin []Input + Vout []Output + Status ConfirmedStatus } -func (a *service) getSpendableVtxos( - ctx context.Context, opts *getVtxosFilter, -) ([]types.Vtxo, error) { - spendable, _, err := a.getVtxos(ctx) - if err != nil { - return nil, err - } - - if opts != nil && len(opts.outpoints) > 0 { - spendable = filterByOutpoints(spendable, opts.outpoints) - } - - recoverableVtxos := make([]types.Vtxo, 0) - spendableVtxos := make([]types.Vtxo, 0, len(spendable)) - if opts != nil && opts.withRecoverableVtxos { - for _, vtxo := range spendable { - if vtxo.IsRecoverable() { - recoverableVtxos = append(recoverableVtxos, vtxo) - continue - } - spendableVtxos = append(spendableVtxos, vtxo) - } - } else { - spendableVtxos = make([]types.Vtxo, len(spendable)) - copy(spendableVtxos, spendable) - } - - allVtxos := append(recoverableVtxos, spendableVtxos...) - - if opts != nil && opts.recomputeExpiry { - // if sorting by expiry is required, we need to get the expiration date of each vtxo - redeemBranches, err := a.getRedeemBranches(ctx, spendableVtxos) - if err != nil { - return nil, err - } - - for vtxoTxid, branch := range redeemBranches { - expiration, err := branch.ExpiresAt() - if err != nil { - return nil, err - } - - for i, vtxo := range allVtxos { - if vtxo.Txid == vtxoTxid { - allVtxos[i].ExpiresAt = *expiration - break - } - } - } - } - - if opts == nil || !opts.withoutExpirySorting { - allVtxos = utils.SortVtxosByExpiry(allVtxos) - } - - if opts != nil && opts.excludeAssetVtxos { - filteredVtxos := make([]types.Vtxo, 0, len(allVtxos)) - for _, vtxo := range allVtxos { - if len(vtxo.Assets) == 0 { - filteredVtxos = append(filteredVtxos, vtxo) - } - } - allVtxos = filteredVtxos - } - - return allVtxos, nil +type ConfirmedStatus struct { + Confirmed bool + BlockTime int64 } -func (a *service) fetchPendingSpentVtxos(ctx context.Context) ([]types.Vtxo, error) { - if a.identity == nil { - return nil, ErrNotInitialized - } - - _, offchainAddrs, _, _, err := a.getAddresses(ctx) - if err != nil { - return nil, err - } - - scripts := make([]string, 0, len(offchainAddrs)) - for _, addr := range offchainAddrs { - decoded, err := arklib.DecodeAddressV0(addr.Address) - if err != nil { - return nil, err - } - vtxoScript, err := script.P2TRScript(decoded.VtxoTapKey) - if err != nil { - return nil, err - } - scripts = append(scripts, hex.EncodeToString(vtxoScript)) - } - resp, err := a.indexer.GetVtxos(ctx, indexer.WithPendingOnly(), indexer.WithScripts(scripts)) - if err != nil { - return nil, err - } - return resp.Vtxos, nil +// ExplorerUtxo represents an unspent transaction output from the blockchain explorer. +type ExplorerUtxo struct { + Txid string + Vout uint32 + Amount uint64 + Script string + Status ConfirmedStatus } -func (a *service) populateVtxosWithTapscripts( - ctx context.Context, vtxos []types.Vtxo, -) ([]types.VtxoWithTapTree, error) { - _, offchainAddrs, _, _, err := a.getAddresses(ctx) - if err != nil { - return nil, err - } - if len(offchainAddrs) <= 0 { - return nil, fmt.Errorf("no offchain addresses found") +// ToUtxo converts the explorer Utxo type to the client-lib Utxo one with the specified +// relative locktime delay (mandatory), tapscripts, and signing closure (optional). +func (u ExplorerUtxo) ToUtxo( + delay arklib.RelativeLocktime, tapscripts []string, signingClosure script.Closure, +) Utxo { + var ( + createdAt time.Time + redeemableAt time.Time + ) + if u.Status.BlockTime > 0 { + createdAt = time.Unix(u.Status.BlockTime, 0) + redeemableAt = createdAt.Add(time.Duration(delay.Seconds()) * time.Second) } - vtxosWithTapscripts := make([]types.VtxoWithTapTree, 0) - - for _, v := range vtxos { - found := false - for _, offchainAddr := range offchainAddrs { - vtxoAddr, err := v.Address(a.SignerPubKey, a.Network) - if err != nil { - return nil, err - } - - if vtxoAddr == offchainAddr.Address { - vtxosWithTapscripts = append(vtxosWithTapscripts, types.VtxoWithTapTree{ - Vtxo: v, - Tapscripts: offchainAddr.Tapscripts, - }) - found = true - break - } - } - if !found { - return nil, fmt.Errorf("no offchain address found for vtxo %s", v.Txid) - } + return Utxo{ + Outpoint: Outpoint{ + Txid: u.Txid, + VOut: u.Vout, + }, + Amount: u.Amount, + Script: u.Script, + Delay: delay, + RedeemableAt: redeemableAt, + CreatedAt: createdAt, + Tapscripts: tapscripts, + SigningClosure: signingClosure, } - - return vtxosWithTapscripts, nil } diff --git a/pkg/client-lib/service_opts.go b/pkg/client-lib/service_opts.go deleted file mode 100644 index 398d6cd0a..000000000 --- a/pkg/client-lib/service_opts.go +++ /dev/null @@ -1,32 +0,0 @@ -package wallet - -import ( - "github.com/arkade-os/arkd/pkg/client-lib/explorer" - "github.com/arkade-os/arkd/pkg/client-lib/identity" -) - -type ServiceOption func(*service) - -func WithVerbose() ServiceOption { - return func(c *service) { - c.verbose = true - } -} - -func WithExplorer(explorer explorer.Explorer) ServiceOption { - return func(c *service) { - c.explorer = explorer - } -} - -func WithIdentity(identitySvc identity.Identity) ServiceOption { - return func(c *service) { - c.identity = identitySvc - } -} - -func WithoutFinalizePendingTxs() ServiceOption { - return func(c *service) { - c.withFinalizePendingTxs = false - } -} diff --git a/pkg/client-lib/sign_opts.go b/pkg/client-lib/sign_opts.go deleted file mode 100644 index 791cd55bb..000000000 --- a/pkg/client-lib/sign_opts.go +++ /dev/null @@ -1,56 +0,0 @@ -package wallet - -import "fmt" - -// SignOption is the intersection of every option family that accepts signing -// keys. A value that satisfies SignOption can be passed to any method taking -// SendOption, BatchSessionOption, or UnrollOption — so WithKeys is defined -// once here instead of duplicated per family. -type SignOption interface { - SendOption - BatchSessionOption - UnrollOption -} - -type keysOpt struct { - keys map[string]string -} - -func (k keysOpt) applySend(o *sendOptions) error { - if len(o.signingKeys) > 0 { - return fmt.Errorf("signing keys already set") - } - if len(k.keys) == 0 { - return fmt.Errorf("missing signing keys") - } - o.signingKeys = k.keys - return nil -} - -func (k keysOpt) applyBatch(o *batchSessionOptions) error { - if len(o.keyIdsByScript) > 0 { - return fmt.Errorf("signing keys already set") - } - if len(k.keys) == 0 { - return fmt.Errorf("missing signing keys") - } - o.keyIdsByScript = k.keys - return nil -} - -func (k keysOpt) applyUnroll(o *unrollOptions) error { - if len(o.signingKeys) > 0 { - return fmt.Errorf("signing keys already set") - } - if len(k.keys) == 0 { - return fmt.Errorf("missing signing keys") - } - o.signingKeys = k.keys - return nil -} - -// WithKeys is usable in SendOffChain, Settle, Unroll, and every other method -// that currently accepts one of the three option families. -func WithKeys(keys map[string]string) SignOption { - return keysOpt{keys: keys} -} diff --git a/pkg/client-lib/store/inmemory/config_store.go b/pkg/client-lib/store/inmemory/config_store.go deleted file mode 100644 index 9b60ad1bb..000000000 --- a/pkg/client-lib/store/inmemory/config_store.go +++ /dev/null @@ -1,57 +0,0 @@ -package inmemorystore - -import ( - "context" - "sync" - - "github.com/arkade-os/arkd/pkg/client-lib/types" -) - -type store struct { - data *types.Config - lock *sync.RWMutex -} - -func NewConfigStore() (types.ConfigStore, error) { - lock := &sync.RWMutex{} - return &store{lock: lock}, nil -} - -func (s *store) Close() {} - -func (s *store) GetType() string { - return "inmemory" -} - -func (s *store) GetDatadir() string { - return "" -} - -func (s *store) AddData( - _ context.Context, data types.Config, -) error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data = &data - return nil -} - -func (s *store) GetData(_ context.Context) (*types.Config, error) { - s.lock.RLock() - defer s.lock.RUnlock() - - if s.data == nil { - return nil, nil - } - - return s.data, nil -} - -func (s *store) CleanData(_ context.Context) error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data = nil - return nil -} diff --git a/pkg/client-lib/store/service.go b/pkg/client-lib/store/service.go deleted file mode 100644 index 5cda48702..000000000 --- a/pkg/client-lib/store/service.go +++ /dev/null @@ -1,55 +0,0 @@ -package store - -import ( - "context" - "fmt" - - filestore "github.com/arkade-os/arkd/pkg/client-lib/store/file" - inmemorystore "github.com/arkade-os/arkd/pkg/client-lib/store/inmemory" - "github.com/arkade-os/arkd/pkg/client-lib/types" - _ "github.com/golang-migrate/migrate/v4/source/file" -) - -type service struct { - configStore types.ConfigStore -} - -type Config struct { - ConfigStoreType string - BaseDir string -} - -func NewStore(storeConfig Config) (types.Store, error) { - var ( - configStore types.ConfigStore - err error - dir = storeConfig.BaseDir - ) - - switch storeConfig.ConfigStoreType { - case types.InMemoryStore: - configStore, err = inmemorystore.NewConfigStore() - case types.FileStore: - configStore, err = filestore.NewConfigStore(dir) - default: - err = fmt.Errorf("unknown config store type") - } - if err != nil { - return nil, err - } - - return &service{configStore}, nil -} - -func (s *service) ConfigStore() types.ConfigStore { - return s.configStore -} - -func (s *service) Clean(ctx context.Context) { - //nolint:all - s.configStore.CleanData(ctx) -} - -func (s *service) Close() { - s.configStore.Close() -} diff --git a/pkg/client-lib/store/service_test.go b/pkg/client-lib/store/service_test.go deleted file mode 100644 index 3c44c866c..000000000 --- a/pkg/client-lib/store/service_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package store_test - -import ( - "context" - "testing" - - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/client-lib/store" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/stretchr/testify/require" -) - -var ( - key, _ = btcec.NewPrivateKey() - forfeitkKey, _ = btcec.NewPrivateKey() - testConfigData = types.Config{ - ServerUrl: "127.0.0.1:7070", - SignerPubKey: key.PubKey(), - ForfeitPubKey: forfeitkKey.PubKey(), - Network: arklib.BitcoinRegTest, - SessionDuration: 10, - UnilateralExitDelay: arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 512}, - Dust: 1000, - BoardingExitDelay: arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 512}, - ForfeitAddress: "bcrt1qzvqj", - CheckpointTapscript: "abcdefghijklmnopqrtuvxyz", - } -) - -func TestService(t *testing.T) { - t.Run("config store", func(t *testing.T) { - dbDir := t.TempDir() - tests := []struct { - name string - config store.Config - }{ - { - name: "inmemory", - config: store.Config{ - ConfigStoreType: types.InMemoryStore, - }, - }, - { - name: "file", - config: store.Config{ - ConfigStoreType: types.FileStore, - BaseDir: dbDir, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc, err := store.NewStore(tt.config) - require.NoError(t, err) - testConfigStore(t, svc.ConfigStore()) - }) - } - }) -} - -func testConfigStore(t *testing.T, storeSvc types.ConfigStore) { - ctx := context.Background() - - // Check empty data when store is empty. - data, err := storeSvc.GetData(ctx) - require.NoError(t, err) - require.Nil(t, data) - - // Check no side effects when cleaning an empty store. - err = storeSvc.CleanData(ctx) - require.NoError(t, err) - - // Check add and retrieve data. - err = storeSvc.AddData(ctx, testConfigData) - require.NoError(t, err) - - data, err = storeSvc.GetData(ctx) - require.NoError(t, err) - require.Equal(t, testConfigData, *data) - - // Check clean and retrieve data. - err = storeSvc.CleanData(ctx) - require.NoError(t, err) - - data, err = storeSvc.GetData(ctx) - require.NoError(t, err) - require.Nil(t, data) - - // Check overwriting the store. - err = storeSvc.AddData(ctx, testConfigData) - require.NoError(t, err) - err = storeSvc.AddData(ctx, testConfigData) - require.NoError(t, err) -} diff --git a/pkg/client-lib/types.go b/pkg/client-lib/types.go index e7df45456..e10823881 100644 --- a/pkg/client-lib/types.go +++ b/pkg/client-lib/types.go @@ -1,49 +1,382 @@ -package wallet +package clientlib -import "github.com/arkade-os/arkd/pkg/client-lib/types" +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "time" -type Balance struct { - OnchainBalance OnchainBalance `json:"onchain_balance"` - OffchainBalance OffchainBalance `json:"offchain_balance"` - AssetBalances map[string]uint64 `json:"asset_balances,omitempty"` + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +type StreamConnectionState string + +const ( + StreamConnectionStateDisconnected StreamConnectionState = "DISCONNECTED" + StreamConnectionStateReconnected StreamConnectionState = "RECONNECTED" + StreamConnectionStateReady StreamConnectionState = "READY" +) + +type FeeInfo struct { + IntentFees arkfee.Config + TxFeeRate float64 +} + +type StreamConnectionEvent struct { + State StreamConnectionState + At time.Time + DisconnectedAt time.Time + Err error +} + +type DeprecatedSigner struct { + PubKey *btcec.PublicKey + CutoffDate time.Time +} + +type Address struct { + // KeyID identifies which wallet key produced this address. + // Single-key wallets populate it with their fixed key handle; HD wallets can + // use the derivation path. + KeyID string + Tapscripts []string + Address string + + vtxoScript script.VtxoScript +} + +func (a *Address) RawScript() (script.VtxoScript, error) { + if a.vtxoScript != nil { + return a.vtxoScript, nil + } + + vtxoScript, err := script.ParseVtxoScript(a.Tapscripts) + if err != nil { + return nil, err + } + a.vtxoScript = vtxoScript + return vtxoScript, nil +} + +func (a Address) Script() (string, error) { + addr, err := arklib.DecodeAddressV0(a.Address) + if err != nil { + return "", err + } + outScript, err := script.P2TRScript(addr.VtxoTapKey) + if err != nil { + return "", err + } + return hex.EncodeToString(outScript), nil +} + +func (a *Address) CollaborativeClosure() (script.Closure, error) { + if a.vtxoScript != nil { + return a.vtxoScript.ForfeitClosures()[0], nil + } + + vtxoScript, err := script.ParseVtxoScript(a.Tapscripts) + if err != nil { + return nil, err + } + if len(vtxoScript.ForfeitClosures()) <= 0 { + return nil, fmt.Errorf("address %s has no collaborative closures", a.Address) + } + a.vtxoScript = vtxoScript + return vtxoScript.ForfeitClosures()[0], nil +} + +func (a *Address) ExitClosure() (script.Closure, error) { + if a.vtxoScript != nil { + return a.vtxoScript.ExitClosures()[0], nil + } + + vtxoScript, err := script.ParseVtxoScript(a.Tapscripts) + if err != nil { + return nil, err + } + if len(vtxoScript.ExitClosures()) <= 0 { + return nil, fmt.Errorf("address %s has no exit closures", a.Address) + } + a.vtxoScript = vtxoScript + return vtxoScript.ExitClosures()[0], nil } -type OnchainBalance struct { - SpendableAmount uint64 `json:"spendable_amount"` - LockedAmount []LockedOnchainBalance `json:"locked_amount,omitempty"` +type Outpoint struct { + Txid string + VOut uint32 } -type LockedOnchainBalance struct { - SpendableAt string `json:"spendable_at"` - Amount uint64 `json:"amount"` +func (v Outpoint) String() string { + return fmt.Sprintf("%s:%d", v.Txid, v.VOut) } -type OffchainBalance struct { - Total uint64 `json:"total"` - NextExpiration string `json:"next_expiration,omitempty"` - Details []VtxoDetails `json:"details"` +type Vtxo struct { + Outpoint + Script string + Amount uint64 + CommitmentTxids []string + ExpiresAt time.Time + CreatedAt time.Time + Preconfirmed bool + Swept bool + Unrolled bool + Spent bool + SpentBy string + SettledBy string + ArkTxid string + Assets []Asset + Tapscripts []string + SigningClosure script.Closure +} + +type Asset struct { + AssetId string + Amount uint64 +} + +func (v Vtxo) String() string { + // nolint + b, _ := json.MarshalIndent(v, "", " ") + return string(b) +} + +func (v Vtxo) IsRecoverable() bool { + expired := !v.ExpiresAt.IsZero() && !time.Now().Before(v.ExpiresAt) + return (v.Swept || expired) && !v.Spent +} + +func (v Vtxo) Address(server *btcec.PublicKey, net arklib.Network) (string, error) { + buf, err := hex.DecodeString(v.Script) + if err != nil { + return "", err + } + pubkeyBytes := buf[2:] + + pubkey, err := schnorr.ParsePubKey(pubkeyBytes) + if err != nil { + return "", err + } + + a := &arklib.Address{ + HRP: net.Addr, + Signer: server, + VtxoTapKey: pubkey, + } + + return a.EncodeV0() +} + +func (v Vtxo) ToArkFeeInput() arkfee.OffchainInput { + vtxoType := arkfee.VtxoTypeVtxo + if v.Swept { + vtxoType = arkfee.VtxoTypeRecoverable + } + + return arkfee.OffchainInput{ + Amount: v.Amount, + Expiry: v.ExpiresAt, + Birth: v.CreatedAt, + Type: vtxoType, + Weight: 0, + } +} + +func (v Vtxo) ParseClosure() ([]byte, *arklib.TaprootMerkleProof, error) { + pkScript, leafProof, err := ParseClosure(v.Outpoint, v.SigningClosure, v.Tapscripts) + if err != nil { + return nil, nil, fmt.Errorf("vtxo %w", err) + } + + return pkScript, leafProof, nil +} + +const ( + TxSent TxType = "SENT" + TxReceived TxType = "RECEIVED" +) + +type TxType string + +type TransactionKey struct { + BoardingTxid string + CommitmentTxid string + ArkTxid string } -type VtxoDetails struct { - ExpiryTime string `json:"expiry_time"` - Amount uint64 `json:"amount"` +func (t TransactionKey) String() string { + return fmt.Sprintf("%s%s%s", t.BoardingTxid, t.CommitmentTxid, t.ArkTxid) } -type getVtxosFilter struct { - // If true, will sort coins by expiration (oldest first) - withoutExpirySorting bool - // If specified, will select only coins in the list - outpoints []types.Outpoint - // If true, will select recoverable (swept but unspent) vtxos first - withRecoverableVtxos bool - // If specified, will select only vtxos below the given expiration threshold (seconds) - expiryThreshold int64 - // If true, will recompute the expiration of all vtxos from their anchestor batch outputs - recomputeExpiry bool - // If set, the provided vtxo set is used and won't be fetched from network - vtxos []types.VtxoWithTapTree - // If set, the provided boarding utxo set is used and won't be fetched from network - utxos []types.Utxo - // If true, coin selection will exclude vtxos holding assets - excludeAssetVtxos bool +type Transaction struct { + TransactionKey + Amount uint64 + Type TxType + CreatedAt time.Time + Hex string + SettledBy string + AssetPacket asset.Packet + // Assets is the per-asset breakdown for this transaction, expressed as + // net amounts (gross inputs minus own change). Populated at construction + // by any code path that has the source vtxos in hand — notably + // funding.vtxosToTxs (for reconciled history) and the wallet-side + // send/batch handlers (for just-signed sends). Nil for pure-BTC + // transactions. + Assets []Asset } + +func (t Transaction) String() string { + buf, _ := json.MarshalIndent(t, "", " ") + return string(buf) +} + +type Utxo struct { + Outpoint + Amount uint64 + Script string + Delay arklib.RelativeLocktime + RedeemableAt time.Time + CreatedAt time.Time + Spent bool + SpentBy string + Tx string + Assets []Asset + Tapscripts []string + SigningClosure script.Closure +} + +func (u Utxo) IsConfirmed() bool { + return !u.CreatedAt.IsZero() +} + +func (u Utxo) Sequence() (uint32, error) { + return arklib.BIP68Sequence(u.Delay) +} + +func (u Utxo) ToArkFeeInput() arkfee.OnchainInput { + return arkfee.OnchainInput{ + Amount: u.Amount, + } +} + +func (u Utxo) ParseClosure() ([]byte, *arklib.TaprootMerkleProof, error) { + pkScript, leafProof, err := ParseClosure(u.Outpoint, u.SigningClosure, u.Tapscripts) + if err != nil { + return nil, nil, fmt.Errorf("utxo %w", err) + } + + return pkScript, leafProof, nil +} + +type Receiver struct { + To string + Amount uint64 + Assets []Asset +} + +func (r Receiver) IsOnchain() bool { + _, err := btcutil.DecodeAddress(r.To, nil) + return err == nil +} + +func (o Receiver) ToTxOut() (*wire.TxOut, bool, error) { + var pkScript []byte + isOnchain := false + + arkAddress, err := arklib.DecodeAddressV0(o.To) + if err != nil { + // Decode onchain address + btcAddress, err := btcutil.DecodeAddress(o.To, nil) + if err != nil { + return nil, false, err + } + + pkScript, err = txscript.PayToAddrScript(btcAddress) + if err != nil { + return nil, false, err + } + + isOnchain = true + } else { + pkScript, err = script.P2TRScript(arkAddress.VtxoTapKey) + if err != nil { + return nil, false, err + } + } + + if len(pkScript) == 0 { + return nil, false, fmt.Errorf("invalid address") + } + + return &wire.TxOut{ + Value: int64(o.Amount), + PkScript: pkScript, + }, isOnchain, nil +} + +func (r Receiver) ToArkFeeOutput() arkfee.Output { + txout, _, err := r.ToTxOut() + if err != nil { + return arkfee.Output{} + } + return arkfee.Output{ + Amount: r.Amount, + Script: hex.EncodeToString(txout.PkScript), + } +} + +type OnchainOutput struct { + Outpoint + Script string + Amount uint64 + CreatedAt time.Time + Spent bool + SpentBy string +} + +type OnchainAddressEvent struct { + Error error + SpentUtxos []OnchainOutput + NewUtxos []OnchainOutput + ConfirmedUtxos []OnchainOutput + Replacements map[string]string // replacedTxid -> replacementTxid +} + +type SyncEvent struct { + Synced bool + Err error +} + +// ControlAsset represents the control asset configuration for issuing new assets. +// Use either NewControlAsset to create a new control asset, or ExistingControlAsset +type ControlAsset interface { + isControlAsset() +} + +// NewControlAsset creates a new control asset with the specified amount. +type NewControlAsset struct { + Amount uint64 +} + +func (NewControlAsset) isControlAsset() {} + +// ExistingControlAsset references an existing control asset by its ID. +type ExistingControlAsset struct { + Id string + Amount uint64 +} + +func (ExistingControlAsset) isControlAsset() {} + +// SignFn signs the provided base64-encoded PSBT with the caller's identity +// and returns the signed PSBT base64. +type SignFn func(ctx context.Context, tx string) (string, error) diff --git a/pkg/client-lib/types/types.go b/pkg/client-lib/types/types.go deleted file mode 100644 index 27907ae5a..000000000 --- a/pkg/client-lib/types/types.go +++ /dev/null @@ -1,408 +0,0 @@ -package types - -import ( - "encoding/hex" - "encoding/json" - "fmt" - "time" - - arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" - "github.com/arkade-os/arkd/pkg/ark-lib/asset" - "github.com/arkade-os/arkd/pkg/ark-lib/script" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" -) - -const ( - InMemoryStore = "inmemory" - FileStore = "file" - KVStore = "kv" - SQLStore = "sql" -) - -type Config struct { - ServerUrl string - SignerPubKey *btcec.PublicKey - ForfeitPubKey *btcec.PublicKey - Network arklib.Network - SessionDuration int64 - UnilateralExitDelay arklib.RelativeLocktime - Dust uint64 - BoardingExitDelay arklib.RelativeLocktime - ExplorerURL string - ForfeitAddress string - UtxoMinAmount int64 - UtxoMaxAmount int64 - VtxoMinAmount int64 - VtxoMaxAmount int64 - CheckpointTapscript string - Fees FeeInfo -} - -func (c Config) CheckpointExitPath() []byte { - // nolint - buf, _ := hex.DecodeString(c.CheckpointTapscript) - return buf -} - -type StreamConnectionState string - -const ( - StreamConnectionStateDisconnected StreamConnectionState = "DISCONNECTED" - StreamConnectionStateReconnected StreamConnectionState = "RECONNECTED" - StreamConnectionStateReady StreamConnectionState = "READY" -) - -type StreamConnectionEvent struct { - State StreamConnectionState - At time.Time - DisconnectedAt time.Time - Err error -} - -type FeeInfo struct { - IntentFees arkfee.Config - TxFeeRate float64 -} - -type DeprecatedSigner struct { - PubKey *btcec.PublicKey - CutoffDate time.Time -} - -type Address struct { - // KeyID identifies which wallet key produced this address. - // Single-key wallets populate it with their fixed key handle; HD wallets can - // use the derivation path. - KeyID string - Tapscripts []string - Address string -} - -type Outpoint struct { - Txid string - VOut uint32 -} - -func (v Outpoint) String() string { - return fmt.Sprintf("%s:%d", v.Txid, v.VOut) -} - -type Vtxo struct { - Outpoint - Script string - Amount uint64 - CommitmentTxids []string - ExpiresAt time.Time - CreatedAt time.Time - Preconfirmed bool - Swept bool - Unrolled bool - Spent bool - SpentBy string - SettledBy string - ArkTxid string - Assets []Asset -} - -type Asset struct { - AssetId string - Amount uint64 -} - -func (v Vtxo) String() string { - // nolint - b, _ := json.MarshalIndent(v, "", " ") - return string(b) -} - -func (v Vtxo) IsRecoverable() bool { - expired := !v.ExpiresAt.IsZero() && !time.Now().Before(v.ExpiresAt) - return (v.Swept || expired) && !v.Spent -} - -func (v Vtxo) Address(server *btcec.PublicKey, net arklib.Network) (string, error) { - buf, err := hex.DecodeString(v.Script) - if err != nil { - return "", err - } - pubkeyBytes := buf[2:] - - pubkey, err := schnorr.ParsePubKey(pubkeyBytes) - if err != nil { - return "", err - } - - a := &arklib.Address{ - HRP: net.Addr, - Signer: server, - VtxoTapKey: pubkey, - } - - return a.EncodeV0() -} - -type VtxoWithTapTree struct { - Vtxo - Tapscripts []string -} - -func (v VtxoWithTapTree) ToArkFeeInput() arkfee.OffchainInput { - vtxoType := arkfee.VtxoTypeVtxo - if v.Swept { - vtxoType = arkfee.VtxoTypeRecoverable - } - - return arkfee.OffchainInput{ - Amount: v.Amount, - Expiry: v.ExpiresAt, - Birth: v.CreatedAt, - Type: vtxoType, - Weight: 0, - } -} - -type UtxoEventType int - -const ( - UtxosAdded UtxoEventType = iota - UtxosConfirmed - UtxosReplaced - UtxosSpent -) - -func (e UtxoEventType) String() string { - return map[UtxoEventType]string{ - UtxosAdded: "UTXOS_ADDED", - UtxosConfirmed: "UTXOS_CONFIRMED", - UtxosReplaced: "UTXOS_REPLACED", - UtxosSpent: "UTXOS_SPENT", - }[e] -} - -type UtxoEvent struct { - Type UtxoEventType - Utxos []Utxo -} - -type VtxoEventType int - -const ( - VtxosAdded VtxoEventType = iota - VtxosSpent - VtxosUpdated -) - -func (e VtxoEventType) String() string { - return map[VtxoEventType]string{ - VtxosAdded: "VTXOS_ADDED", - VtxosSpent: "VTXOS_SPENT", - VtxosUpdated: "VTXOS_UPDATED", - }[e] -} - -type VtxoEvent struct { - Type VtxoEventType - Vtxos []Vtxo -} - -const ( - TxSent TxType = "SENT" - TxReceived TxType = "RECEIVED" -) - -type TxType string - -type TransactionKey struct { - BoardingTxid string - CommitmentTxid string - ArkTxid string -} - -func (t TransactionKey) String() string { - return fmt.Sprintf("%s%s%s", t.BoardingTxid, t.CommitmentTxid, t.ArkTxid) -} - -type Transaction struct { - TransactionKey - Amount uint64 - Type TxType - CreatedAt time.Time - Hex string - SettledBy string - AssetPacket asset.Packet - // Assets is the per-asset breakdown for this transaction, expressed as - // net amounts (gross inputs minus own change). Populated at construction - // by any code path that has the source vtxos in hand — notably - // funding.vtxosToTxs (for reconciled history) and the wallet-side - // send/batch handlers (for just-signed sends). Nil for pure-BTC - // transactions. - Assets []Asset -} - -func (t Transaction) String() string { - buf, _ := json.MarshalIndent(t, "", " ") - return string(buf) -} - -type TxEventType int - -const ( - TxsAdded TxEventType = iota - TxsSettled - TxsConfirmed - TxsReplaced - TxsUpdated -) - -func (e TxEventType) String() string { - return map[TxEventType]string{ - TxsAdded: "TXS_ADDED", - TxsSettled: "TXS_SETTLED", - TxsConfirmed: "TXS_CONFIRMED", - TxsReplaced: "TXS_REPLACED", - }[e] -} - -type TransactionEvent struct { - Type TxEventType - Txs []Transaction - Replacements map[string]string -} - -type Utxo struct { - Outpoint - Amount uint64 - Script string - Delay arklib.RelativeLocktime - SpendableAt time.Time - CreatedAt time.Time - Tapscripts []string - Spent bool - SpentBy string - Tx string - Assets []Asset -} - -func (u Utxo) IsConfirmed() bool { - return !u.CreatedAt.IsZero() -} - -func (u Utxo) Sequence() (uint32, error) { - return arklib.BIP68Sequence(u.Delay) -} - -func (u Utxo) ToArkFeeInput() arkfee.OnchainInput { - return arkfee.OnchainInput{ - Amount: u.Amount, - } -} - -type Receiver struct { - To string - Amount uint64 - Assets []Asset -} - -func (r Receiver) IsOnchain() bool { - _, err := btcutil.DecodeAddress(r.To, nil) - return err == nil -} - -func (o Receiver) ToTxOut() (*wire.TxOut, bool, error) { - var pkScript []byte - isOnchain := false - - arkAddress, err := arklib.DecodeAddressV0(o.To) - if err != nil { - // decode onchain address - btcAddress, err := btcutil.DecodeAddress(o.To, nil) - if err != nil { - return nil, false, err - } - - pkScript, err = txscript.PayToAddrScript(btcAddress) - if err != nil { - return nil, false, err - } - - isOnchain = true - } else { - pkScript, err = script.P2TRScript(arkAddress.VtxoTapKey) - if err != nil { - return nil, false, err - } - } - - if len(pkScript) == 0 { - return nil, false, fmt.Errorf("invalid address") - } - - return &wire.TxOut{ - Value: int64(o.Amount), - PkScript: pkScript, - }, isOnchain, nil -} - -func (r Receiver) ToArkFeeOutput() arkfee.Output { - txout, _, err := r.ToTxOut() - if err != nil { - return arkfee.Output{} - } - return arkfee.Output{ - Amount: r.Amount, - Script: hex.EncodeToString(txout.PkScript), - } -} - -type OnchainOutput struct { - Outpoint - Script string - Amount uint64 - CreatedAt time.Time - Spent bool - SpentBy string -} - -type OnchainAddressEvent struct { - Error error - SpentUtxos []OnchainOutput - NewUtxos []OnchainOutput - ConfirmedUtxos []OnchainOutput - Replacements map[string]string // replacedTxid -> replacementTxid -} - -type SyncEvent struct { - Synced bool - Err error -} - -// ControlAsset represents the control asset configuration for issuing new assets. -// Use either NewControlAsset to create a new control asset, or ExistingControlAsset -type ControlAsset interface { - isControlAsset() -} - -// NewControlAsset creates a new control asset with the specified amount. -type NewControlAsset struct { - Amount uint64 -} - -func (NewControlAsset) isControlAsset() {} - -// ExistingControlAsset references an existing control asset by its ID. -type ExistingControlAsset struct { - ID string -} - -func (ExistingControlAsset) isControlAsset() {} - -type AssetInfo struct { - AssetId string - ControlAssetId string - Metadata []asset.Metadata -} diff --git a/pkg/client-lib/unroll.go b/pkg/client-lib/unroll.go deleted file mode 100644 index 9f1762ebe..000000000 --- a/pkg/client-lib/unroll.go +++ /dev/null @@ -1,719 +0,0 @@ -package wallet - -import ( - "bytes" - "context" - "encoding/hex" - "fmt" - "math" - "strings" - "time" - - "github.com/arkade-os/arkd/pkg/ark-lib/script" - "github.com/arkade-os/arkd/pkg/ark-lib/txutils" - "github.com/arkade-os/arkd/pkg/client-lib/explorer" - "github.com/arkade-os/arkd/pkg/client-lib/redemption" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/lightningnetwork/lnd/input" - "github.com/lightningnetwork/lnd/lntypes" - log "github.com/sirupsen/logrus" -) - -var ErrWaitingForConfirmation = fmt.Errorf("waiting for confirmation(s), please retry later") - -func (a *service) Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes, error) { - if err := a.safeCheck(); err != nil { - return nil, err - } - options := newDefaultUnrollOptions() - for _, opt := range opts { - if err := opt.applyUnroll(options); err != nil { - return nil, err - } - } - - a.txLock.Lock() - defer a.txLock.Unlock() - - vtxos := options.vtxos - var err error - if len(vtxos) <= 0 { - vtxos, err = a.getSpendableVtxos(ctx, nil) - if err != nil { - return nil, err - } - } - - if len(vtxos) == 0 { - return nil, fmt.Errorf("no vtxos to unroll") - } - - totalVtxosAmount := uint64(0) - for _, vtxo := range vtxos { - totalVtxosAmount += vtxo.Amount - } - - // transactionsMap avoid duplicates - transactionsMap := make(map[string]struct{}, 0) - transactions := make([]string, 0) - - redeemBranches, err := a.getRedeemBranches(ctx, vtxos) - if err != nil { - return nil, err - } - - isWaitingForConfirmation := false - - for _, branch := range redeemBranches { - nextTx, err := branch.NextRedeemTx() - if err != nil { - if err, ok := err.(redemption.ErrPendingConfirmation); ok { - // the branch tx is in the mempool, we must wait for confirmation - // print only, do not make the function to fail - // continue to try other branches - log.Debug(err.Error()) - isWaitingForConfirmation = true - continue - } - - return nil, err - } - - if _, ok := transactionsMap[nextTx]; !ok { - transactions = append(transactions, nextTx) - transactionsMap[nextTx] = struct{}{} - } - } - - if len(transactions) == 0 { - if isWaitingForConfirmation { - return nil, ErrWaitingForConfirmation - } - - return nil, nil - } - - res := make([]UnrollRes, 0, len(transactions)) - for _, parent := range transactions { - var parentTx wire.MsgTx - if err := parentTx.Deserialize(hex.NewDecoder(strings.NewReader(parent))); err != nil { - return nil, err - } - - childTxid, child, err := a.bumpAnchorTx(ctx, &parentTx) - if err != nil { - return nil, err - } - - // broadcast the package (parent + child) - packageResponse, err := a.explorer.Broadcast(parent, child) - if err != nil { - return nil, err - } - - res = append(res, UnrollRes{ - ParentTx: parent, - ParentTxid: parentTx.TxID(), - ChildTx: child, - ChildTxid: childTxid, - }) - log.Debugf("package broadcasted: %s", packageResponse) - } - - return res, nil -} - -func (a *service) CompleteUnroll( - ctx context.Context, to string, opts ...UnrollOption, -) (string, error) { - if err := a.safeCheck(); err != nil { - return "", err - } - - options := newDefaultUnrollOptions() - for _, opt := range opts { - if err := opt.applyUnroll(options); err != nil { - return "", err - } - } - - if len(to) == 0 { - newAddr, _, _, err := a.newAddress(ctx) - if err != nil { - return "", err - } - - to = newAddr - } else if _, err := btcutil.DecodeAddress(to, nil); err != nil { - return "", fmt.Errorf("invalid receiver address '%s': must be onchain", to) - } - - return a.completeUnroll(ctx, to, options) -} - -func (a *service) WithdrawFromAllExpiredBoardings( - ctx context.Context, to string, opts ...UnrollOption, -) (string, error) { - if err := a.safeCheck(); err != nil { - return "", err - } - - options := newDefaultUnrollOptions() - for _, opt := range opts { - if err := opt.applyUnroll(options); err != nil { - return "", err - } - } - - if _, err := btcutil.DecodeAddress(to, nil); err != nil { - return "", fmt.Errorf("invalid receiver address '%s': must be onchain", to) - } - - return a.sendExpiredBoardingUtxos(ctx, to, options) -} - -func (a *service) OnboardAgainAllExpiredBoardings( - ctx context.Context, opts ...UnrollOption, -) (string, error) { - if err := a.safeCheck(); err != nil { - return "", err - } - - if a.UtxoMaxAmount == 0 { - return "", fmt.Errorf("operation not allowed by the server") - } - - options := newDefaultUnrollOptions() - for _, opt := range opts { - if err := opt.applyUnroll(options); err != nil { - return "", err - } - } - - addr, err := a.getBoardingReceiver(ctx, options.receiver) - if err != nil { - return "", err - } - - return a.sendExpiredBoardingUtxos(ctx, addr, options) -} - -// bumpAnchorTx builds and signs a transaction bumping the fees for a given tx with P2A output. -// Makes use of the onchain P2TR account to select UTXOs to pay fees for parent. -func (a *service) bumpAnchorTx(ctx context.Context, parent *wire.MsgTx) (string, string, error) { - anchor, err := txutils.FindAnchorOutpoint(parent) - if err != nil { - return "", "", err - } - - // estimate for the size of the bump transaction - weightEstimator := input.TxWeightEstimator{} - - // WeightEstimator doesn't support P2A size, using P2WSH will lead to a small overestimation - // TODO use the exact P2A size - weightEstimator.AddNestedP2WSHInput(lntypes.VByte(3).ToWU()) - - // We assume only one UTXO will be selected to have a correct estimation - weightEstimator.AddTaprootKeySpendInput(txscript.SigHashDefault) - weightEstimator.AddP2TROutput() - - childVSize := weightEstimator.Weight().ToVB() - - packageSize := childVSize + computeVSize(parent) - feeRate, err := a.explorer.GetFeeRate() - if err != nil { - return "", "", err - } - - fees := uint64(math.Ceil(float64(packageSize) * feeRate)) - - addresses, _, _, _, err := a.getAddresses(ctx) - if err != nil { - return "", "", err - } - - selectedCoins := make([]explorer.Utxo, 0) - selectedAmount := uint64(0) - amountToSelect := int64(fees) - txutils.ANCHOR_VALUE - keys := make(map[string]string) - for _, addr := range addresses { - utxos, err := a.explorer.GetUtxos([]string{addr.Address}) - if err != nil { - return "", "", err - } - script, err := toOutputScript(addr.Address, a.Network) - if err != nil { - return "", "", err - } - - for _, utxo := range utxos { - selectedCoins = append(selectedCoins, utxo) - selectedAmount += utxo.Amount - amountToSelect -= int64(selectedAmount) - keys[hex.EncodeToString(script)] = addr.KeyID - if amountToSelect <= 0 { - break - } - } - } - - if amountToSelect > 0 { - return "", "", fmt.Errorf("not enough funds to select %d", amountToSelect) - } - - changeAmount := selectedAmount - fees - - newAddr, _, _, err := a.newAddress(ctx) - if err != nil { - return "", "", err - } - - pkScript, err := toOutputScript(newAddr, a.Network) - if err != nil { - return "", "", err - } - - inputs := []*wire.OutPoint{anchor} - sequences := []uint32{ - wire.MaxTxInSequenceNum, - } - outputs := []*wire.TxOut{ - { - Value: int64(changeAmount), - PkScript: pkScript, - }, - } - - for _, utxo := range selectedCoins { - txid, err := chainhash.NewHashFromStr(utxo.Txid) - if err != nil { - return "", "", err - } - inputs = append(inputs, &wire.OutPoint{ - Hash: *txid, - Index: utxo.Vout, - }) - sequences = append(sequences, wire.MaxTxInSequenceNum) - } - - ptx, err := psbt.New(inputs, outputs, 3, 0, sequences) - if err != nil { - return "", "", err - } - - ptx.Inputs[0].WitnessUtxo = txutils.AnchorOutput() - - for i, utxo := range selectedCoins { - pkScript, err := hex.DecodeString(utxo.Script) - if err != nil { - return "", "", err - } - var keyID string - if len(keys) > 0 { - id, ok := keys[utxo.Script] - if !ok { - return "", "", fmt.Errorf("no signing key for utxo %s:%d", utxo.Txid, utxo.Vout) - } - keyID = id - } - keyRef, err := a.identity.GetKey(ctx, keyID) - if err != nil { - return "", "", err - } - - ptx.Inputs[i+1].WitnessUtxo = &wire.TxOut{ - Value: int64(utxo.Amount), - PkScript: pkScript, - } - ptx.Inputs[i+1].TaprootInternalKey = schnorr.SerializePubKey(keyRef.PubKey) - } - - b64, err := ptx.B64Encode() - if err != nil { - return "", "", err - } - - tx, err := a.identity.SignTransaction(ctx, b64, keys) - if err != nil { - return "", "", err - } - - signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true) - if err != nil { - return "", "", err - } - - for inIndex := range signedPtx.Inputs[1:] { - if _, err := psbt.MaybeFinalize(signedPtx, inIndex+1); err != nil { - return "", "", err - } - } - - childTx, err := txutils.ExtractWithAnchors(signedPtx) - if err != nil { - return "", "", err - } - - var serializedTx bytes.Buffer - if err := childTx.Serialize(&serializedTx); err != nil { - return "", "", err - } - - return childTx.TxID(), hex.EncodeToString(serializedTx.Bytes()), nil -} - -func (a *service) completeUnroll( - ctx context.Context, to string, opts *unrollOptions, -) (string, error) { - pkscript, err := toOutputScript(to, a.Network) - if err != nil { - return "", err - } - - utxos := opts.utxos - if len(utxos) <= 0 { - utxos, err = a.getMatureUtxos(ctx) - if err != nil { - return "", err - } - } - - targetAmount := uint64(0) - for _, u := range utxos { - targetAmount += u.Amount - } - - if targetAmount == 0 { - return "", fmt.Errorf("no mature funds available") - } - - ptx, err := psbt.New(nil, nil, 2, 0, nil) - if err != nil { - return "", err - } - - updater, err := psbt.NewUpdater(ptx) - if err != nil { - return "", err - } - - updater.Upsbt.UnsignedTx.AddTxOut(&wire.TxOut{ - Value: int64(targetAmount), - PkScript: pkscript, - }) - updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{}) - - if err := a.addInputs(ctx, updater, utxos); err != nil { - return "", err - } - - vbytes := computeVSize(updater.Upsbt.UnsignedTx) - feeRate, err := a.explorer.GetFeeRate() - if err != nil { - return "", err - } - - feeAmount := uint64(math.Ceil(float64(vbytes)*feeRate) + 100) - - if targetAmount-feeAmount <= a.Dust { - return "", fmt.Errorf("not enough funds to cover network fees") - } - - updater.Upsbt.UnsignedTx.TxOut[0].Value -= int64(feeAmount) - - unsignedTx, _ := ptx.B64Encode() - - signedTx, err := a.identity.SignTransaction(ctx, unsignedTx, opts.signingKeys) - if err != nil { - return "", err - } - - ptx, err = psbt.NewFromRawBytes(strings.NewReader(signedTx), true) - if err != nil { - return "", err - } - - for i := range ptx.Inputs { - if err := psbt.Finalize(ptx, i); err != nil { - return "", err - } - } - - tx, err := psbt.Extract(ptx) - if err != nil { - return "", err - } - - buf := bytes.NewBuffer(nil) - if err := tx.Serialize(buf); err != nil { - return "", err - } - - txHex := hex.EncodeToString(buf.Bytes()) - return a.explorer.Broadcast(txHex) -} - -func (a *service) sendExpiredBoardingUtxos( - ctx context.Context, to string, opts *unrollOptions, -) (string, error) { - pkscript, err := toOutputScript(to, a.Network) - if err != nil { - return "", err - } - - a.txLock.Lock() - defer a.txLock.Unlock() - - utxos, err := a.getExpiredBoardingUtxos(ctx, nil) - if err != nil { - return "", err - } - - targetAmount := uint64(0) - for _, u := range utxos { - targetAmount += u.Amount - } - - if targetAmount == 0 { - return "", fmt.Errorf("no expired boarding funds available") - } - - ptx, err := psbt.New(nil, nil, 2, 0, nil) - if err != nil { - return "", err - } - - updater, err := psbt.NewUpdater(ptx) - if err != nil { - return "", err - } - - updater.Upsbt.UnsignedTx.AddTxOut(&wire.TxOut{ - Value: int64(targetAmount), - PkScript: pkscript, - }) - updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{}) - - if err := a.addInputs(ctx, updater, utxos); err != nil { - return "", err - } - - vbytes := computeVSize(updater.Upsbt.UnsignedTx) - feeRate, err := a.explorer.GetFeeRate() - if err != nil { - return "", err - } - feeAmount := uint64(math.Ceil(float64(vbytes)*feeRate) + 50) - - if targetAmount-feeAmount <= a.Dust { - return "", fmt.Errorf("not enough funds to cover network fees") - } - - updater.Upsbt.UnsignedTx.TxOut[0].Value -= int64(feeAmount) - - unsignedTx, _ := ptx.B64Encode() - - signedTx, err := a.identity.SignTransaction(ctx, unsignedTx, opts.signingKeys) - if err != nil { - return "", err - } - - ptx, err = psbt.NewFromRawBytes(strings.NewReader(signedTx), true) - if err != nil { - return "", err - } - - for i := range ptx.Inputs { - if err := psbt.Finalize(ptx, i); err != nil { - return "", err - } - } - - return ptx.B64Encode() -} - -func (a *service) getExpiredBoardingUtxos( - ctx context.Context, opts *getVtxosFilter, -) ([]types.Utxo, error) { - _, _, boardingAddrs, _, err := a.getAddresses(ctx) - if err != nil { - return nil, err - } - - expired := make([]types.Utxo, 0) - for _, addr := range boardingAddrs { - boardingScript, err := script.ParseVtxoScript(addr.Tapscripts) - if err != nil { - return nil, err - } - - boardingTimeout, err := boardingScript.SmallestExitDelay() - if err != nil { - return nil, err - } - - boardingUtxos, err := a.explorer.GetUtxos([]string{addr.Address}) - if err != nil { - return nil, err - } - - now := time.Now() - - for _, utxo := range boardingUtxos { - if opts != nil && len(opts.outpoints) > 0 { - utxoOutpoint := types.Outpoint{ - Txid: utxo.Txid, - VOut: utxo.Vout, - } - found := false - for _, outpoint := range opts.outpoints { - if outpoint == utxoOutpoint { - found = true - break - } - } - - if !found { - continue - } - } - - u := utxo.ToUtxo(*boardingTimeout, addr.Tapscripts) - if u.SpendableAt.Before(now) || u.SpendableAt.Equal(now) { - expired = append(expired, u) - } - } - } - - return expired, nil -} - -func (a *service) addInputs( - ctx context.Context, updater *psbt.Updater, utxos []types.Utxo, -) error { - for _, utxo := range utxos { - vtxoScript, err := script.ParseVtxoScript(utxo.Tapscripts) - if err != nil { - return err - } - - previousHash, err := chainhash.NewHashFromStr(utxo.Txid) - if err != nil { - return err - } - - sequence, err := utxo.Sequence() - if err != nil { - return err - } - - pkScript, err := hex.DecodeString(utxo.Script) - if err != nil { - return err - } - - updater.Upsbt.UnsignedTx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: *previousHash, - Index: utxo.VOut, - }, - Sequence: sequence, - }) - - exitClosures := vtxoScript.ExitClosures() - if len(exitClosures) <= 0 { - return fmt.Errorf("no exit closures found") - } - - exitClosure := exitClosures[0] - - exitScript, err := exitClosure.Script() - if err != nil { - return err - } - - _, taprootTree, err := vtxoScript.TapTree() - if err != nil { - return err - } - - exitLeaf := txscript.NewBaseTapLeaf(exitScript) - leafProof, err := taprootTree.GetTaprootMerkleProof(exitLeaf.TapHash()) - if err != nil { - return fmt.Errorf("failed to get taproot merkle proof: %s", err) - } - - updater.Upsbt.Inputs = append(updater.Upsbt.Inputs, psbt.PInput{ - WitnessUtxo: &wire.TxOut{ - Value: int64(utxo.Amount), - PkScript: pkScript, - }, - TaprootLeafScript: []*psbt.TaprootTapLeafScript{ - { - ControlBlock: leafProof.ControlBlock, - Script: leafProof.Script, - LeafVersion: txscript.BaseLeafVersion, - }, - }, - }) - } - - return nil -} - -func (a *service) getMatureUtxos(ctx context.Context) ([]types.Utxo, error) { - _, _, _, redemptionAddrs, err := a.getAddresses(ctx) - if err != nil { - return nil, err - } - - now := time.Now() - - utxos := make([]types.Utxo, 0) - addresses := make([]string, 0, len(redemptionAddrs)) - addrTapscripts := make(map[string][]string) - for _, addr := range redemptionAddrs { - addresses = append(addresses, addr.Address) - // nolint - script, _ := toOutputScript(addr.Address, a.Network) - addrTapscripts[hex.EncodeToString(script)] = addr.Tapscripts - } - - fetchedUtxos, err := a.explorer.GetUtxos(addresses) - if err != nil { - return nil, err - } - - for _, utxo := range fetchedUtxos { - tapscripts := addrTapscripts[utxo.Script] - u := utxo.ToUtxo(a.UnilateralExitDelay, tapscripts) - if u.SpendableAt.Before(now) { - utxos = append(utxos, u) - } - } - - return utxos, nil -} - -func (a *service) getRedeemBranches( - ctx context.Context, vtxos []types.Vtxo, -) (map[string]*redemption.CovenantlessRedeemBranch, error) { - redeemBranches := make(map[string]*redemption.CovenantlessRedeemBranch, 0) - - for _, vtxo := range vtxos { - redeemBranch, err := redemption.NewRedeemBranch(ctx, a.explorer, a.indexer, vtxo) - if err != nil { - return nil, err - } - - redeemBranches[vtxo.Txid] = redeemBranch - } - - return redeemBranches, nil -} diff --git a/pkg/client-lib/redemption/redeem.go b/pkg/client-lib/unroll/branch.go similarity index 78% rename from pkg/client-lib/redemption/redeem.go rename to pkg/client-lib/unroll/branch.go index 6fa47c46a..3f9e12c7e 100644 --- a/pkg/client-lib/redemption/redeem.go +++ b/pkg/client-lib/unroll/branch.go @@ -1,4 +1,4 @@ -package redemption +package unroll import ( "bytes" @@ -10,26 +10,22 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/script" "github.com/arkade-os/arkd/pkg/ark-lib/txutils" - "github.com/arkade-os/arkd/pkg/client-lib/explorer" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" "github.com/btcsuite/btcd/btcutil/psbt" ) -type CovenantlessRedeemBranch struct { - vtxo types.Vtxo - branch []indexer.ChainWithExpiry - explorer explorer.Explorer - indexer indexer.Indexer +type RedeemBranch struct { + vtxo clientlib.Vtxo + branch []clientlib.ChainWithExpiry + explorer clientlib.Explorer + indexer clientlib.Indexer } func NewRedeemBranch( ctx context.Context, - explorer explorer.Explorer, - indexerSvc indexer.Indexer, - vtxo types.Vtxo, -) (*CovenantlessRedeemBranch, error) { - chain, err := indexerSvc.GetVtxoChain(ctx, types.Outpoint{ + explorer clientlib.Explorer, indexer clientlib.Indexer, vtxo clientlib.Vtxo, +) (*RedeemBranch, error) { + chain, err := indexer.GetVtxoChain(ctx, clientlib.Outpoint{ Txid: vtxo.Txid, VOut: vtxo.VOut, }) @@ -37,24 +33,24 @@ func NewRedeemBranch( return nil, err } - return &CovenantlessRedeemBranch{ + return &RedeemBranch{ vtxo: vtxo, branch: chain.Chain, explorer: explorer, - indexer: indexerSvc, + indexer: indexer, }, nil } // RedeemPath returns the list of transactions to broadcast in order to access the vtxo output // due to current P2A relay policy, we can't broadcast the branch tx until its parent tx is // confirmed so we'll broadcast only the first tx of every branch -func (r *CovenantlessRedeemBranch) NextRedeemTx() (string, error) { +func (r *RedeemBranch) NextRedeemTx() (string, error) { nextTxToBroadcast := "" for i := len(r.branch) - 1; i >= 0; i-- { tx := r.branch[i] // commitment txs are always onchain, so we can skip them switch tx.Type { - case indexer.IndexerChainedTxTypeCommitment, indexer.IndexerChainedTxTypeUnspecified: + case clientlib.IndexerChainedTxTypeCommitment, clientlib.IndexerChainedTxTypeUnspecified: continue } @@ -125,7 +121,9 @@ func (r *CovenantlessRedeemBranch) NextRedeemTx() (string, error) { args := make(map[string][]byte) if len(conditionWitnessFields) > 0 { var conditionWitnessBytes bytes.Buffer - if err := psbt.WriteTxWitness(&conditionWitnessBytes, conditionWitnessFields[0]); err != nil { + if err := psbt.WriteTxWitness( + &conditionWitnessBytes, conditionWitnessFields[0], + ); err != nil { return "", err } args[string(txutils.ArkFieldConditionWitness)] = conditionWitnessBytes.Bytes() @@ -166,7 +164,7 @@ func (r *CovenantlessRedeemBranch) NextRedeemTx() (string, error) { return hex.EncodeToString(txBytes.Bytes()), nil } -func (r *CovenantlessRedeemBranch) ExpiresAt() (*time.Time, error) { +func (r *RedeemBranch) ExpiresAt() (*time.Time, error) { lastKnownBlocktime := int64(0) for _, node := range r.branch { confirmed, _, err := r.explorer.GetTxBlockTime(node.Txid) @@ -186,8 +184,10 @@ func (r *CovenantlessRedeemBranch) ExpiresAt() (*time.Time, error) { return &t, nil } -// ErrPendingConfirmation is returned when computing the offchain path of a redeem branch. Due to P2A relay policy, only 1C1P packages are accepted. -// This error is returned when the tx is found onchain but not confirmed yet, allowing the user to know when to wait for the tx to be confirmed or to continue with the redemption. +// ErrPendingConfirmation is returned when computing the offchain path of a redeem branch. +// Due to P2A relay policy, only 1C1P packages are accepted. +// This error is returned when the tx is found onchain but not confirmed yet, allowing the user to +// know when to wait for the tx to be confirmed or to continue with the redemption. type ErrPendingConfirmation struct { Txid string } diff --git a/pkg/client-lib/unroll/complete.go b/pkg/client-lib/unroll/complete.go new file mode 100644 index 000000000..7fb7d4b9c --- /dev/null +++ b/pkg/client-lib/unroll/complete.go @@ -0,0 +1,153 @@ +package unroll + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "math" + "strings" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" +) + +// CompleteUnrollArgs configures CompleteUnroll: spends the user's matured +// onchain Ark UTXOs (those past their exit delay) to Receiver and broadcasts +// the resulting transaction. +type CompleteUnrollArgs struct { + Explorer clientlib.Explorer + SignTx clientlib.SignFn + ServerInfo clientlib.Info + ArkAddr clientlib.Address + Receiver string +} + +func (a CompleteUnrollArgs) validate() error { + if a.Explorer == nil { + return fmt.Errorf("missing explorer") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx function") + } + if len(a.ServerInfo.Network) <= 0 { + return fmt.Errorf("missing server info") + } + if a.ServerInfo.Dust == 0 { + return fmt.Errorf("missing server info") + } + if len(a.ArkAddr.Address) <= 0 { + return fmt.Errorf("missing ark address") + } + if len(a.ArkAddr.Tapscripts) <= 0 { + return fmt.Errorf("missing ark address tapscripts") + } + if len(a.Receiver) <= 0 { + return fmt.Errorf("missing receiver address") + } + netParams := clientlib.ToBitcoinNetwork(clientlib.NetworkFromString(a.ServerInfo.Network)) + if _, err := btcutil.DecodeAddress(a.Receiver, &netParams); err != nil { + return fmt.Errorf("invalid receiver address") + } + return nil +} + +// CompleteUnroll spends the user's matured onchain Ark UTXOs (those past their +// exit delay) to args.Receiver, signs the resulting transaction and broadcasts +// it. Returns the broadcast response from the explorer. +func CompleteUnroll(ctx context.Context, args CompleteUnrollArgs) (string, error) { + if err := args.validate(); err != nil { + return "", fmt.Errorf("invalid args: %w", err) + } + + network := clientlib.NetworkFromString(args.ServerInfo.Network) + + pkscript, err := toOutputScript(args.Receiver, network) + if err != nil { + return "", err + } + + utxos, err := getMatureUtxos(ctx, args.Explorer, args.ArkAddr, network) + if err != nil { + return "", err + } + + targetAmount := uint64(0) + for _, u := range utxos { + targetAmount += u.Amount + } + + if targetAmount == 0 { + return "", fmt.Errorf("no mature funds available") + } + + ptx, err := psbt.New(nil, nil, 2, 0, nil) + if err != nil { + return "", err + } + + updater, err := psbt.NewUpdater(ptx) + if err != nil { + return "", err + } + + updater.Upsbt.UnsignedTx.AddTxOut(&wire.TxOut{ + Value: int64(targetAmount), + PkScript: pkscript, + }) + updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{}) + + if err := addInputs(updater, utxos); err != nil { + return "", err + } + + vbytes := computeVSize(updater.Upsbt.UnsignedTx) + feeRate, err := args.Explorer.GetFeeRate() + if err != nil { + return "", err + } + + feeAmount := uint64(math.Ceil(float64(vbytes)*feeRate) + 100) + + if targetAmount-feeAmount <= args.ServerInfo.Dust { + return "", fmt.Errorf("not enough funds to cover network fees") + } + + updater.Upsbt.UnsignedTx.TxOut[0].Value -= int64(feeAmount) + + unsignedTx, err := ptx.B64Encode() + if err != nil { + return "", err + } + + signedTx, err := args.SignTx(ctx, unsignedTx) + if err != nil { + return "", err + } + + ptx, err = psbt.NewFromRawBytes(strings.NewReader(signedTx), true) + if err != nil { + return "", err + } + + for i := range ptx.Inputs { + if err := psbt.Finalize(ptx, i); err != nil { + return "", err + } + } + + tx, err := psbt.Extract(ptx) + if err != nil { + return "", err + } + + buf := bytes.NewBuffer(nil) + if err := tx.Serialize(buf); err != nil { + return "", err + } + + txHex := hex.EncodeToString(buf.Bytes()) + return args.Explorer.Broadcast(txHex) +} diff --git a/pkg/client-lib/unroll/types.go b/pkg/client-lib/unroll/types.go new file mode 100644 index 000000000..203c3b8f3 --- /dev/null +++ b/pkg/client-lib/unroll/types.go @@ -0,0 +1,13 @@ +package unroll + +import "fmt" + +// UnrollRes is the result of a single Unroll iteration (one parent + child 1C1P package). +type UnrollRes struct { + ParentTx string + ParentTxid string + ChildTx string + ChildTxid string +} + +var ErrWaitingForConfirmation = fmt.Errorf("waiting for confirmation(s), please retry later") diff --git a/pkg/client-lib/unroll/unroll.go b/pkg/client-lib/unroll/unroll.go new file mode 100644 index 000000000..96982f093 --- /dev/null +++ b/pkg/client-lib/unroll/unroll.go @@ -0,0 +1,286 @@ +package unroll + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "math" + "strings" + + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + log "github.com/sirupsen/logrus" +) + +// UnrollArgs configures Unroll: builds 1C1P bump packages for each given +// vtxo's next branch tx and broadcasts each parent+child pair. +type UnrollArgs struct { + Explorer clientlib.Explorer + Indexer clientlib.Indexer + SignTx clientlib.SignFn + ServerInfo clientlib.Info + Vtxos []clientlib.Vtxo + BumpAddr string + BumpPubKey *btcec.PublicKey +} + +func (a UnrollArgs) validate() error { + if a.Explorer == nil { + return fmt.Errorf("missing explorer") + } + if a.Indexer == nil { + return fmt.Errorf("missing indexer") + } + if a.SignTx == nil { + return fmt.Errorf("missing sign tx function") + } + if a.BumpPubKey == nil { + return fmt.Errorf("missing bump pub key") + } + if len(a.ServerInfo.Network) <= 0 { + return fmt.Errorf("missing server info") + } + if len(a.Vtxos) <= 0 { + return fmt.Errorf("missing vtxos to unroll") + } + if len(a.BumpAddr) <= 0 { + return fmt.Errorf("missing bump address") + } + netParams := clientlib.ToBitcoinNetwork(clientlib.NetworkFromString(a.ServerInfo.Network)) + if _, err := btcutil.DecodeAddress(a.BumpAddr, &netParams); err != nil { + return fmt.Errorf("invalid bump address") + } + return nil +} + +// Unroll iterates over each vtxo's redeem branch, builds a 1C1P bump package +// (parent branch tx + child anchor-bumping tx) and broadcasts the package. +func Unroll(ctx context.Context, args UnrollArgs) ([]UnrollRes, error) { + if err := args.validate(); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + vtxos := args.Vtxos + + totalVtxosAmount := uint64(0) + for _, vtxo := range vtxos { + totalVtxosAmount += vtxo.Amount + } + + // transactionsMap avoid duplicates + transactionsMap := make(map[string]struct{}, 0) + transactions := make([]string, 0) + + branches, err := getBranchesToUnroll(ctx, args.Explorer, args.Indexer, vtxos) + if err != nil { + return nil, err + } + + isWaitingForConfirmation := false + + for _, branch := range branches { + nextTx, err := branch.NextRedeemTx() + if err != nil { + if err, ok := err.(ErrPendingConfirmation); ok { + // the branch tx is in the mempool, we must wait for confirmation + // print only, do not make the function to fail + // continue to try other branches + log.Debug(err.Error()) + isWaitingForConfirmation = true + continue + } + + return nil, err + } + + if _, ok := transactionsMap[nextTx]; !ok { + transactions = append(transactions, nextTx) + transactionsMap[nextTx] = struct{}{} + } + } + + if len(transactions) == 0 { + if isWaitingForConfirmation { + return nil, ErrWaitingForConfirmation + } + + return nil, nil + } + + res := make([]UnrollRes, 0, len(transactions)) + for _, parent := range transactions { + var parentTx wire.MsgTx + if err := parentTx.Deserialize(hex.NewDecoder(strings.NewReader(parent))); err != nil { + return nil, err + } + + childTxid, child, err := bumpAnchorTx(ctx, args, &parentTx) + if err != nil { + return nil, err + } + + // broadcast the package (parent + child) + packageResponse, err := args.Explorer.Broadcast(parent, child) + if err != nil { + return nil, err + } + + res = append(res, UnrollRes{ + ParentTx: parent, + ParentTxid: parentTx.TxID(), + ChildTx: child, + ChildTxid: childTxid, + }) + log.Debugf("package broadcasted: %s", packageResponse) + } + + return res, nil +} + +// bumpAnchorTx builds and signs a transaction bumping the fees for a given tx with P2A output. +// Makes use of args.BumpAddr/args.BumpPubKey to select UTXOs to pay fees for parent. +func bumpAnchorTx( + ctx context.Context, args UnrollArgs, parent *wire.MsgTx, +) (string, string, error) { + anchor, err := txutils.FindAnchorOutpoint(parent) + if err != nil { + return "", "", err + } + + // estimate for the size of the bump transaction + weightEstimator := input.TxWeightEstimator{} + + // WeightEstimator doesn't support P2A size, using P2WSH will lead to a small overestimation + // TODO use the exact P2A size + weightEstimator.AddNestedP2WSHInput(lntypes.VByte(3).ToWU()) + + // We assume only one UTXO will be selected to have a correct estimation + weightEstimator.AddTaprootKeySpendInput(txscript.SigHashDefault) + weightEstimator.AddP2TROutput() + + childVSize := weightEstimator.Weight().ToVB() + + packageSize := childVSize + computeVSize(parent) + feeRate, err := args.Explorer.GetFeeRate() + if err != nil { + return "", "", err + } + + fees := uint64(math.Ceil(float64(packageSize) * feeRate)) + + addr := args.BumpAddr + pkScript, err := toOutputScript(addr, clientlib.NetworkFromString(args.ServerInfo.Network)) + if err != nil { + return "", "", err + } + + selectedCoins := make([]clientlib.ExplorerUtxo, 0) + selectedAmount := uint64(0) + amountToSelect := int64(fees) - txutils.ANCHOR_VALUE + + utxos, err := args.Explorer.GetUtxos([]string{addr}) + if err != nil { + return "", "", err + } + + for _, utxo := range utxos { + selectedCoins = append(selectedCoins, utxo) + selectedAmount += utxo.Amount + amountToSelect -= int64(utxo.Amount) + if amountToSelect <= 0 { + break + } + } + + if amountToSelect > 0 { + return "", "", fmt.Errorf("not enough funds to select %d", amountToSelect) + } + + changeAmount := selectedAmount - fees + + inputs := []*wire.OutPoint{anchor} + sequences := []uint32{ + wire.MaxTxInSequenceNum, + } + outputs := []*wire.TxOut{ + { + Value: int64(changeAmount), + PkScript: pkScript, + }, + } + + for _, utxo := range selectedCoins { + txid, err := chainhash.NewHashFromStr(utxo.Txid) + if err != nil { + return "", "", err + } + inputs = append(inputs, &wire.OutPoint{ + Hash: *txid, + Index: utxo.Vout, + }) + sequences = append(sequences, wire.MaxTxInSequenceNum) + } + + ptx, err := psbt.New(inputs, outputs, 3, 0, sequences) + if err != nil { + return "", "", err + } + + ptx.Inputs[0].WitnessUtxo = txutils.AnchorOutput() + + for i, utxo := range selectedCoins { + pkScript, err := hex.DecodeString(utxo.Script) + if err != nil { + return "", "", err + } + + ptx.Inputs[i+1].WitnessUtxo = &wire.TxOut{ + Value: int64(utxo.Amount), + PkScript: pkScript, + } + ptx.Inputs[i+1].TaprootInternalKey = schnorr.SerializePubKey(args.BumpPubKey) + } + + b64, err := ptx.B64Encode() + if err != nil { + return "", "", err + } + + tx, err := args.SignTx(ctx, b64) + if err != nil { + return "", "", err + } + + signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true) + if err != nil { + return "", "", err + } + + for inIndex := range signedPtx.Inputs[1:] { + if _, err := psbt.MaybeFinalize(signedPtx, inIndex+1); err != nil { + return "", "", err + } + } + + childTx, err := txutils.ExtractWithAnchors(signedPtx) + if err != nil { + return "", "", err + } + + var serializedTx bytes.Buffer + if err := childTx.Serialize(&serializedTx); err != nil { + return "", "", err + } + + return childTx.TxID(), hex.EncodeToString(serializedTx.Bytes()), nil +} diff --git a/pkg/client-lib/unroll/utils.go b/pkg/client-lib/unroll/utils.go new file mode 100644 index 000000000..4d8f75d93 --- /dev/null +++ b/pkg/client-lib/unroll/utils.go @@ -0,0 +1,170 @@ +package unroll + +import ( + "context" + "encoding/hex" + "fmt" + "time" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/script" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lntypes" +) + +func computeVSize(tx *wire.MsgTx) lntypes.VByte { + baseSize := tx.SerializeSizeStripped() + totalSize := tx.SerializeSize() // including witness + weight := totalSize + baseSize*3 + return lntypes.WeightUnit(uint64(weight)).ToVB() +} + +func toOutputScript(onchainAddress string, network arklib.Network) ([]byte, error) { + netParams := clientlib.ToBitcoinNetwork(network) + rcvAddr, err := btcutil.DecodeAddress(onchainAddress, &netParams) + if err != nil { + return nil, err + } + + return txscript.PayToAddrScript(rcvAddr) +} + +func addInputs(updater *psbt.Updater, utxos []clientlib.Utxo) error { + for _, utxo := range utxos { + vtxoScript, err := script.ParseVtxoScript(utxo.Tapscripts) + if err != nil { + return err + } + + previousHash, err := chainhash.NewHashFromStr(utxo.Txid) + if err != nil { + return err + } + + sequence, err := utxo.Sequence() + if err != nil { + return err + } + + pkScript, err := hex.DecodeString(utxo.Script) + if err != nil { + return err + } + + updater.Upsbt.UnsignedTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *previousHash, + Index: utxo.VOut, + }, + Sequence: sequence, + }) + + exitClosures := vtxoScript.ExitClosures() + if len(exitClosures) <= 0 { + return fmt.Errorf("no exit closures found") + } + + exitClosure := exitClosures[0] + + exitScript, err := exitClosure.Script() + if err != nil { + return err + } + + _, taprootTree, err := vtxoScript.TapTree() + if err != nil { + return err + } + + exitLeaf := txscript.NewBaseTapLeaf(exitScript) + leafProof, err := taprootTree.GetTaprootMerkleProof(exitLeaf.TapHash()) + if err != nil { + return fmt.Errorf("failed to get taproot merkle proof: %s", err) + } + + updater.Upsbt.Inputs = append(updater.Upsbt.Inputs, psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: int64(utxo.Amount), + PkScript: pkScript, + }, + TaprootLeafScript: []*psbt.TaprootTapLeafScript{ + { + ControlBlock: leafProof.ControlBlock, + Script: leafProof.Script, + LeafVersion: txscript.BaseLeafVersion, + }, + }, + }) + } + + return nil +} + +func getMatureUtxos( + ctx context.Context, + explorer clientlib.Explorer, + arkAddr clientlib.Address, + network arklib.Network, +) ([]clientlib.Utxo, error) { + rawScript, err := arkAddr.RawScript() + if err != nil { + return nil, err + } + + signingClosure, err := arkAddr.ExitClosure() + if err != nil { + return nil, err + } + + exitDelay, err := rawScript.SmallestExitDelay() + if err != nil { + return nil, err + } + + now := time.Now() + + addrTapscripts := make(map[string][]string) + // nolint + script, _ := toOutputScript(arkAddr.Address, network) + addrTapscripts[hex.EncodeToString(script)] = arkAddr.Tapscripts + + fetchedUtxos, err := explorer.GetUtxos([]string{arkAddr.Address}) + if err != nil { + return nil, err + } + + utxos := make([]clientlib.Utxo, 0) + for _, utxo := range fetchedUtxos { + tapscripts := addrTapscripts[utxo.Script] + u := utxo.ToUtxo(*exitDelay, tapscripts, signingClosure) + if u.RedeemableAt.Before(now) { + utxos = append(utxos, u) + } + } + return utxos, nil +} + +func getBranchesToUnroll( + ctx context.Context, + explorer clientlib.Explorer, + indexer clientlib.Indexer, + vtxos []clientlib.Vtxo, +) (map[string]*RedeemBranch, error) { + redeemBranches := make(map[string]*RedeemBranch, 0) + + for _, vtxo := range vtxos { + redeemBranch, err := NewRedeemBranch(ctx, explorer, indexer, vtxo) + if err != nil { + return nil, err + } + + redeemBranches[vtxo.Txid] = redeemBranch + } + + return redeemBranches, nil +} diff --git a/pkg/client-lib/unroll_ops.go b/pkg/client-lib/unroll_ops.go deleted file mode 100644 index 418acfa63..000000000 --- a/pkg/client-lib/unroll_ops.go +++ /dev/null @@ -1,40 +0,0 @@ -package wallet - -import ( - "fmt" - - "github.com/arkade-os/arkd/pkg/client-lib/types" -) - -type UnrollOption interface { - applyUnroll(*unrollOptions) error -} - -type unrollOptFn func(*unrollOptions) error - -func (f unrollOptFn) applyUnroll(o *unrollOptions) error { return f(o) } - -func WithUtxosToClaim(utxos []types.Utxo) UnrollOption { - return unrollOptFn(func(o *unrollOptions) error { - if len(o.utxos) > 0 { - return fmt.Errorf("utxos already set") - } - if len(utxos) <= 0 { - return fmt.Errorf("missing utxos") - } - o.utxos = make([]types.Utxo, len(utxos)) - copy(o.utxos, utxos) - return nil - }) -} - -type unrollOptions struct { - vtxos []types.Vtxo - utxos []types.Utxo - signingKeys map[string]string - receiver string -} - -func newDefaultUnrollOptions() *unrollOptions { - return &unrollOptions{} -} diff --git a/pkg/client-lib/utils.go b/pkg/client-lib/utils.go index 37b039f16..3a08d1d2a 100644 --- a/pkg/client-lib/utils.go +++ b/pkg/client-lib/utils.go @@ -1,1054 +1,303 @@ -package wallet +package clientlib import ( - "bytes" - "crypto/sha256" - "encoding/binary" - "encoding/hex" "fmt" - "math" - "slices" - "strconv" - "strings" - "time" + "sort" arklib "github.com/arkade-os/arkd/pkg/ark-lib" - "github.com/arkade-os/arkd/pkg/ark-lib/asset" - "github.com/arkade-os/arkd/pkg/ark-lib/extension" - "github.com/arkade-os/arkd/pkg/ark-lib/intent" - "github.com/arkade-os/arkd/pkg/ark-lib/note" - "github.com/arkade-os/arkd/pkg/ark-lib/offchain" + "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" "github.com/arkade-os/arkd/pkg/ark-lib/script" - "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/ark-lib/txutils" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/identity" - singlekeyidentity "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey" - identitystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store" - identityfilestore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store/file" - identityinmemorystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store/inmemory" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/lightningnetwork/lnd/lntypes" ) -func getClient( - supportedClients utils.SupportedType[utils.ClientFactory], - clientType, serverUrl string, withMonitorConn bool, -) (client.Client, error) { - factory := supportedClients[clientType] - return factory(serverUrl, withMonitorConn) -} - -func getIndexer( - supportedIndexers utils.SupportedType[utils.IndexerFactory], - clientType, serverUrl string, withMonitorConn bool, -) (indexer.Indexer, error) { - factory := supportedIndexers[clientType] - return factory(serverUrl, withMonitorConn) -} - -func getSingleKeyIdentity(datadir, storeType string) (identity.Identity, error) { - store, err := getIdentityStore(storeType, datadir) - if err != nil { - return nil, err - } - - return singlekeyidentity.NewIdentity(store) -} - -func getIdentityStore(storeType, datadir string) (identitystore.IdentityStore, error) { - switch storeType { - case types.InMemoryStore: - return identityinmemorystore.NewStore() - case types.FileStore: - return identityfilestore.NewStore(datadir) +// NetworkFromString resolves the textual network name reported by the server (mainnet, testnet, +// testnet4, signet, mutinynet, regtest) into the matching arklib.Network value. +// Unknown values fall back to arklib.Bitcoin (mainnet). +func NetworkFromString(net string) arklib.Network { + switch net { + case arklib.BitcoinTestNet.Name: + return arklib.BitcoinTestNet + case arklib.BitcoinTestNet4.Name: + return arklib.BitcoinTestNet4 + case arklib.BitcoinSigNet.Name: + return arklib.BitcoinSigNet + case arklib.BitcoinMutinyNet.Name: + return arklib.BitcoinMutinyNet + case arklib.BitcoinRegTest.Name: + return arklib.BitcoinRegTest + case arklib.Bitcoin.Name: + fallthrough default: - return nil, fmt.Errorf("unknown identity store type") - } -} - -func filterByOutpoints(vtxos []types.Vtxo, outpoints []types.Outpoint) []types.Vtxo { - filtered := make([]types.Vtxo, 0, len(vtxos)) - for _, vtxo := range vtxos { - for _, outpoint := range outpoints { - if vtxo.Outpoint == outpoint { - filtered = append(filtered, vtxo) - } - } - } - return filtered -} - -type arkTxInput struct { - types.VtxoWithTapTree - ForfeitLeafHash chainhash.Hash -} - -func validateReceivers( - network arklib.Network, ptx *psbt.Packet, receivers []types.Receiver, vtxoTree *tree.TxTree, -) error { - netParams := utils.ToBitcoinNetwork(network) - for _, receiver := range receivers { - isOnChain, onchainScript, err := utils.ParseBitcoinAddress(receiver.To, netParams) - if err != nil { - return fmt.Errorf("invalid receiver address: %s err = %s", receiver.To, err) - } - - if isOnChain { - if err := validateOnchainReceiver(ptx, receiver, onchainScript); err != nil { - return err - } - } else { - if err := validateOffchainReceiver(vtxoTree, receiver); err != nil { - return err + return arklib.Bitcoin + } +} + +// ToBitcoinNetwork maps an arklib.Network to the corresponding btcd chaincfg.Params used to +// decode/encode Bitcoin addresses on that network. Unknown networks fall back to mainnet params. +func ToBitcoinNetwork(net arklib.Network) chaincfg.Params { + switch net.Name { + case arklib.Bitcoin.Name: + return chaincfg.MainNetParams + case arklib.BitcoinTestNet.Name: + return chaincfg.TestNet3Params + //case arklib.BitcoinTestNet4.Name: //TODO uncomment once supported + // return chaincfg.TestNet4Params + case arklib.BitcoinSigNet.Name: + return chaincfg.SigNetParams + case arklib.BitcoinMutinyNet.Name: + return arklib.MutinyNetSigNetParams + case arklib.BitcoinRegTest.Name: + return chaincfg.RegressionNetParams + default: + return chaincfg.MainNetParams + } +} + +// CoinSelect picks boarding utxos and vtxos to cover the BTC amount of the given outputs. +// When feeEstimator is non-nil it also accounts for the per-input and per-output fees, growing +// the target amount accordingly. +// If the computed change is below dust it is folded into the selection by adding one more input +// (preferring an offchain vtxo, then a boarding utxo) when one is available, otherwise the change +// is dropped to zero. Returns the selected boarding utxos, the selected vtxos, and the leftover +// change in sats. +func CoinSelect( + boardingUtxos []Utxo, vtxos []Vtxo, + outputs []Receiver, dust uint64, feeEstimator *arkfee.Estimator, +) ([]Utxo, []Vtxo, uint64, error) { + selected, notSelected := make([]Vtxo, 0), make([]Vtxo, 0) + selectedBoarding, notSelectedBoarding := make([]Utxo, 0), make([]Utxo, 0) + selectedAmount := uint64(0) + + amount := uint64(0) + for _, output := range outputs { + amount += output.Amount + if feeEstimator != nil { + var fees arkfee.FeeAmount + var err error + arkFeeOutput := output.ToArkFeeOutput() + if output.IsOnchain() { + fees, err = feeEstimator.EvalOnchainOutput(arkFeeOutput) + } else { + fees, err = feeEstimator.EvalOffchainOutput(arkFeeOutput) } - } - } - return nil -} - -func validateOnchainReceiver( - ptx *psbt.Packet, receiver types.Receiver, onchainScript []byte, -) error { - found := false - for _, output := range ptx.UnsignedTx.TxOut { - if bytes.Equal(output.PkScript, onchainScript) { - if output.Value != int64(receiver.Amount) { - return fmt.Errorf( - "invalid collaborative exit output amount: got %d, want %d", - output.Value, receiver.Amount, - ) + if err != nil { + return nil, nil, 0, err } - found = true - break + amount += uint64(fees.ToSatoshis()) } } - if !found { - return fmt.Errorf("collaborative exit output not found: %s", receiver.To) - } - return nil -} -func validateOffchainReceiver(vtxoTree *tree.TxTree, receiver types.Receiver) error { - found := false - - rcvAddr, err := arklib.DecodeAddressV0(receiver.To) - if err != nil { - return err - } - - vtxoTapKey := schnorr.SerializePubKey(rcvAddr.VtxoTapKey) - - leaves := vtxoTree.Leaves() - for _, leaf := range leaves { - for outputIndex, output := range leaf.UnsignedTx.TxOut { - if len(output.PkScript) == 0 { - continue - } - - if bytes.Equal(output.PkScript[2:], vtxoTapKey) { - if output.Value != int64(receiver.Amount) { - continue - } + // Sort vtxos by expiration (oldest last) + sort.SliceStable(vtxos, func(i, j int) bool { + return vtxos[i].ExpiresAt.After(vtxos[j].ExpiresAt) + }) - found = true - if len(receiver.Assets) > 0 { - if err := validateAssetOutputs(leaf.UnsignedTx, outputIndex, receiver); err != nil { - return err - } - } - break - } - } + sort.SliceStable(boardingUtxos, func(i, j int) bool { + return boardingUtxos[i].RedeemableAt.Before(boardingUtxos[j].RedeemableAt) + }) - if found { + for _, boardingUtxo := range boardingUtxos { + if selectedAmount >= amount { + notSelectedBoarding = append(notSelectedBoarding, boardingUtxo) break } - } - - if !found { - return fmt.Errorf("offchain send output not found: %s", receiver.To) - } - return nil -} - -func validateAssetOutputs(tx *wire.MsgTx, outputIndex int, receiver types.Receiver) error { - ext, err := extension.NewExtensionFromTx(tx) - if err != nil { - return err - } - assetPacket := ext.GetAssetPacket() - if len(assetPacket) == 0 { - return fmt.Errorf("no asset packet found in transaction") - } + selectedBoarding = append(selectedBoarding, boardingUtxo) + selectedAmount += boardingUtxo.Amount - // For each expected asset, verify the asset group exists and contains the correct output - for _, expectedAsset := range receiver.Assets { - found := false - for _, assetGroup := range assetPacket { - // Skip issuances - if assetGroup.IsIssuance() { - continue - } - - if assetGroup.AssetId.String() == expectedAsset.AssetId { - if err := validateAssetGroupOutput(assetGroup.Outputs, outputIndex, expectedAsset); err != nil { - return err - } - found = true - break + if feeEstimator != nil { + fees, err := feeEstimator.EvalOnchainInput(boardingUtxo.ToArkFeeInput()) + if err != nil { + return nil, nil, 0, err } + amount += uint64(fees.ToSatoshis()) } - - if !found { - return fmt.Errorf("asset group not found in batch leaf") - } - } - - return nil -} - -func validateAssetGroupOutput( - outputs []asset.AssetOutput, - outputIndex int, - expectedAsset types.Asset, -) error { - found := false - for _, output := range outputs { - if int(output.Vout) != outputIndex { - continue - } - - if output.Amount != expectedAsset.Amount { - return fmt.Errorf( - "invalid asset output amount: got %d, want %d", - output.Amount, - expectedAsset.Amount, - ) - } - found = true - break - } - - if !found { - return fmt.Errorf("asset output not found in asset group: %s", expectedAsset.AssetId) - } - return nil -} - -func buildOffchainTx( - vtxos []arkTxInput, receivers []types.Receiver, serverUnrollScript []byte, dustLimit uint64, -) (string, []string, error) { - if len(vtxos) <= 0 { - return "", nil, fmt.Errorf("missing vtxos") } - ins := make([]offchain.VtxoInput, 0, len(vtxos)) for _, vtxo := range vtxos { - if len(vtxo.Tapscripts) <= 0 { - return "", nil, fmt.Errorf("missing tapscripts for vtxo %s", vtxo.Txid) - } - - vtxoTxID, err := chainhash.NewHashFromStr(vtxo.Txid) - if err != nil { - return "", nil, err - } - - vtxoOutpoint := &wire.OutPoint{ - Hash: *vtxoTxID, - Index: vtxo.VOut, - } - - vtxoScript, err := script.ParseVtxoScript(vtxo.Tapscripts) - if err != nil { - return "", nil, err - } - - _, vtxoTree, err := vtxoScript.TapTree() - if err != nil { - return "", nil, err - } - - leafProof, err := vtxoTree.GetTaprootMerkleProof(vtxo.ForfeitLeafHash) - if err != nil { - return "", nil, err - } - - ctrlBlock, err := txscript.ParseControlBlock(leafProof.ControlBlock) - if err != nil { - return "", nil, err - } - - tapscript := &waddrmgr.Tapscript{ - RevealedScript: leafProof.Script, - ControlBlock: ctrlBlock, - } - - ins = append(ins, offchain.VtxoInput{ - Outpoint: vtxoOutpoint, - Tapscript: tapscript, - Amount: int64(vtxo.Amount), - RevealedTapscripts: vtxo.Tapscripts, - }) - } - - outs := make([]*wire.TxOut, 0, len(receivers)) - - for i, receiver := range receivers { - if receiver.IsOnchain() { - return "", nil, fmt.Errorf("receiver %d is onchain", i) - } - - addr, err := arklib.DecodeAddressV0(receiver.To) - if err != nil { - return "", nil, err - } - - var newVtxoScript []byte - - if receiver.Amount < dustLimit { - newVtxoScript, err = script.SubDustScript(addr.VtxoTapKey) - } else { - newVtxoScript, err = script.P2TRScript(addr.VtxoTapKey) - } - if err != nil { - return "", nil, err - } - - outs = append(outs, &wire.TxOut{ - Value: int64(receiver.Amount), - PkScript: newVtxoScript, - }) - } - - arkPtx, checkpointPtxs, err := offchain.BuildTxs(ins, outs, serverUnrollScript) - if err != nil { - return "", nil, err - } - - arkTx, err := arkPtx.B64Encode() - if err != nil { - return "", nil, err - } - - checkpointTxs := make([]string, 0, len(checkpointPtxs)) - for _, ptx := range checkpointPtxs { - tx, err := ptx.B64Encode() - if err != nil { - return "", nil, err - } - checkpointTxs = append(checkpointTxs, tx) - } - - return arkTx, checkpointTxs, nil -} - -func inputsToDerivationPath(inputs []types.Outpoint, notesInputs []string) string { - // sort arknotes - slices.SortStableFunc(notesInputs, func(i, j string) int { - return strings.Compare(i, j) - }) - - // sort outpoints - slices.SortStableFunc(inputs, func(i, j types.Outpoint) int { - txidCmp := strings.Compare(i.Txid, j.Txid) - if txidCmp != 0 { - return txidCmp - } - return int(i.VOut - j.VOut) - }) - - // serialize outpoints and arknotes - - var buf bytes.Buffer - - for _, input := range inputs { - buf.WriteString(input.Txid) - buf.WriteString(strconv.Itoa(int(input.VOut))) - } - - for _, note := range notesInputs { - buf.WriteString(note) - } - - // hash the serialized data - hash := sha256.Sum256(buf.Bytes()) - - // convert hash to bip32 derivation path - // split the 32-byte hash into 8 uint32 values (4 bytes each) - path := "m" - for i := 0; i < 8; i++ { - // Convert 4 bytes to uint32 using big-endian encoding - segment := binary.BigEndian.Uint32(hash[i*4 : (i+1)*4]) - path += fmt.Sprintf("/%d'", segment) - } - - return path -} - -func extractCollaborativePath(tapscripts []string) ([]byte, *arklib.TaprootMerkleProof, error) { - vtxoScript, err := script.ParseVtxoScript(tapscripts) - if err != nil { - return nil, nil, err - } - - forfeitClosures := vtxoScript.ForfeitClosures() - if len(forfeitClosures) <= 0 { - return nil, nil, fmt.Errorf("no exit closures found") - } - - forfeitClosure := forfeitClosures[0] - forfeitScript, err := forfeitClosure.Script() - if err != nil { - return nil, nil, err - } - - taprootKey, taprootTree, err := vtxoScript.TapTree() - if err != nil { - return nil, nil, err - } - - forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) - leafProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) - if err != nil { - return nil, nil, fmt.Errorf("failed to get taproot merkle proof: %s", err) - } - pkScript, err := script.P2TRScript(taprootKey) - if err != nil { - return nil, nil, err - } - - return pkScript, leafProof, nil -} - -// convert regular coins (boarding, vtxos or notes) to intent proof inputs -// it also returns the necessary data used to sign the proof PSBT -func toIntentInputs( - boardingUtxos []types.Utxo, vtxos []types.VtxoWithTapTree, notes []string, -) ([]intent.Input, []*arklib.TaprootMerkleProof, [][]*psbt.Unknown, map[int][]types.Asset, error) { - inputs := make([]intent.Input, 0, len(boardingUtxos)+len(vtxos)) - signingLeaves := make([]*arklib.TaprootMerkleProof, 0, len(boardingUtxos)+len(vtxos)) - arkFields := make([][]*psbt.Unknown, 0, len(boardingUtxos)+len(vtxos)) - assetInputs := make(map[int][]types.Asset) - - for inputIndex, coin := range vtxos { - hash, err := chainhash.NewHashFromStr(coin.Txid) - if err != nil { - return nil, nil, nil, nil, err - } - outpoint := wire.NewOutPoint(hash, coin.VOut) - - pkScript, leafProof, err := extractCollaborativePath(coin.Tapscripts) - if err != nil { - return nil, nil, nil, nil, err - } - - signingLeaves = append(signingLeaves, leafProof) - - inputs = append(inputs, intent.Input{ - OutPoint: outpoint, - Sequence: wire.MaxTxInSequenceNum, - WitnessUtxo: &wire.TxOut{ - Value: int64(coin.Amount), - PkScript: pkScript, - }, - }) - - if len(coin.Assets) > 0 { - // in context of intent transaction, there is a "fake" input at index 0 - // that's why from the asset packet point of view, the index must be i+1 - assetInputs[inputIndex+1] = coin.Assets - } - - taptreeField, err := txutils.VtxoTaprootTreeField.Encode(coin.Tapscripts) - if err != nil { - return nil, nil, nil, nil, err - } - - arkFields = append(arkFields, []*psbt.Unknown{taptreeField}) - } - - for boardingIndex, coin := range boardingUtxos { - hash, err := chainhash.NewHashFromStr(coin.Txid) - if err != nil { - return nil, nil, nil, nil, err - } - outpoint := wire.NewOutPoint(hash, coin.VOut) - - pkScript, leafProof, err := extractCollaborativePath(coin.Tapscripts) - if err != nil { - return nil, nil, nil, nil, err + if selectedAmount >= amount { + notSelected = append(notSelected, vtxo) + break } - signingLeaves = append(signingLeaves, leafProof) + selected = append(selected, vtxo) + selectedAmount += vtxo.Amount - inputs = append(inputs, intent.Input{ - OutPoint: outpoint, - Sequence: wire.MaxTxInSequenceNum, - WitnessUtxo: &wire.TxOut{ - Value: int64(coin.Amount), - PkScript: pkScript, - }, - }) - - if len(coin.Assets) > 0 { - // boarding utxos sit after vtxos in the proof PSBT, and the +1 - // accounts for the fake intent input at index 0. - assetInputs[len(vtxos)+boardingIndex+1] = coin.Assets - } - - taptreeField, err := txutils.VtxoTaprootTreeField.Encode(coin.Tapscripts) - if err != nil { - return nil, nil, nil, nil, err + if feeEstimator != nil { + feesForInput, err := feeEstimator.EvalOffchainInput(vtxo.ToArkFeeInput()) + if err != nil { + return nil, nil, 0, err + } + amount += uint64(feesForInput.ToSatoshis()) } - arkFields = append(arkFields, []*psbt.Unknown{taptreeField}) } - nextInputIndex := len(inputs) - if nextInputIndex > 0 { - // if there is non-notes inputs, count the extra intent proof input - nextInputIndex++ + if selectedAmount < amount { + return nil, nil, 0, fmt.Errorf("not enough funds to cover amount %d", amount) } - for _, n := range notes { - parsedNote, err := note.NewNoteFromString(n) - if err != nil { - return nil, nil, nil, nil, err - } - - outpoint, input, err := parsedNote.IntentProofInput() - if err != nil { - return nil, nil, nil, nil, err - } + change := selectedAmount - amount - inputs = append(inputs, intent.Input{ - OutPoint: outpoint, - Sequence: wire.MaxTxInSequenceNum, - WitnessUtxo: &wire.TxOut{ - Value: input.WitnessUtxo.Value, - PkScript: input.WitnessUtxo.PkScript, - }, + if feeEstimator != nil { + fees, err := feeEstimator.EvalOffchainOutput(arkfee.Output{ + Amount: change, }) - - vtxoScript := parsedNote.VtxoScript() - - _, taprootTree, err := vtxoScript.TapTree() - if err != nil { - return nil, nil, nil, nil, err - } - - forfeitScript, err := vtxoScript.Closures[0].Script() if err != nil { - return nil, nil, nil, nil, err + return nil, nil, 0, err } - - forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) - leafProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to get taproot merkle proof: %s", err) - } - - nextInputIndex++ - // if the note vtxo is the first input, it will be used twice - if nextInputIndex == 1 { - nextInputIndex++ - } - - signingLeaves = append(signingLeaves, leafProof) - arkFields = append(arkFields, input.Unknowns) + change -= uint64(fees.ToSatoshis()) } - return inputs, signingLeaves, arkFields, assetInputs, nil -} - -func getOffchainBalanceDetails(amountByExpiration map[int64]uint64) (int64, []VtxoDetails) { - nextExpiration := int64(0) - details := make([]VtxoDetails, 0) - for timestamp, amount := range amountByExpiration { - if nextExpiration == 0 || timestamp < nextExpiration { - nextExpiration = timestamp - } + if change < dust { + if len(notSelected) > 0 { + selected = append(selected, notSelected[0]) + change += notSelected[0].Amount - fancyTime := time.Unix(timestamp, 0).Format(time.RFC3339) - details = append( - details, - VtxoDetails{ - ExpiryTime: fancyTime, - Amount: amount, - }, - ) - } - return nextExpiration, details -} - -func getFancyTimeExpiration(nextExpiration int64) string { - if nextExpiration == 0 { - return "" - } - - fancyTimeExpiration := "" - t := time.Unix(nextExpiration, 0) - if t.Before(time.Now().Add(48 * time.Hour)) { - // print the duration instead of the absolute time - until := time.Until(t) - seconds := math.Abs(until.Seconds()) - minutes := math.Abs(until.Minutes()) - hours := math.Abs(until.Hours()) - - if hours < 1 { - if minutes < 1 { - fancyTimeExpiration = fmt.Sprintf("%d seconds", int(seconds)) - } else { - fancyTimeExpiration = fmt.Sprintf("%d minutes", int(minutes)) + if feeEstimator != nil { + fees, err := feeEstimator.EvalOffchainInput(notSelected[0].ToArkFeeInput()) + if err != nil { + return nil, nil, 0, err + } + change -= uint64(fees.ToSatoshis()) + } + } else if len(notSelectedBoarding) > 0 { + selectedBoarding = append(selectedBoarding, notSelectedBoarding[0]) + change += notSelectedBoarding[0].Amount + + if feeEstimator != nil { + fees, err := feeEstimator.EvalOnchainInput(notSelectedBoarding[0].ToArkFeeInput()) + if err != nil { + return nil, nil, 0, err + } + change -= uint64(fees.ToSatoshis()) } } else { - fancyTimeExpiration = fmt.Sprintf("%d hours", int(hours)) - } - } else { - fancyTimeExpiration = t.Format(time.RFC3339) - } - return fancyTimeExpiration -} - -func computeVSize(tx *wire.MsgTx) lntypes.VByte { - baseSize := tx.SerializeSizeStripped() - totalSize := tx.SerializeSize() // including witness - weight := totalSize + baseSize*3 - return lntypes.WeightUnit(uint64(weight)).ToVB() -} - -func registerIntentMessage( - assetInputs map[int][]types.Asset, outputs []types.Receiver, cosignersPublicKeys []string, -) (string, []*wire.TxOut, extension.Extension, error) { - outputsTxOut := make([]*wire.TxOut, 0) - onchainOutputsIndexes := make([]int, 0) - - for i, output := range outputs { - txOut, isOnchain, err := output.ToTxOut() - if err != nil { - return "", nil, nil, err - } - - if isOnchain { - onchainOutputsIndexes = append(onchainOutputsIndexes, i) + change = 0 } - - outputsTxOut = append(outputsTxOut, txOut) } - var ext extension.Extension - if len(assetInputs) > 0 { - assetPacket, err := createAssetPacket(assetInputs, outputs, nil) - if err != nil { - return "", nil, nil, err - } - - ext = extension.Extension{assetPacket} - assetPacketOutput, err := ext.TxOut() - if err != nil { - return "", nil, nil, err - } - outputsTxOut = append(outputsTxOut, assetPacketOutput) - } - - message, err := intent.RegisterMessage{ - BaseMessage: intent.BaseMessage{ - Type: intent.IntentMessageTypeRegister, - }, - OnchainOutputIndexes: onchainOutputsIndexes, - CosignersPublicKeys: cosignersPublicKeys, - }.Encode() - if err != nil { - return "", nil, nil, err - } - - return message, outputsTxOut, ext, nil + return selectedBoarding, selected, change, nil } -func selectedCoinsToAssetInputs(selectedCoins []types.VtxoWithTapTree) map[int][]types.Asset { - assetInputs := make(map[int][]types.Asset) - for inputIndex, coin := range selectedCoins { - if len(coin.Assets) == 0 { - continue - } - assetInputs[inputIndex] = coin.Assets - } - return assetInputs -} - -// createAssetPacket computes the right packet for the given asset inputs and receivers -func createAssetPacket( - assetInputs map[int][]types.Asset, receivers []types.Receiver, changeReceiver *types.Receiver, -) (asset.Packet, error) { - if changeReceiver != nil { - receivers = append(receivers, *changeReceiver) - } +// CoinSelectAsset picks vtxos that, combined, hold at least `amount` units of the asset identified +// by assetID. Vtxos with no matching asset are filtered out up front. By default vtxos are sorted +// so that the ones nearest to expiry sit at the end of the slice, and the loop picks from the +// front — i.e. it preserves the soonest-to-expire balance for as long as possible. +// Pass withoutExpirySorting=true to consume the input order verbatim (useful when the caller has +// already ordered the vtxos). Returns the selected vtxos plus the leftover change in asset units. +func CoinSelectAsset( + vtxos []Vtxo, amount uint64, + assetID string, withoutExpirySorting bool, +) ([]Vtxo, uint64, error) { + selected := make([]Vtxo, 0) + selectedAmount := uint64(0) - type assetTransfer struct { - inputs []asset.AssetInput - outputs []asset.AssetOutput - } + filteredVtxos := make([]Vtxo, 0) - assetTransfers := make(map[string]*assetTransfer) - for inputIndex, assets := range assetInputs { - for _, a := range assets { - if _, exists := assetTransfers[a.AssetId]; !exists { - assetTransfers[a.AssetId] = &assetTransfer{ - inputs: make([]asset.AssetInput, 0), - outputs: make([]asset.AssetOutput, 0), + // filter out vtxos holding other assets (or no assets) + for _, vtxo := range vtxos { + if len(vtxo.Assets) > 0 { + for _, asset := range vtxo.Assets { + if asset.AssetId == assetID { + filteredVtxos = append(filteredVtxos, vtxo) + break } } - - input, err := asset.NewAssetInput(uint16(inputIndex), a.Amount) - if err != nil { - return nil, err - } - assetTransfers[a.AssetId].inputs = append( - assetTransfers[a.AssetId].inputs, - *input, - ) } } - for receiverIndex, receiver := range receivers { - if len(receiver.Assets) == 0 { - continue - } - - for _, ass := range receiver.Assets { - if _, exists := assetTransfers[ass.AssetId]; !exists { - return nil, fmt.Errorf("asset %s not found", ass.AssetId) - } + vtxos = filteredVtxos - output, err := asset.NewAssetOutput(uint16(receiverIndex), ass.Amount) - if err != nil { - return nil, err - } - assetTransfers[ass.AssetId].outputs = append( - assetTransfers[ass.AssetId].outputs, - *output, - ) - } + if !withoutExpirySorting { + // Sort vtxos by expiration (oldest last) + sort.SliceStable(vtxos, func(i, j int) bool { + return vtxos[i].ExpiresAt.After(vtxos[j].ExpiresAt) + }) } - assetGroups := make([]asset.AssetGroup, 0) - for assetId, inputsOutputs := range assetTransfers { - assetId, err := asset.NewAssetIdFromString(assetId) - if err != nil { - return nil, err + for _, vtxo := range vtxos { + if selectedAmount >= amount { + break } - - assetGroup, err := asset.NewAssetGroup( - assetId, - nil, - inputsOutputs.inputs, - inputsOutputs.outputs, - nil, - ) - if err != nil { - return nil, err + selected = append(selected, vtxo) + for _, asset := range vtxo.Assets { + if asset.AssetId == assetID { + selectedAmount += asset.Amount + break + } } - assetGroups = append(assetGroups, *assetGroup) } - if len(assetGroups) == 0 { - return nil, nil + if selectedAmount < amount { + return nil, 0, fmt.Errorf("not enough funds to cover amount %d", amount) } - return asset.NewPacket(assetGroups) + change := selectedAmount - amount + return selected, change, nil } -// addExtension inserts an extension OP_RETURN (asset packet + extras) right -// before the P2A anchor output, which remains last. If both assetPacket and -// extraPkts are empty it is a no-op. Duplicate packet types are rejected. -func addExtension( - ptx *psbt.Packet, assetPacket asset.Packet, extraPkts []extension.Packet, -) error { - // Nothing to add when we have neither an asset packet nor extras. - if len(assetPacket) == 0 && len(extraPkts) == 0 { - return nil - } - - pkts := make([]extension.Packet, 0, 1+len(extraPkts)) - if len(assetPacket) > 0 { - pkts = append(pkts, assetPacket) - } - pkts = append(pkts, extraPkts...) - - ext, err := extension.NewExtensionFromPackets(pkts...) +// ParseBitcoinAddress attempts to decode addr as a Bitcoin address on the given network. +// Returns (true, output script, nil) on success, (false, nil, nil) when the address can't be +// decoded on that network (i.e. the caller can treat the input as "not an on-chain address"), and +// (false, nil, err) only when a successfully-decoded address fails to produce a PayToAddr script. +func ParseBitcoinAddress(addr string, net chaincfg.Params) ( + bool, []byte, error, +) { + btcAddr, err := btcutil.DecodeAddress(addr, &net) if err != nil { - return err + return false, nil, nil } - packetOut, err := ext.TxOut() + onchainScript, err := txscript.PayToAddrScript(btcAddr) if err != nil { - return fmt.Errorf("building extension txout: %w", err) - } - // Insert the extension output immediately before the P2A anchor, keeping - // ptx.Outputs[i] aligned with ptx.UnsignedTx.TxOut[i]. The anchor's own - // PSBT-level metadata must follow its TxOut to the new last index; the - // fresh empty POutput goes next to the EXT TxOut. - lastIdx := len(ptx.UnsignedTx.TxOut) - 1 - p2aTxOut := ptx.UnsignedTx.TxOut[lastIdx] - p2aPOutput := ptx.Outputs[lastIdx] - ptx.UnsignedTx.TxOut[lastIdx] = packetOut - ptx.Outputs[lastIdx] = psbt.POutput{} - ptx.UnsignedTx.TxOut = append(ptx.UnsignedTx.TxOut, p2aTxOut) - ptx.Outputs = append(ptx.Outputs, p2aPOutput) - return nil -} - -func findVtxosSpentInSettlement(vtxos []types.Vtxo, vtxo types.Vtxo) []types.Vtxo { - if vtxo.Preconfirmed { - return nil - } - return findVtxosSettled(vtxos, vtxo.CommitmentTxids[0]) -} - -func findVtxosSettled(vtxos []types.Vtxo, id string) []types.Vtxo { - var result []types.Vtxo - leftVtxos := make([]types.Vtxo, 0) - for _, v := range vtxos { - if v.SettledBy == id { - result = append(result, v) - } else { - leftVtxos = append(leftVtxos, v) - } + return false, nil, err } - // Update the given list with only the left vtxos. - copy(vtxos, leftVtxos) - return result -} - -func findVtxosResultedFromSettledBy(vtxos []types.Vtxo, commitmentTxid string) []types.Vtxo { - var result []types.Vtxo - for _, v := range vtxos { - if v.Preconfirmed || len(v.CommitmentTxids) != 1 { - continue - } - if v.CommitmentTxids[0] == commitmentTxid { - result = append(result, v) - } - } - return result -} - -func findVtxosSpent(vtxos []types.Vtxo, id string) []types.Vtxo { - var result []types.Vtxo - leftVtxos := make([]types.Vtxo, 0) - for _, v := range vtxos { - if v.ArkTxid == id { - result = append(result, v) - } else { - leftVtxos = append(leftVtxos, v) - } - } - // Update the given list with only the left vtxos. - copy(vtxos, leftVtxos) - return result -} - -func reduceVtxosAmount(vtxos []types.Vtxo) uint64 { - var total uint64 - for _, v := range vtxos { - total += v.Amount - } - return total -} - -func findVtxosSpentInPayment(vtxos []types.Vtxo, vtxo types.Vtxo) []types.Vtxo { - return findVtxosSpent(vtxos, vtxo.Txid) + return true, onchainScript, nil } -func findVtxosResultedFromSpentBy(vtxos []types.Vtxo, spentByTxid string) []types.Vtxo { - var result []types.Vtxo - for _, v := range vtxos { - if v.Txid == spentByTxid { - result = append(result, v) - } +// ParseClosure derives the on-chain pkScript and the taproot merkle proof that authorizes the +// given closure (typically the forfeit script) under the vtxo's tapscript tree. +// Used by Vtxo/Utxo to expose the signing leaf needed when adding the input to an intent proof or +// ark transaction. Returns errors scoped to outpoint so failures are traceable to a specific input. +func ParseClosure( + outpoint Outpoint, closure script.Closure, tapscripts []string, +) ([]byte, *arklib.TaprootMerkleProof, error) { + if closure == nil { + return nil, nil, fmt.Errorf("%s has no signing closure", outpoint.String()) } - return result -} - -func getVtxo(usedVtxos []types.Vtxo, spentByVtxos []types.Vtxo) types.Vtxo { - if len(usedVtxos) > 0 { - return usedVtxos[0] - } else if len(spentByVtxos) > 0 { - return spentByVtxos[0] + if len(tapscripts) <= 0 { + return nil, nil, fmt.Errorf("%s has no tapscripts", outpoint.String()) } - return types.Vtxo{} -} -func ecPubkeyFromHex(pubkey string) (*btcec.PublicKey, error) { - buf, err := hex.DecodeString(pubkey) + vtxoScript, err := script.ParseVtxoScript(tapscripts) if err != nil { - return nil, err + return nil, nil, fmt.Errorf("%s has invalid tapscripts: %w", outpoint.String(), err) } - return btcec.ParsePubKey(buf) -} - -func getBatchExpiryLocktime(expiry uint32) arklib.RelativeLocktime { - if expiry >= 512 { - return arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: expiry} - } - return arklib.RelativeLocktime{Type: arklib.LocktimeTypeBlock, Value: expiry} -} - -func toOutputScript(onchainAddress string, network arklib.Network) ([]byte, error) { - netParams := utils.ToBitcoinNetwork(network) - rcvAddr, err := btcutil.DecodeAddress(onchainAddress, &netParams) + forfeitScript, err := closure.Script() if err != nil { - return nil, err - } - - return txscript.PayToAddrScript(rcvAddr) -} - -func verifySignedCheckpoints( - originalCheckpoints, signedCheckpoints []string, signerpubkey *btcec.PublicKey, -) error { - // index by txid - indexedOriginalCheckpoints := make(map[string]*psbt.Packet) - indexedSignedCheckpoints := make(map[string]*psbt.Packet) - - for _, cp := range originalCheckpoints { - originalPtx, err := psbt.NewFromRawBytes(strings.NewReader(cp), true) - if err != nil { - return err - } - indexedOriginalCheckpoints[originalPtx.UnsignedTx.TxID()] = originalPtx - } - - for _, cp := range signedCheckpoints { - signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(cp), true) - if err != nil { - return err - } - indexedSignedCheckpoints[signedPtx.UnsignedTx.TxID()] = signedPtx - } - - for txid, originalPtx := range indexedOriginalCheckpoints { - signedPtx, ok := indexedSignedCheckpoints[txid] - if !ok { - return fmt.Errorf("signed checkpoint %s not found", txid) - } - if err := verifyOffchainPsbt(originalPtx, signedPtx, signerpubkey); err != nil { - return err - } + return nil, nil, fmt.Errorf( + "%s has invalid signing closure: %w", outpoint.String(), err, + ) } - return nil -} - -func verifySignedArk(original, signed string, signerPubKey *btcec.PublicKey) error { - originalPtx, err := psbt.NewFromRawBytes(strings.NewReader(original), true) + taprootKey, taprootTree, err := vtxoScript.TapTree() if err != nil { - return err + return nil, nil, fmt.Errorf("%s has invalid taptree: %w", outpoint.String(), err) } - signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(signed), true) + forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) + leafProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) if err != nil { - return err - } - - return verifyOffchainPsbt(originalPtx, signedPtx, signerPubKey) -} - -func verifyOffchainPsbt(original, signed *psbt.Packet, signerpubkey *btcec.PublicKey) error { - xonlySigner := schnorr.SerializePubKey(signerpubkey) - - if original.UnsignedTx.TxID() != signed.UnsignedTx.TxID() { - return fmt.Errorf("invalid offchain tx : txids mismatch") - } - - if len(original.Inputs) != len(signed.Inputs) { - return fmt.Errorf( - "input count mismatch: expected %d, got %d", - len(original.Inputs), - len(signed.Inputs), + return nil, nil, fmt.Errorf( + "%s has invalid signing script: %w", outpoint.String(), err, ) } - - if len(original.UnsignedTx.TxIn) != len(signed.UnsignedTx.TxIn) { - return fmt.Errorf( - "transaction input count mismatch: expected %d, got %d", - len(original.UnsignedTx.TxIn), - len(signed.UnsignedTx.TxIn), - ) - } - - prevouts := make(map[wire.OutPoint]*wire.TxOut) - - for inputIndex, signedInput := range signed.Inputs { - - if signedInput.WitnessUtxo == nil { - return fmt.Errorf("witness utxo not found for input %d", inputIndex) - } - - // fill prevouts map with the original witness data - previousOutpoint := original.UnsignedTx.TxIn[inputIndex].PreviousOutPoint - prevouts[previousOutpoint] = original.Inputs[inputIndex].WitnessUtxo + pkScript, err := script.P2TRScript(taprootKey) + if err != nil { + return nil, nil, fmt.Errorf("%s has invalid tapkey: %w", outpoint.String(), err) } - prevoutFetcher := txscript.NewMultiPrevOutFetcher(prevouts) - txsigHashes := txscript.NewTxSigHashes(original.UnsignedTx, prevoutFetcher) - - // loop over every input and check that the signer's signature is present and valid - for inputIndex, signedInput := range signed.Inputs { - orignalInput := original.Inputs[inputIndex] - if len(orignalInput.TaprootLeafScript) == 0 { - return fmt.Errorf( - "original input %d has no taproot leaf script, cannot verify signature", - inputIndex, - ) - } - - // check that every input has the signer's signature - var signerSig *psbt.TaprootScriptSpendSig - - for _, sig := range signedInput.TaprootScriptSpendSig { - if bytes.Equal(sig.XOnlyPubKey, xonlySigner) { - signerSig = sig - break - } - } - - if signerSig == nil { - return fmt.Errorf("signer signature not found for input %d", inputIndex) - } - - sig, err := schnorr.ParseSignature(signerSig.Signature) - if err != nil { - return fmt.Errorf("failed to parse signer signature for input %d: %s", inputIndex, err) - } - - // verify the signature - message, err := txscript.CalcTapscriptSignaturehash( - txsigHashes, - signedInput.SighashType, - original.UnsignedTx, - inputIndex, - prevoutFetcher, - txscript.NewBaseTapLeaf(orignalInput.TaprootLeafScript[0].Script), - ) - if err != nil { - return err - } - - if !sig.Verify(message, signerpubkey) { - return fmt.Errorf("invalid signer signature for input %d", inputIndex) - } - } - return nil + return pkScript, leafProof, nil } diff --git a/pkg/client-lib/utils_test.go b/pkg/client-lib/utils_test.go new file mode 100644 index 000000000..2da27cee5 --- /dev/null +++ b/pkg/client-lib/utils_test.go @@ -0,0 +1,236 @@ +package clientlib_test + +import ( + "testing" + "time" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/chaincfg" + "github.com/stretchr/testify/require" +) + +func TestNetworkFromString(t *testing.T) { + testCases := []struct { + networkName string + expectedNetwork arklib.Network + }{ + {arklib.BitcoinTestNet.Name, arklib.BitcoinTestNet}, + {arklib.BitcoinTestNet4.Name, arklib.BitcoinTestNet4}, + {arklib.BitcoinSigNet.Name, arklib.BitcoinSigNet}, + {arklib.BitcoinMutinyNet.Name, arklib.BitcoinMutinyNet}, + {arklib.BitcoinRegTest.Name, arklib.BitcoinRegTest}, + {arklib.Bitcoin.Name, arklib.Bitcoin}, + {"unknown", arklib.Bitcoin}, + } + + for _, tc := range testCases { + t.Run(tc.networkName, func(t *testing.T) { + result := clientlib.NetworkFromString(tc.networkName) + require.Equal(t, tc.expectedNetwork, result) + }) + } +} + +func TestToBitcoinNetwork(t *testing.T) { + testCases := []struct { + network arklib.Network + expectedNetwork chaincfg.Params + }{ + {arklib.BitcoinTestNet, chaincfg.TestNet3Params}, + {arklib.BitcoinSigNet, chaincfg.SigNetParams}, + {arklib.BitcoinMutinyNet, arklib.MutinyNetSigNetParams}, + {arklib.BitcoinRegTest, chaincfg.RegressionNetParams}, + {arklib.Bitcoin, chaincfg.MainNetParams}, + {arklib.BitcoinTestNet4, chaincfg.MainNetParams}, // testnet4 as unknown + } + + for _, tc := range testCases { + t.Run(tc.network.Name, func(t *testing.T) { + result := clientlib.ToBitcoinNetwork(tc.network) + require.Equal(t, tc.expectedNetwork, result) + }) + } +} + +func TestCoinSelect(t *testing.T) { + // Three vtxos with distinct expiries. After sort the loop consumes the + // furthest-expiry first; the soonest-to-expire vtxo is held back. + now := time.Now() + soon := vtxoAt("a", 0, 1000, now.Add(1*time.Hour)) + mid := vtxoAt("b", 0, 1000, now.Add(24*time.Hour)) + later := vtxoAt("c", 0, 1000, now.Add(48*time.Hour)) + + t.Run("insufficient funds", func(t *testing.T) { + _, _, _, err := clientlib.CoinSelect( + nil, []clientlib.Vtxo{soon}, + []clientlib.Receiver{{Amount: 5000}}, 330, nil, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "not enough funds") + }) + + t.Run("selects furthest-expiry vtxos first", func(t *testing.T) { + _, sel, change, err := clientlib.CoinSelect( + nil, + // intentionally shuffled order; the sort puts later first. + []clientlib.Vtxo{soon, later, mid}, + // Target 1500 sats + dust=330: one vtxo (1000) won't cover; two + // will. Expect later + mid picked, soon left aside. + []clientlib.Receiver{{Amount: 1500}}, 330, nil, + ) + require.NoError(t, err) + require.Len(t, sel, 2) + require.Equal(t, "c", sel[0].Txid, "furthest-expiry first") + require.Equal(t, "b", sel[1].Txid, "mid-expiry second") + require.Equal(t, uint64(500), change) + }) + + t.Run("sub-dust change folds in spare vtxo", func(t *testing.T) { + // Two vtxos of 1000 each, target 1900, dust 330. After picking the + // first vtxo (later=1000) we still need 900, so we pick another + // (mid=1000) and end up with change=100 — below dust. The fallback + // folds in the remaining `soon` vtxo (1000) so change becomes 1100. + _, sel, change, err := clientlib.CoinSelect( + nil, + []clientlib.Vtxo{soon, later, mid}, + []clientlib.Receiver{{Amount: 1900}}, 330, nil, + ) + require.NoError(t, err) + require.Len(t, sel, 3, "third vtxo folded in to lift change above dust") + require.Equal(t, uint64(1100), change) + }) + + t.Run("sub-dust change with no spare drops change to zero", func(t *testing.T) { + // Exactly one vtxo and a target that leaves sub-dust change. With + // no spare to fold in, change is set to zero (the leftover dust is + // implicitly absorbed by the receiver/server). + _, sel, change, err := clientlib.CoinSelect( + nil, + []clientlib.Vtxo{later}, + []clientlib.Receiver{{Amount: 900}}, 330, nil, + ) + require.NoError(t, err) + require.Len(t, sel, 1) + require.Equal(t, uint64(0), change) + }) + + t.Run("exact match returns zero change", func(t *testing.T) { + _, sel, change, err := clientlib.CoinSelect( + nil, + []clientlib.Vtxo{later}, + []clientlib.Receiver{{Amount: 1000}}, 330, nil, + ) + require.NoError(t, err) + require.Len(t, sel, 1) + require.Equal(t, uint64(0), change) + }) +} + +func TestCoinSelectAsset(t *testing.T) { + const asset = "deadbeef" + now := time.Now() + soon := vtxoWithAsset("a", 0, 1000, now.Add(1*time.Hour), asset, 100) + mid := vtxoWithAsset("b", 0, 1000, now.Add(24*time.Hour), asset, 100) + later := vtxoWithAsset("c", 0, 1000, now.Add(48*time.Hour), asset, 100) + + t.Run("no vtxos hold the asset", func(t *testing.T) { + // A vtxo with a different asset must be filtered out, leaving zero + // candidates. + other := vtxoWithAsset("z", 0, 1000, now.Add(1*time.Hour), "other", 100) + _, _, err := clientlib.CoinSelectAsset( + []clientlib.Vtxo{other}, 50, asset, false, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "not enough funds") + }) + + t.Run("selects furthest-expiry first when sorting", func(t *testing.T) { + // Need 150 units of the asset; each vtxo holds 100. Sort moves + // later/mid to the front; the soon vtxo is held back. + sel, change, err := clientlib.CoinSelectAsset( + []clientlib.Vtxo{soon, later, mid}, 150, asset, false, + ) + require.NoError(t, err) + require.Len(t, sel, 2) + require.Equal(t, "c", sel[0].Txid, "furthest-expiry first") + require.Equal(t, "b", sel[1].Txid) + require.Equal(t, uint64(50), change) + }) + + t.Run("withoutExpirySorting consumes input order", func(t *testing.T) { + // Same inputs but withoutExpirySorting=true; loop picks in the + // order given (soon, later, mid). + sel, change, err := clientlib.CoinSelectAsset( + []clientlib.Vtxo{soon, later, mid}, 150, asset, true, + ) + require.NoError(t, err) + require.Len(t, sel, 2) + require.Equal(t, "a", sel[0].Txid, "input order preserved") + require.Equal(t, "c", sel[1].Txid) + require.Equal(t, uint64(50), change) + }) + + t.Run("filters out vtxos with no matching asset", func(t *testing.T) { + other := vtxoWithAsset("z", 0, 1000, now.Add(1*time.Hour), "other", 100) + sel, change, err := clientlib.CoinSelectAsset( + []clientlib.Vtxo{other, later}, 50, asset, false, + ) + require.NoError(t, err) + require.Len(t, sel, 1) + require.Equal(t, "c", sel[0].Txid, "vtxo holding 'other' asset filtered out") + require.Equal(t, uint64(50), change) + }) +} + +func TestParseBitcoinAddress(t *testing.T) { + // Known-good mainnet p2wpkh / p2pkh / testnet p2wpkh fixtures. + const ( + // Same fixture used by pkg/client-lib/explorer/service_test.go. + mainnetP2WPKH = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + notAnAddress = "not-an-address" + ) + + t.Run("decodes valid address on matching network", func(t *testing.T) { + ok, script, err := clientlib.ParseBitcoinAddress(mainnetP2WPKH, chaincfg.MainNetParams) + require.NoError(t, err) + require.True(t, ok) + require.NotEmpty(t, script) + }) + + t.Run("rejects garbage input", func(t *testing.T) { + ok, script, err := clientlib.ParseBitcoinAddress(notAnAddress, chaincfg.MainNetParams) + require.NoError(t, err) + require.False(t, ok) + require.Nil(t, script) + }) + + t.Run("rejects empty input", func(t *testing.T) { + ok, script, err := clientlib.ParseBitcoinAddress("", chaincfg.MainNetParams) + require.NoError(t, err) + require.False(t, ok) + require.Nil(t, script) + }) +} + +// vtxoAt is a helper to build a baseline Vtxo with the given amount and +// expiry. Only the fields read by CoinSelect / CoinSelectAsset are populated; +// everything else is left zero. +func vtxoAt(txid string, vout uint32, amount uint64, expiresAt time.Time) clientlib.Vtxo { + return clientlib.Vtxo{ + Outpoint: clientlib.Outpoint{Txid: txid, VOut: vout}, + Amount: amount, + ExpiresAt: expiresAt, + } +} + +// vtxoWithAsset returns a Vtxo carrying the given asset balance. Used by the +// CoinSelectAsset cases. +func vtxoWithAsset( + txid string, vout uint32, amount uint64, expiresAt time.Time, + assetID string, assetAmount uint64, +) clientlib.Vtxo { + v := vtxoAt(txid, vout, amount, expiresAt) + v.Assets = []clientlib.Asset{{AssetId: assetID, Amount: assetAmount}} + return v +} diff --git a/pkg/client-lib/vtxos_opts.go b/pkg/client-lib/vtxos_opts.go deleted file mode 100644 index c73c5104b..000000000 --- a/pkg/client-lib/vtxos_opts.go +++ /dev/null @@ -1,51 +0,0 @@ -package wallet - -import ( - "fmt" - - "github.com/arkade-os/arkd/pkg/client-lib/types" -) - -// VtxosOption is the intersection of every option family that accepts a -// caller-supplied set of vtxos. A single WithVtxos satisfies all of -// SendOption, BatchSessionOption, and UnrollOption. -type VtxosOption interface { - SendOption - UnrollOption -} - -type vtxosOpt struct { - vtxos []types.VtxoWithTapTree -} - -func (v vtxosOpt) applySend(o *sendOptions) error { - if len(o.vtxos) > 0 { - return fmt.Errorf("vtxos already set") - } - if len(v.vtxos) == 0 { - return fmt.Errorf("missing vtxos") - } - o.vtxos = append([]types.VtxoWithTapTree(nil), v.vtxos...) - return nil -} - -// Unroll does not need tapscripts — they are resolved downstream from the -// explorer when computing redeem branches — so stripping is lossless. -func (v vtxosOpt) applyUnroll(o *unrollOptions) error { - if len(o.vtxos) > 0 { - return fmt.Errorf("vtxos already set") - } - if len(v.vtxos) == 0 { - return fmt.Errorf("missing vtxos") - } - plain := make([]types.Vtxo, len(v.vtxos)) - for i, vt := range v.vtxos { - plain[i] = vt.Vtxo - } - o.vtxos = plain - return nil -} - -func WithVtxos(vtxos []types.VtxoWithTapTree) VtxosOption { - return vtxosOpt{vtxos: vtxos} -} diff --git a/pkg/client-lib/wallet.go b/pkg/client-lib/wallet.go deleted file mode 100644 index dc35cefba..000000000 --- a/pkg/client-lib/wallet.go +++ /dev/null @@ -1,136 +0,0 @@ -package wallet - -import ( - "context" - "time" - - "github.com/arkade-os/arkd/pkg/ark-lib/asset" - "github.com/arkade-os/arkd/pkg/ark-lib/extension" - "github.com/arkade-os/arkd/pkg/client-lib/client" - "github.com/arkade-os/arkd/pkg/client-lib/explorer" - "github.com/arkade-os/arkd/pkg/client-lib/identity" - "github.com/arkade-os/arkd/pkg/client-lib/indexer" - "github.com/arkade-os/arkd/pkg/client-lib/types" -) - -var Version string - -type Wallet interface { - Identity() identity.Identity - Client() client.Client - Indexer() indexer.Indexer - Explorer() explorer.Explorer - - GetVersion() string - GetConfigData(ctx context.Context) (*types.Config, error) - Init(ctx context.Context, args InitArgs) error - IsLocked(ctx context.Context) bool - Unlock(ctx context.Context, password string) error - Lock(ctx context.Context) error - Dump(ctx context.Context) (seed string, err error) - SignTransaction(ctx context.Context, tx string, opts ...SignOption) (string, error) - Reset(ctx context.Context) - Stop() - // ** Funding ** - Receive( - ctx context.Context, - ) (onchainAddr string, offchainAddr, boardingAddr *types.Address, err error) - GetAddresses(ctx context.Context) ( - onchainAddresses []string, - offchainAddresses, boardingAddresses, redemptionAddresses []types.Address, err error, - ) - Balance(ctx context.Context) (*Balance, error) - ListVtxos( - ctx context.Context, opts ...ListVtxosOption, - ) (spendable, spent []types.Vtxo, err error) - GetTransactionHistory(ctx context.Context) ([]types.Transaction, error) - NotifyIncomingFunds(ctx context.Context, address string) ([]types.Vtxo, error) - // ** Assets ** - IssueAsset( - ctx context.Context, amount uint64, controlAsset types.ControlAsset, - metadata []asset.Metadata, opts ...SendOption, - ) (*IssueAssetRes, error) - ReissueAsset( - ctx context.Context, assetId string, amount uint64, opts ...SendOption, - ) (*ReissueAssetRes, error) - BurnAsset( - ctx context.Context, assetID string, amount uint64, opts ...SendOption, - ) (*BurnAssetRes, error) - // ** Offchain txs ** - SendOffChain( - ctx context.Context, receivers []types.Receiver, opts ...SendOption, - ) (*SendOffChainRes, error) - FinalizePendingTxs( - ctx context.Context, createdAfter *time.Time, opts ...SendOption, - ) ([]string, error) - // ** Batch session ** - Settle(ctx context.Context, opts ...BatchSessionOption) (*SettleRes, error) - CollaborativeExit( - ctx context.Context, addr string, amount uint64, opts ...BatchSessionOption, - ) (*CollaborativeExitRes, error) - RedeemNotes( - ctx context.Context, notes []string, opts ...BatchSessionOption, - ) (*RedeemNotesRes, error) - RegisterIntent( - ctx context.Context, vtxos []types.Vtxo, boardingUtxos []types.Utxo, notes []string, - outputs []types.Receiver, cosignersPublicKeys []string, opts ...SignOption, - ) (intentID string, err error) - DeleteIntent( - ctx context.Context, vtxos []types.Vtxo, boardingUtxos []types.Utxo, - notes []string, opts ...SignOption, - ) error - // ** Unroll ** - Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes, error) - CompleteUnroll(ctx context.Context, to string, opts ...UnrollOption) (string, error) - OnboardAgainAllExpiredBoardings(ctx context.Context, opts ...UnrollOption) (string, error) - WithdrawFromAllExpiredBoardings( - ctx context.Context, to string, opts ...UnrollOption, - ) (string, error) -} - -type ReissueAssetRes = OffchainTxRes - -type BurnAssetRes = OffchainTxRes - -type SendOffChainRes = OffchainTxRes - -type FinalizePendingTxsRes = []OffchainTxRes - -type SettleRes = BatchTxRes - -type CollaborativeExitRes = BatchTxRes - -type RedeemNotesRes = BatchTxRes - -type UnrollRes struct { - ParentTx string - ParentTxid string - ChildTx string - ChildTxid string -} - -type IssueAssetRes struct { - OffchainTxRes - IssuedAssets []asset.AssetId -} - -type BatchTxRes struct { - CommitmentTxid string - CommitmentTx string - IntentTx string - ForfeitTxs []string - VtxoInputs []types.Vtxo - UtxoInputs []types.Utxo - VtxoOutputs []types.Vtxo - UtxoOutputs []types.Receiver - Extension extension.Extension -} - -type OffchainTxRes struct { - Txid string - Tx string - Checkpoints []string - Inputs []types.Vtxo - Outputs []types.Receiver - Extension extension.Extension -} diff --git a/pkg/client-wallet/asset.go b/pkg/client-wallet/asset.go new file mode 100644 index 000000000..698c51e81 --- /dev/null +++ b/pkg/client-wallet/asset.go @@ -0,0 +1,162 @@ +package wallet + +import ( + "context" + "fmt" + "slices" + + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + offchaintx "github.com/arkade-os/arkd/pkg/client-lib/offchain-tx" +) + +func (w *wallet) IssueAsset( + ctx context.Context, amount uint64, controlAsset clientlib.ControlAsset, + metadata []asset.Metadata, opts ...offchaintx.Option, +) (*IssueAssetRes, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + vtxos, err := w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) + if err != nil { + return nil, err + } + + ctrlAsset := controlAsset + if c, ok := ctrlAsset.(clientlib.ExistingControlAsset); ok { + ctrlAssetAmount := uint64(0) + for _, v := range vtxos { + if i := slices.IndexFunc(v.Assets, func(asset clientlib.Asset) bool { + return asset.AssetId == c.Id + }); i >= 0 { + ctrlAssetAmount += v.Assets[i].Amount + } + } + ctrlAsset = clientlib.ExistingControlAsset{ + Id: c.Id, + Amount: ctrlAssetAmount, + } + } + + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + w.txLock.Lock() + defer w.txLock.Unlock() + + return offchaintx.IssueAsset(ctx, offchaintx.IssueAssetArgs{ + Client: w.client, + ServerInfo: w.Config.ClientInfo(), + SignTx: signTx, + Vtxos: vtxos, + ChangeAddr: offchainAddr.Address, + Amount: amount, + ControlAsset: ctrlAsset, + Metadata: metadata, + }, opts...) +} + +func (w *wallet) ReissueAsset( + ctx context.Context, assetId string, amount uint64, opts ...offchaintx.Option, +) (*ReissueAssetRes, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + controlAsset, err := w.getControlAsset(ctx, assetId) + if err != nil { + return nil, fmt.Errorf("failed to get control asset: %w", err) + } + if controlAsset == nil { + return nil, fmt.Errorf("%s can't be reissued, no control asset", assetId) + } + + vtxos, err := w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) + if err != nil { + return nil, err + } + + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + w.txLock.Lock() + defer w.txLock.Unlock() + + return offchaintx.ReissueAsset(ctx, offchaintx.ReissueAssetArgs{ + Client: w.client, + ServerInfo: w.Config.ClientInfo(), + SignTx: signTx, + Vtxos: vtxos, + ChangeAddr: offchainAddr.Address, + Asset: clientlib.Asset{ + AssetId: assetId, + Amount: amount, + }, + ControlAsset: *controlAsset, + }, opts...) +} + +func (w *wallet) BurnAsset( + ctx context.Context, assetId string, amount uint64, opts ...offchaintx.Option, +) (*BurnAssetRes, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + vtxos, err := w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) + if err != nil { + return nil, err + } + + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + w.txLock.Lock() + defer w.txLock.Unlock() + + return offchaintx.BurnAsset(ctx, offchaintx.BurnAssetArgs{ + Client: w.client, + ServerInfo: w.Config.ClientInfo(), + SignTx: signTx, + Vtxos: vtxos, + ChangeAddr: offchainAddr.Address, + Asset: clientlib.Asset{ + AssetId: assetId, + Amount: amount, + }, + }, opts...) +} + +func (w *wallet) getControlAsset(ctx context.Context, assetId string) (*clientlib.Asset, error) { + info, err := w.indexer.GetAsset(ctx, assetId) + if err != nil { + return nil, fmt.Errorf("failed to fetch asset data: %w", err) + } + controlAssetInfo, err := w.indexer.GetAsset(ctx, info.ControlAssetId) + if err != nil { + return nil, fmt.Errorf("failed to fetch control asset data: %w", err) + } + return &clientlib.Asset{ + AssetId: controlAssetInfo.AssetId, + Amount: controlAssetInfo.Supply, + }, nil +} diff --git a/pkg/client-wallet/batch_session.go b/pkg/client-wallet/batch_session.go new file mode 100644 index 000000000..482a54785 --- /dev/null +++ b/pkg/client-wallet/batch_session.go @@ -0,0 +1,217 @@ +package wallet + +import ( + "context" + "fmt" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsession "github.com/arkade-os/arkd/pkg/client-lib/batch-session" +) + +func (w *wallet) Settle( + ctx context.Context, opts ...batchsession.Option, +) (*SettleRes, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + w.txLock.Lock() + defer w.txLock.Unlock() + + info, err := w.client.GetInfo(ctx) + if err != nil { + return nil, err + } + + vtxos, err := w.getSpendableVtxos(ctx, nil) + if err != nil { + return nil, err + } + // coinselect all available boarding utxos and vtxos + boardingUtxos, err := w.getClaimableBoardingUtxos(ctx) + if err != nil { + return nil, err + } + + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + return batchsession.Settle(ctx, batchsession.SettleArgs{ + Client: w.client, + ServerInfo: *info, + SignTx: signTx, + BoardingUtxos: boardingUtxos, + Vtxos: vtxos, + ReceiverAddr: offchainAddr.Address, + }, opts...) +} + +func (w *wallet) RedeemNotes( + ctx context.Context, notes []string, opts ...batchsession.Option, +) (*RedeemNotesRes, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + info, err := w.client.GetInfo(ctx) + if err != nil { + return nil, err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + return batchsession.RedeemNotes(ctx, batchsession.RedeemNotesArgs{ + Client: w.client, + ServerInfo: *info, + SignTx: signTx, + Notes: notes, + ReceiverAddr: offchainAddr.Address, + }, opts...) +} + +func (w *wallet) CollaborativeExit( + ctx context.Context, addr string, amount uint64, opts ...batchsession.Option, +) (*CollaborativeExitRes, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + if w.UtxoMaxAmount == 0 { + return nil, fmt.Errorf("operation not allowed by the server") + } + + w.txLock.Lock() + defer w.txLock.Unlock() + + // send all case: substract fees from exited amount + info, err := w.client.GetInfo(ctx) + if err != nil { + return nil, err + } + + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + vtxos, err := w.getSpendableVtxos(ctx, nil) + if err != nil { + return nil, err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + return batchsession.CollaborativeExit(ctx, batchsession.CollaborativeExitArgs{ + Client: w.client, + SignTx: signTx, + ServerInfo: *info, + Vtxos: vtxos, + Receiver: clientlib.Receiver{To: addr, Amount: amount}, + ChangeAddr: offchainAddr.Address, + }, opts...) +} + +func (w *wallet) RegisterIntent( + ctx context.Context, vtxos []clientlib.Vtxo, boardingUtxos []clientlib.Utxo, notes []string, + outputs []clientlib.Receiver, cosignersPublicKeys []string, +) (string, error) { + if err := w.safeCheck(); err != nil { + return "", err + } + + _, offchainAddr, boardingAddr, _, err := w.getAddresses(ctx) + if err != nil { + return "", err + } + + myVtxos, myBoardingUtxos, err := w.populateVtxosWithTapscripts( + ctx, vtxos, boardingUtxos, offchainAddr, boardingAddr, + ) + if err != nil { + return "", err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + proofTx, message, _, err := batchsession.BuildAndSignRegisterIntent( + ctx, batchsession.IntentArgs{ + Cosigners: cosignersPublicKeys, + BaseArgs: batchsession.BaseArgs{ + Vtxos: myVtxos, + BoardingUtxos: myBoardingUtxos, + Notes: notes, + Outputs: outputs, + SignTx: signTx, + }, + }, + ) + if err != nil { + return "", err + } + + return w.client.RegisterIntent(ctx, proofTx, message) +} + +func (w *wallet) DeleteIntent( + ctx context.Context, vtxos []clientlib.Vtxo, boardingUtxos []clientlib.Utxo, notes []string, +) error { + if err := w.safeCheck(); err != nil { + return err + } + + _, offchainAddr, boardingAddr, _, err := w.getAddresses(ctx) + if err != nil { + return err + } + + myVtxos, myBoardingUtxos, err := w.populateVtxosWithTapscripts( + ctx, vtxos, boardingUtxos, offchainAddr, boardingAddr, + ) + if err != nil { + return err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + proofTx, message, err := batchsession.BuildAndSignDeleteIntent( + ctx, batchsession.IntentArgs{BaseArgs: batchsession.BaseArgs{ + Vtxos: myVtxos, + BoardingUtxos: myBoardingUtxos, + Notes: notes, + SignTx: signTx, + }}, + ) + if err != nil { + return err + } + + return w.client.DeleteIntent(ctx, proofTx, message) +} + +func (w *wallet) getClaimableBoardingUtxos(ctx context.Context) ([]clientlib.Utxo, error) { + _, _, boardingAddr, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + return w.getUtxos(ctx, *boardingAddr, getUtxosFilter{claimable: true}) +} diff --git a/pkg/client-lib/example/README.md b/pkg/client-wallet/example/README.md similarity index 97% rename from pkg/client-lib/example/README.md rename to pkg/client-wallet/example/README.md index 6d2ac33e1..876005d22 100644 --- a/pkg/client-lib/example/README.md +++ b/pkg/client-wallet/example/README.md @@ -17,7 +17,7 @@ go run example/multi_connection_demo/multi_connection_demo.go [flags] | Flag | Type | Default | Description | |------|------|---------|-------------| | `-addresses` | int | 1500 | Number of addresses to generate and subscribe | -| `-listenners` | int | 1 | Number of listeners watching for events | +| `-listeners` | int | 1 | Number of listeners watching for events | | `-url` | string | https://mempool.space/api | Explorer API URL | | `-max-events` | int | 5 | Maximum events to receive before stopping (0 = unlimited) | | `-show-all` | bool | false | Show all subscribed addresses (not just first 3) | diff --git a/pkg/client-lib/example/alice_to_bob/alice_to_bob.go b/pkg/client-wallet/example/alice_to_bob/alice_to_bob.go similarity index 93% rename from pkg/client-lib/example/alice_to_bob/alice_to_bob.go rename to pkg/client-wallet/example/alice_to_bob/alice_to_bob.go index 0ddd4968b..4eec25218 100644 --- a/pkg/client-lib/example/alice_to_bob/alice_to_bob.go +++ b/pkg/client-wallet/example/alice_to_bob/alice_to_bob.go @@ -9,9 +9,10 @@ import ( "sync" "time" - wallet "github.com/arkade-os/arkd/pkg/client-lib" - "github.com/arkade-os/arkd/pkg/client-lib/store" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + wallet "github.com/arkade-os/arkd/pkg/client-wallet" + "github.com/arkade-os/arkd/pkg/client-wallet/store" + "github.com/arkade-os/arkd/pkg/client-wallet/types" log "github.com/sirupsen/logrus" ) @@ -111,7 +112,7 @@ func main() { log.Infof("bob offchain balance: %d", bobBalance.OffchainBalance.Total) amount := uint64(1000) - receivers := []types.Receiver{{To: bobOffchainAddr.Address, Amount: amount}} + receivers := []clientlib.Receiver{{To: bobOffchainAddr.Address, Amount: amount}} fmt.Println("") log.Infof("alice is sending %d sats to bob offchain...", amount) @@ -156,9 +157,7 @@ func main() { } func setupArkClient() (wallet.Wallet, error) { - appDataStore, err := store.NewStore(store.Config{ - ConfigStoreType: types.InMemoryStore, - }) + appDataStore, err := store.NewStore(types.InMemoryStore, "") if err != nil { return nil, fmt.Errorf("failed to setup app data store: %s", err) } diff --git a/pkg/client-lib/example/multi_connection_demo/multi_connection_demo.go b/pkg/client-wallet/example/multi_connection_demo/multi_connection_demo.go similarity index 95% rename from pkg/client-lib/example/multi_connection_demo/multi_connection_demo.go rename to pkg/client-wallet/example/multi_connection_demo/multi_connection_demo.go index ceca4aa12..3e85b26a7 100644 --- a/pkg/client-lib/example/multi_connection_demo/multi_connection_demo.go +++ b/pkg/client-wallet/example/multi_connection_demo/multi_connection_demo.go @@ -12,7 +12,7 @@ import ( "time" arklib "github.com/arkade-os/arkd/pkg/ark-lib" - mempoolexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" + "github.com/arkade-os/arkd/pkg/client-lib/explorer" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" @@ -41,8 +41,8 @@ func main() { fmt.Println("============================================================") // Create explorer with configurable parameters - svc, err := mempoolexplorer.NewExplorer( - *explorerURL, arklib.Bitcoin, mempoolexplorer.WithTracker(true), + svc, err := explorer.NewExplorer( + *explorerURL, arklib.Bitcoin, explorer.WithTracker(true), ) if err != nil { log.Fatal("❌ Failed to create explorer:", err) @@ -155,7 +155,7 @@ func main() { fmt.Printf(" Error: %v\n", ev.Error) } else { buf, _ := json.MarshalIndent(ev, "", " ") - fmt.Printf("🎯 Listener %d receveived event #%d: %s\n", i, eventCount, string(buf)) + fmt.Printf("🎯 Listener %d received event #%d: %s\n", i, eventCount, string(buf)) } // Stop after receiving max events (if configured) diff --git a/pkg/client-lib/funding.go b/pkg/client-wallet/funding.go similarity index 56% rename from pkg/client-lib/funding.go rename to pkg/client-wallet/funding.go index 989f94947..860881f8e 100644 --- a/pkg/client-lib/funding.go +++ b/pkg/client-wallet/funding.go @@ -11,89 +11,87 @@ import ( 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/arkd/pkg/client-lib/indexer" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-wallet/types" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" ) -func (a *service) Receive(ctx context.Context) ( - onchainAddr string, offchainAddr, boardingAddr *types.Address, err error, +func (w *wallet) Receive(ctx context.Context) ( + string, *clientlib.Address, *clientlib.Address, error, ) { - if a.identity == nil { + if w.identity == nil { return "", nil, nil, ErrNotInitialized } - onchainAddr, offchainAddr, boardingAddr, err = a.newAddress(ctx) + onchainAddr, offchainAddr, boardingAddr, _, err := w.getAddresses(ctx) if err != nil { return "", nil, nil, err } - if a.UtxoMaxAmount == 0 { + if w.UtxoMaxAmount == 0 { boardingAddr = nil } - return onchainAddr, offchainAddr, boardingAddr, nil + return onchainAddr.Address, offchainAddr, boardingAddr, nil } -func (a *service) GetAddresses( +func (w *wallet) GetAddresses( ctx context.Context, -) ([]string, []types.Address, []types.Address, []types.Address, error) { - if err := a.safeCheck(); err != nil { +) ([]string, []clientlib.Address, []clientlib.Address, []clientlib.Address, error) { + if err := w.safeCheck(); err != nil { return nil, nil, nil, nil, err } - onchainAddrs, offchainAddrs, boardingAddrs, redemptionAddrs, err := a.getAddresses(ctx) + onchainAddr, offchainAddr, boardingAddr, redemptionAddr, err := w.getAddresses(ctx) if err != nil { return nil, nil, nil, nil, err } - onchainAddresses := make([]string, 0, len(onchainAddrs)) - for _, addr := range onchainAddrs { - onchainAddresses = append(onchainAddresses, addr.Address) - } - return onchainAddresses, offchainAddrs, boardingAddrs, redemptionAddrs, nil + onchainAddrs := []string{onchainAddr.Address} + offchainAddrs := []clientlib.Address{*offchainAddr} + boardingAddrs := []clientlib.Address{*boardingAddr} + redemptionAddrs := []clientlib.Address{*redemptionAddr} + return onchainAddrs, offchainAddrs, boardingAddrs, redemptionAddrs, nil } -func (a *service) ListVtxos( +func (w *wallet) ListVtxos( ctx context.Context, opts ...ListVtxosOption, -) ([]types.Vtxo, []types.Vtxo, error) { +) ([]clientlib.Vtxo, []clientlib.Vtxo, error) { o, err := ApplyListVtxosOptions(opts...) if err != nil { return nil, nil, err } - var indexerOpts []indexer.GetVtxosOption + var indexerOpts []clientlib.GetVtxosOption if o.Before > 0 || o.After > 0 { - indexerOpts = append(indexerOpts, indexer.WithTimeRange(o.Before, o.After)) + indexerOpts = append(indexerOpts, clientlib.WithTimeRange(o.Before, o.After)) } - return a.getVtxos(ctx, indexerOpts...) + return w.getVtxos(ctx, indexerOpts...) } -func (a *service) Balance(ctx context.Context) (*Balance, error) { - if a.identity == nil { +func (w *wallet) Balance(ctx context.Context) (*types.Balance, error) { + if w.identity == nil { return nil, ErrNotInitialized } - onchainAddrs, _, boardingAddrs, redeemAddrs, err := a.getAddresses(ctx) + onchainAddr, _, boardingAddr, redeemAddr, err := w.getAddresses(ctx) if err != nil { return nil, err } - if a.UtxoMaxAmount == 0 { - balance, amountByExpiration, assetBalances, err := a.getOffchainBalance(ctx) + if w.UtxoMaxAmount == 0 { + balance, amountByExpiration, assetBalances, err := w.getOffchainBalance(ctx) if err != nil { return nil, err } nextExpiration, details := getOffchainBalanceDetails(amountByExpiration) - return &Balance{ - OffchainBalance: OffchainBalance{ + return &types.Balance{ + OffchainBalance: types.OffchainBalance{ Total: balance, NextExpiration: getFancyTimeExpiration(nextExpiration), Details: details, @@ -108,19 +106,17 @@ func (a *service) Balance(ctx context.Context) (*Balance, error) { assetBalances map[string]uint64 onchainSpendable uint64 boardingSpendable uint64 - boardingLocked []LockedOnchainBalance + boardingLocked []types.LockedOnchainBalance redeemSpendable uint64 - redeemLocked []LockedOnchainBalance + redeemLocked []types.LockedOnchainBalance offchainErr, onchainErr, boardingErr, redeemErr error ) wg := &sync.WaitGroup{} - wg.Add(4) - go func() { - defer wg.Done() - bal, byExp, assets, err := a.getOffchainBalance(ctx) + wg.Go(func() { + bal, byExp, assets, err := w.getOffchainBalance(ctx) if err != nil { offchainErr = err return @@ -128,15 +124,10 @@ func (a *service) Balance(ctx context.Context) (*Balance, error) { offchainBalance = bal amountByExpiration = byExp assetBalances = assets - }() + }) - go func() { - defer wg.Done() - addresses := make([]string, 0, len(onchainAddrs)) - for _, addr := range onchainAddrs { - addresses = append(addresses, addr.Address) - } - utxos, err := a.explorer.GetUtxos(addresses) + wg.Go(func() { + utxos, err := w.explorer.GetUtxos([]string{onchainAddr.Address}) if err != nil { onchainErr = err return @@ -144,47 +135,41 @@ func (a *service) Balance(ctx context.Context) (*Balance, error) { for _, u := range utxos { onchainSpendable += u.Amount } - }() + }) - go func() { - defer wg.Done() - for _, addr := range boardingAddrs { - spendable, locked, err := a.explorer.GetRedeemedVtxosBalance( - addr.Address, a.UnilateralExitDelay, - ) - if err != nil { - boardingErr = err - return - } - boardingSpendable += spendable - for ts, amt := range locked { - boardingLocked = append(boardingLocked, LockedOnchainBalance{ - SpendableAt: time.Unix(ts, 0).Format(time.RFC3339), - Amount: amt, - }) - } + wg.Go(func() { + spendable, locked, err := w.explorer.GetRedeemedVtxosBalance( + boardingAddr.Address, w.BoardingExitDelay, + ) + if err != nil { + boardingErr = err + return } - }() + boardingSpendable += spendable + for ts, amt := range locked { + boardingLocked = append(boardingLocked, types.LockedOnchainBalance{ + SpendableAt: time.Unix(ts, 0).Format(time.RFC3339), + Amount: amt, + }) + } + }) - go func() { - defer wg.Done() - for _, addr := range redeemAddrs { - spendable, locked, err := a.explorer.GetRedeemedVtxosBalance( - addr.Address, a.UnilateralExitDelay, - ) - if err != nil { - redeemErr = err - return - } - redeemSpendable += spendable - for ts, amt := range locked { - redeemLocked = append(redeemLocked, LockedOnchainBalance{ - SpendableAt: time.Unix(ts, 0).Format(time.RFC3339), - Amount: amt, - }) - } + wg.Go(func() { + spendable, locked, err := w.explorer.GetRedeemedVtxosBalance( + redeemAddr.Address, w.UnilateralExitDelay, + ) + if err != nil { + redeemErr = err + return + } + redeemSpendable += spendable + for ts, amt := range locked { + redeemLocked = append(redeemLocked, types.LockedOnchainBalance{ + SpendableAt: time.Unix(ts, 0).Format(time.RFC3339), + Amount: amt, + }) } - }() + }) wg.Wait() @@ -202,16 +187,18 @@ func (a *service) Balance(ctx context.Context) (*Balance, error) { nextExpiration, details := getOffchainBalanceDetails(amountByExpiration) - lockedOnchainBalance := make([]LockedOnchainBalance, 0, len(boardingLocked)+len(redeemLocked)) + lockedOnchainBalance := make( + []types.LockedOnchainBalance, 0, len(boardingLocked)+len(redeemLocked), + ) lockedOnchainBalance = append(lockedOnchainBalance, boardingLocked...) lockedOnchainBalance = append(lockedOnchainBalance, redeemLocked...) - return &Balance{ - OnchainBalance: OnchainBalance{ + return &types.Balance{ + OnchainBalance: types.OnchainBalance{ SpendableAmount: onchainSpendable + boardingSpendable + redeemSpendable, LockedAmount: lockedOnchainBalance, }, - OffchainBalance: OffchainBalance{ + OffchainBalance: types.OffchainBalance{ Total: offchainBalance, NextExpiration: getFancyTimeExpiration(nextExpiration), Details: details, @@ -220,13 +207,13 @@ func (a *service) Balance(ctx context.Context) (*Balance, error) { }, nil } -func (a *service) GetTransactionHistory(ctx context.Context) ([]types.Transaction, error) { - spendable, spent, err := a.getVtxos(ctx) +func (w *wallet) GetTransactionHistory(ctx context.Context) ([]clientlib.Transaction, error) { + spendable, spent, err := w.getVtxos(ctx) if err != nil { return nil, err } - onchainHistory, err := a.getBoardingTxs(ctx) + onchainHistory, err := w.getBoardingTxs(ctx) if err != nil { return nil, err } @@ -237,7 +224,7 @@ func (a *service) GetTransactionHistory(ctx context.Context) ([]types.Transactio } } - offchainHistory, err := a.vtxosToTxs(ctx, spendable, spent, commitmentTxsToIgnore) + offchainHistory, err := w.vtxosToTxs(ctx, spendable, spent, commitmentTxsToIgnore) if err != nil { return nil, err } @@ -250,8 +237,8 @@ func (a *service) GetTransactionHistory(ctx context.Context) ([]types.Transactio return history, nil } -func (a *service) NotifyIncomingFunds(ctx context.Context, addr string) ([]types.Vtxo, error) { - if err := a.safeCheck(); err != nil { +func (w *wallet) NotifyIncomingFunds(ctx context.Context, addr string) ([]clientlib.Vtxo, error) { + if err := w.safeCheck(); err != nil { return nil, err } @@ -265,7 +252,7 @@ func (a *service) NotifyIncomingFunds(ctx context.Context, addr string) ([]types } scripts := []string{hex.EncodeToString(vtxoScript)} - _, eventCh, closeFn, err := a.indexer.NewSubscription(ctx, scripts) + _, eventCh, closeFn, err := w.indexer.NewSubscription(ctx, scripts) if err != nil { return nil, err } @@ -287,60 +274,31 @@ func (a *service) NotifyIncomingFunds(ctx context.Context, addr string) ([]types } } -func (a *service) newAddress( - ctx context.Context, -) (onchainAddr string, offchainAddr, boardingAddr *types.Address, err error) { - key, err := a.identity.NewKey(ctx) - if err != nil { - return "", nil, nil, err - } - - onchainAddr, offchainAddr, boardingAddr, _, err = a.deriveDefaultAddresses(*key) - return onchainAddr, offchainAddr, boardingAddr, err -} - -func (a *service) getAddresses(ctx context.Context) ( - onchainAddrs, offchainAddrs, boardingAddrs, redemptionAddrs []types.Address, - err error, +func (w *wallet) getAddresses(ctx context.Context) ( + *clientlib.Address, *clientlib.Address, *clientlib.Address, *clientlib.Address, error, ) { - keys := make([]identity.KeyRef, 0) - seenKeys := make(map[string]struct{}) - - keyRefs, err := a.identity.ListKeys(ctx) + keyRefs, err := w.identity.ListKeys(ctx) if err != nil { return nil, nil, nil, nil, err } + key := keyRefs[0] - for _, key := range keyRefs { - if _, ok := seenKeys[key.Id]; ok { - continue - } - seenKeys[key.Id] = struct{}{} - keys = append(keys, key) - } - - for _, key := range keys { - onchainAddr, offchainAddr, boardingAddr, redemptionAddr, err := a.deriveDefaultAddresses(key) - if err != nil { - return nil, nil, nil, nil, err - } - - onchainAddrs = append(onchainAddrs, types.Address{KeyID: key.Id, Address: onchainAddr}) - offchainAddrs = append(offchainAddrs, *offchainAddr) - boardingAddrs = append(boardingAddrs, *boardingAddr) - redemptionAddrs = append(redemptionAddrs, *redemptionAddr) + addr, offchainAddr, boardingAddr, redemptionAddr, err := w.deriveDefaultAddresses(key) + if err != nil { + return nil, nil, nil, nil, err } - return onchainAddrs, offchainAddrs, boardingAddrs, redemptionAddrs, nil + onchainAddr := &clientlib.Address{Address: addr, KeyID: key.Id} + return onchainAddr, offchainAddr, boardingAddr, redemptionAddr, nil } -func (a *service) deriveDefaultAddresses( - key identity.KeyRef, -) (onchainAddr string, offchainAddr, boardingAddr, redemptionAddr *types.Address, err error) { - netParams := utils.ToBitcoinNetwork(a.Network) +func (w *wallet) deriveDefaultAddresses( + key clientlib.KeyRef, +) (onchainAddr string, offchainAddr, boardingAddr, redemptionAddr *clientlib.Address, err error) { + netParams := clientlib.ToBitcoinNetwork(w.Network) defaultVtxoScript := script.NewDefaultVtxoScript( - key.PubKey, a.SignerPubKey, a.UnilateralExitDelay, + key.PubKey, w.SignerPubKey, w.UnilateralExitDelay, ) vtxoTapKey, _, err := defaultVtxoScript.TapTree() if err != nil { @@ -348,8 +306,8 @@ func (a *service) deriveDefaultAddresses( } offchainAddress := &arklib.Address{ - HRP: a.Network.Addr, - Signer: a.SignerPubKey, + HRP: w.Network.Addr, + Signer: w.SignerPubKey, VtxoTapKey: vtxoTapKey, } encodedOffchainAddr, err := offchainAddress.EncodeV0() @@ -363,7 +321,7 @@ func (a *service) deriveDefaultAddresses( } boardingVtxoScript := script.NewDefaultVtxoScript( - key.PubKey, a.SignerPubKey, a.BoardingExitDelay, + key.PubKey, w.SignerPubKey, w.BoardingExitDelay, ) boardingTapKey, _, err := boardingVtxoScript.TapTree() if err != nil { @@ -398,17 +356,17 @@ func (a *service) deriveDefaultAddresses( } onchainAddr = onchainTaprootAddr.EncodeAddress() - offchainAddr = &types.Address{ + offchainAddr = &clientlib.Address{ KeyID: key.Id, Tapscripts: tapscripts, Address: encodedOffchainAddr, } - boardingAddr = &types.Address{ + boardingAddr = &clientlib.Address{ KeyID: key.Id, Tapscripts: boardingTapscripts, Address: boardingTaprootAddr.EncodeAddress(), } - redemptionAddr = &types.Address{ + redemptionAddr = &clientlib.Address{ KeyID: key.Id, Tapscripts: tapscripts, Address: redemptionTaprootAddr.EncodeAddress(), @@ -417,13 +375,12 @@ func (a *service) deriveDefaultAddresses( return } -func (a *service) getOffchainBalance(ctx context.Context) ( +func (w *wallet) getOffchainBalance(ctx context.Context) ( uint64, map[int64]uint64, map[string]uint64, error, ) { amountByExpiration := make(map[int64]uint64, 0) assetBalances := make(map[string]uint64, 0) - opts := &getVtxosFilter{withRecoverableVtxos: true} - vtxos, err := a.getSpendableVtxos(ctx, opts) + vtxos, err := w.getSpendableVtxos(ctx, nil) if err != nil { return 0, nil, nil, err } @@ -453,21 +410,21 @@ func (a *service) getOffchainBalance(ctx context.Context) ( return balance, amountByExpiration, assetBalances, nil } -func (a *service) getBoardingTxs(ctx context.Context) ([]types.Transaction, error) { - allUtxos, err := a.getAllBoardingUtxos(ctx) +func (w *wallet) getBoardingTxs(ctx context.Context) ([]clientlib.Transaction, error) { + allUtxos, err := w.getAllBoardingUtxos(ctx) if err != nil { return nil, err } - unconfirmedTxs := make([]types.Transaction, 0) - confirmedTxs := make([]types.Transaction, 0) + unconfirmedTxs := make([]clientlib.Transaction, 0) + confirmedTxs := make([]clientlib.Transaction, 0) for _, u := range allUtxos { - tx := types.Transaction{ - TransactionKey: types.TransactionKey{ + tx := clientlib.Transaction{ + TransactionKey: clientlib.TransactionKey{ BoardingTxid: u.Txid, }, Amount: u.Amount, - Type: types.TxReceived, + Type: clientlib.TxReceived, CreatedAt: u.CreatedAt, SettledBy: u.SpentBy, Hex: u.Tx, @@ -484,63 +441,67 @@ func (a *service) getBoardingTxs(ctx context.Context) ([]types.Transaction, erro return txs, nil } -func (a *service) getAllBoardingUtxos(ctx context.Context) ([]types.Utxo, error) { - _, _, boardingAddrs, _, err := a.getAddresses(ctx) +func (w *wallet) getAllBoardingUtxos(ctx context.Context) ([]clientlib.Utxo, error) { + _, _, boardingAddr, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + addr := boardingAddr.Address + closure, err := boardingAddr.CollaborativeClosure() if err != nil { return nil, err } - utxos := []types.Utxo{} - for _, addr := range boardingAddrs { - txs, err := a.explorer.GetTxs(addr.Address) - if err != nil { - return nil, err - } - for _, tx := range txs { - for i, vout := range tx.Vout { - if vout.Address == addr.Address { - createdAt := time.Time{} - utxoTime := time.Now() - if tx.Status.Confirmed { - createdAt = time.Unix(tx.Status.BlockTime, 0) - utxoTime = time.Unix(tx.Status.BlockTime, 0) - } + utxos := []clientlib.Utxo{} + txs, err := w.explorer.GetTxs(addr) + if err != nil { + return nil, err + } + for _, tx := range txs { + for i, vout := range tx.Vout { + if vout.Address == addr { + createdAt := time.Time{} + utxoTime := time.Now() + if tx.Status.Confirmed { + createdAt = time.Unix(tx.Status.BlockTime, 0) + utxoTime = time.Unix(tx.Status.BlockTime, 0) + } - txHex, err := a.explorer.GetTxHex(tx.Txid) - if err != nil { - return nil, err - } - spentStatuses, err := a.explorer.GetTxOutspends(tx.Txid) - if err != nil { - return nil, err - } - spent := false - spentBy := "" - if len(spentStatuses) > i { - if spentStatuses[i].Spent { - spent = true - spentBy = spentStatuses[i].SpentBy - } + txHex, err := w.explorer.GetTxHex(tx.Txid) + if err != nil { + return nil, err + } + spentStatuses, err := w.explorer.GetTxOutspends(tx.Txid) + if err != nil { + return nil, err + } + spent := false + spentBy := "" + if len(spentStatuses) > i { + if spentStatuses[i].Spent { + spent = true + spentBy = spentStatuses[i].SpentBy } - - utxos = append(utxos, types.Utxo{ - Outpoint: types.Outpoint{ - Txid: tx.Txid, - VOut: uint32(i), - }, - Amount: vout.Amount, - Script: vout.Script, - Delay: a.BoardingExitDelay, - SpendableAt: utxoTime.Add( - time.Duration(a.BoardingExitDelay.Seconds()) * time.Second, - ), - CreatedAt: createdAt, - Tapscripts: addr.Tapscripts, - Spent: spent, - SpentBy: spentBy, - Tx: txHex, - }) } + + utxos = append(utxos, clientlib.Utxo{ + Outpoint: clientlib.Outpoint{ + Txid: tx.Txid, + VOut: uint32(i), + }, + Amount: vout.Amount, + Script: vout.Script, + Delay: w.BoardingExitDelay, + RedeemableAt: utxoTime.Add( + time.Duration(w.BoardingExitDelay.Seconds()) * time.Second, + ), + CreatedAt: createdAt, + Spent: spent, + SpentBy: spentBy, + Tx: txHex, + Tapscripts: boardingAddr.Tapscripts, + SigningClosure: closure, + }) } } } @@ -548,17 +509,17 @@ func (a *service) getAllBoardingUtxos(ctx context.Context) ([]types.Utxo, error) return utxos, nil } -func (i *service) vtxosToTxs( - ctx context.Context, spendable, spent []types.Vtxo, commitmentTxsToIgnore map[string]struct{}, -) ([]types.Transaction, error) { - txs := make([]types.Transaction, 0) +func (w *wallet) vtxosToTxs( + ctx context.Context, spendable, spent []clientlib.Vtxo, commitmentTxsToIgnore map[string]struct{}, +) ([]clientlib.Transaction, error) { + txs := make([]clientlib.Transaction, 0) // Receivals // All vtxos are receivals unless: // - they resulted from a settlement (either boarding or refresh) // - they are the change of a spend tx or a collaborative exit - vtxosLeftToCheck := append([]types.Vtxo{}, spent...) + vtxosLeftToCheck := append([]clientlib.Vtxo{}, spent...) for _, vtxo := range append(spendable, spent...) { if _, ok := commitmentTxsToIgnore[vtxo.CommitmentTxids[0]]; !vtxo.Preconfirmed && ok { continue @@ -585,16 +546,16 @@ func (i *service) vtxosToTxs( settledBy = vtxo.SettledBy } - txs = append(txs, types.Transaction{ - TransactionKey: types.TransactionKey{ + txs = append(txs, clientlib.Transaction{ + TransactionKey: clientlib.TransactionKey{ CommitmentTxid: commitmentTxid, ArkTxid: arkTxid, }, Amount: vtxo.Amount - settleAmount - spentAmount, - Type: types.TxReceived, + Type: clientlib.TxReceived, CreatedAt: vtxo.CreatedAt, SettledBy: settledBy, - Assets: NetVtxoAssets([]types.Vtxo{vtxo}, append(settleVtxos, spentVtxos...)), + Assets: NetVtxoAssets([]clientlib.Vtxo{vtxo}, append(settleVtxos, spentVtxos...)), }) } @@ -603,15 +564,15 @@ func (i *service) vtxosToTxs( // All spent vtxos are payments unless they are settlements of boarding utxos or refreshes // Aggregate settled vtxos by "settledBy" (commitment txid) - vtxosBySettledBy := make(map[string][]types.Vtxo) + vtxosBySettledBy := make(map[string][]clientlib.Vtxo) // Aggregate spent vtxos by "arkTxid" - vtxosBySpentBy := make(map[string][]types.Vtxo) + vtxosBySpentBy := make(map[string][]clientlib.Vtxo) for _, v := range spent { if v.SettledBy != "" { if _, ok := commitmentTxsToIgnore[v.SettledBy]; !ok { if _, ok := vtxosBySettledBy[v.SettledBy]; !ok { - vtxosBySettledBy[v.SettledBy] = make([]types.Vtxo, 0) + vtxosBySettledBy[v.SettledBy] = make([]clientlib.Vtxo, 0) } vtxosBySettledBy[v.SettledBy] = append(vtxosBySettledBy[v.SettledBy], v) continue @@ -623,7 +584,7 @@ func (i *service) vtxosToTxs( } if _, ok := vtxosBySpentBy[v.ArkTxid]; !ok { - vtxosBySpentBy[v.ArkTxid] = make([]types.Vtxo, 0) + vtxosBySpentBy[v.ArkTxid] = make([]clientlib.Vtxo, 0) } vtxosBySpentBy[v.ArkTxid] = append(vtxosBySpentBy[v.ArkTxid], v) } @@ -636,12 +597,12 @@ func (i *service) vtxosToTxs( if forfeitAmount > resultedAmount { vtxo := getVtxo(resultedVtxos, vtxosBySettledBy[sb]) - txs = append(txs, types.Transaction{ - TransactionKey: types.TransactionKey{ + txs = append(txs, clientlib.Transaction{ + TransactionKey: clientlib.TransactionKey{ CommitmentTxid: vtxo.CommitmentTxids[0], }, Amount: forfeitAmount - resultedAmount, - Type: types.TxSent, + Type: clientlib.TxSent, CreatedAt: vtxo.CreatedAt, Assets: NetVtxoAssets(vtxosBySettledBy[sb], resultedVtxos), }) @@ -658,7 +619,9 @@ func (i *service) vtxosToTxs( vtxo := getVtxo(resultedVtxos, vtxosBySpentBy[sb]) if resultedAmount == 0 { // send all: fetch the created vtxo to source creation and expiration timestamps - resp, err := i.indexer.GetVtxos(ctx, indexer.WithOutpoints([]types.Outpoint{{Txid: sb, VOut: 0}})) + resp, err := w.indexer.GetVtxos( + ctx, clientlib.WithOutpoints([]clientlib.Outpoint{{Txid: sb, VOut: 0}}), + ) if err != nil { return nil, err } @@ -677,13 +640,13 @@ func (i *service) vtxosToTxs( commitmentTxid = "" } - txs = append(txs, types.Transaction{ - TransactionKey: types.TransactionKey{ + txs = append(txs, clientlib.Transaction{ + TransactionKey: clientlib.TransactionKey{ CommitmentTxid: commitmentTxid, ArkTxid: arkTxid, }, Amount: spentAmount - resultedAmount, - Type: types.TxSent, + Type: clientlib.TxSent, CreatedAt: vtxo.CreatedAt, SettledBy: vtxo.SettledBy, Assets: NetVtxoAssets(vtxosBySpentBy[sb], resultedVtxos), @@ -693,34 +656,6 @@ func (i *service) vtxosToTxs( return txs, nil } -func (s *service) getReceiver(ctx context.Context, optReceiver string) (string, error) { - if len(optReceiver) > 0 { - if err := validateOffchainAddress(optReceiver); err != nil { - return "", err - } - return optReceiver, nil - } - _, changeAddr, _, err := s.newAddress(ctx) - if err != nil { - return "", err - } - return changeAddr.Address, nil -} - -func (s *service) getBoardingReceiver(ctx context.Context, optReceiver string) (string, error) { - if len(optReceiver) > 0 { - if err := validateOffchainAddress(optReceiver); err != nil { - return "", err - } - return optReceiver, nil - } - _, _, changeAddr, err := s.newAddress(ctx) - if err != nil { - return "", err - } - return changeAddr.Address, nil -} - // NetVtxoAssets returns the per-asset balance for a vtxo movement: // assets found in `gross` minus the portion in `subtract` that effectively // stayed in the wallet (change, already-owned vtxos, etc.). @@ -732,13 +667,13 @@ func (s *service) getBoardingReceiver(ctx context.Context, optReceiver string) ( // It is exported so that external SDKs reproducing the same vtxosToTxs // reconstruction (e.g. go-sdk) can derive Transaction.Assets with identical // semantics, rather than keeping a parallel copy of the helper. -func NetVtxoAssets(gross, subtract []types.Vtxo) []types.Asset { +func NetVtxoAssets(gross, subtract []clientlib.Vtxo) []clientlib.Asset { grossSums, order := sumVtxoAssets(gross) if len(order) == 0 { return nil } subSums, _ := sumVtxoAssets(subtract) - out := make([]types.Asset, 0, len(order)) + out := make([]clientlib.Asset, 0, len(order)) zero := new(big.Int) for _, id := range order { g := grossSums[id] @@ -748,7 +683,7 @@ func NetVtxoAssets(gross, subtract []types.Vtxo) []types.Asset { } if g.Cmp(s) > 0 { diff := new(big.Int).Sub(g, s) - out = append(out, types.Asset{AssetId: id, Amount: diff.Uint64()}) + out = append(out, clientlib.Asset{AssetId: id, Amount: diff.Uint64()}) } } if len(out) == 0 { @@ -760,7 +695,7 @@ func NetVtxoAssets(gross, subtract []types.Vtxo) []types.Asset { // sumVtxoAssets aggregates per-asset amounts across the given vtxos, returning // a map of asset id → total amount together with the asset ids in first-seen // order (useful for deterministic output). -func sumVtxoAssets(vtxos []types.Vtxo) (map[string]*big.Int, []string) { +func sumVtxoAssets(vtxos []clientlib.Vtxo) (map[string]*big.Int, []string) { sums := make(map[string]*big.Int) order := make([]string, 0) for _, v := range vtxos { diff --git a/pkg/client-lib/funding_opts.go b/pkg/client-wallet/funding_opts.go similarity index 100% rename from pkg/client-lib/funding_opts.go rename to pkg/client-wallet/funding_opts.go diff --git a/pkg/client-lib/funding_opts_test.go b/pkg/client-wallet/funding_opts_test.go similarity index 97% rename from pkg/client-lib/funding_opts_test.go rename to pkg/client-wallet/funding_opts_test.go index 9aeca6f1c..7c68ad569 100644 --- a/pkg/client-lib/funding_opts_test.go +++ b/pkg/client-wallet/funding_opts_test.go @@ -3,7 +3,7 @@ package wallet_test import ( "testing" - wallet "github.com/arkade-os/arkd/pkg/client-lib" + wallet "github.com/arkade-os/arkd/pkg/client-wallet" "github.com/stretchr/testify/require" ) diff --git a/pkg/client-wallet/go.mod b/pkg/client-wallet/go.mod new file mode 100644 index 000000000..b42b3212b --- /dev/null +++ b/pkg/client-wallet/go.mod @@ -0,0 +1,79 @@ +module github.com/arkade-os/arkd/pkg/client-wallet + +replace github.com/arkade-os/arkd/pkg/client-lib => ../client-lib + +replace github.com/arkade-os/arkd/pkg/ark-lib => ../ark-lib + +replace github.com/arkade-os/arkd/api-spec => ../../api-spec + +replace github.com/arkade-os/arkd/pkg/errors => ../errors + +replace github.com/btcsuite/btcd/btcec/v2 => github.com/btcsuite/btcd/btcec/v2 v2.3.3 + +go 1.26.3 + +require ( + github.com/arkade-os/arkd/pkg/ark-lib v0.0.0-00010101000000-000000000000 + github.com/arkade-os/arkd/pkg/client-lib v0.0.0-00010101000000-000000000000 + github.com/btcsuite/btcd v0.24.3-0.20240921052913-67b8efd3ba53 + github.com/btcsuite/btcd/btcec/v2 v2.3.4 + github.com/btcsuite/btcd/btcutil v1.1.5 + github.com/btcsuite/btcd/btcutil/psbt v1.1.9 + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 + github.com/lightningnetwork/lnd v0.18.2-beta + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.48.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/aead/siphash v1.0.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/arkade-os/arkd/api-spec v0.0.0-00010101000000-000000000000 // indirect + github.com/arkade-os/arkd/pkg/errors v0.0.0-00010101000000-000000000000 // indirect + github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect + github.com/btcsuite/btcwallet v0.16.10-0.20240718224643-db3a4a2543bd // indirect + github.com/btcsuite/btcwallet/wallet/txauthor v1.3.4 // indirect + github.com/btcsuite/btcwallet/wallet/txrules v1.2.1 // indirect + github.com/btcsuite/btcwallet/wallet/txsizes v1.2.4 // indirect + github.com/btcsuite/btcwallet/walletdb v1.4.2 // indirect + github.com/btcsuite/btcwallet/wtxmgr v1.5.3 // indirect + github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect + github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/decred/dcrd/lru v1.1.2 // indirect + github.com/google/cel-go v0.26.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect + github.com/kkdai/bstream v1.0.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect + github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd // indirect + github.com/lightninglabs/neutrino/cache v1.1.2 // indirect + github.com/lightningnetwork/lnd/clock v1.1.1 // indirect + github.com/lightningnetwork/lnd/fn v1.2.1 // indirect + github.com/lightningnetwork/lnd/queue v1.1.1 // indirect + github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect + github.com/lightningnetwork/lnd/tlv v1.2.6 // indirect + github.com/meshapi/grpc-api-gateway v0.1.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/client-wallet/go.sum b/pkg/client-wallet/go.sum new file mode 100644 index 000000000..3ed50443c --- /dev/null +++ b/pkg/client-wallet/go.sum @@ -0,0 +1,440 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= +github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.3-0.20240921052913-67b8efd3ba53 h1:XOZ/wRGHkKv0AqxfDks5IkzaQ1Ge6fq322ZOOG5VIkU= +github.com/btcsuite/btcd v0.24.3-0.20240921052913-67b8efd3ba53/go.mod h1:zHK7t7sw8XbsCkD64WePHE3r3k9/XoGAcf6mXV14c64= +github.com/btcsuite/btcd/btcec/v2 v2.3.3 h1:6+iXlDKE8RMtKsvK0gshlXIuPbyWM/h84Ensb7o3sC0= +github.com/btcsuite/btcd/btcec/v2 v2.3.3/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/btcutil/psbt v1.1.9 h1:UmfOIiWMZcVMOLaN+lxbbLSuoINGS1WmK1TZNI0b4yk= +github.com/btcsuite/btcd/btcutil/psbt v1.1.9/go.mod h1:ehBEvU91lxSlXtA+zZz3iFYx7Yq9eqnKx4/kSrnsvMY= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcwallet v0.16.10-0.20240718224643-db3a4a2543bd h1:QDb8foTCRoXrfoZVEzSYgSde16MJh4gCtCin8OCS0kI= +github.com/btcsuite/btcwallet v0.16.10-0.20240718224643-db3a4a2543bd/go.mod h1:X2xDre+j1QphTRo54y2TikUzeSvreL1t1aMXrD8Kc5A= +github.com/btcsuite/btcwallet/wallet/txauthor v1.3.4 h1:poyHFf7+5+RdxNp5r2T6IBRD7RyraUsYARYbp/7t4D8= +github.com/btcsuite/btcwallet/wallet/txauthor v1.3.4/go.mod h1:GETGDQuyq+VFfH1S/+/7slLM/9aNa4l7P4ejX6dJfb0= +github.com/btcsuite/btcwallet/wallet/txrules v1.2.1 h1:UZo7YRzdHbwhK7Rhv3PO9bXgTxiOH45edK5qdsdiatk= +github.com/btcsuite/btcwallet/wallet/txrules v1.2.1/go.mod h1:MVSqRkju/IGxImXYPfBkG65FgEZYA4fXchheILMVl8g= +github.com/btcsuite/btcwallet/wallet/txsizes v1.2.4 h1:nmcKAVTv/cmYrs0A4hbiC6Qw+WTLYy/14SmTt3mLnCo= +github.com/btcsuite/btcwallet/wallet/txsizes v1.2.4/go.mod h1:YqJR8WAAHiKIPesZTr9Cx9Az4fRhRLcJ6GcxzRUZCAc= +github.com/btcsuite/btcwallet/walletdb v1.4.2 h1:zwZZ+zaHo4mK+FAN6KeK85S3oOm+92x2avsHvFAhVBE= +github.com/btcsuite/btcwallet/walletdb v1.4.2/go.mod h1:7ZQ+BvOEre90YT7eSq8bLoxTsgXidUzA/mqbRS114CQ= +github.com/btcsuite/btcwallet/wtxmgr v1.5.3 h1:QrWCio9Leh3DwkWfp+A1SURj8pYn3JuTLv3waP5uEro= +github.com/btcsuite/btcwallet/wtxmgr v1.5.3/go.mod h1:M4nQpxGTXiDlSOODKXboXX7NFthmiBNjzAKKNS7Fhjg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0 h1:J9B4L7e3oqhXOcm+2IuNApwzQec85lE+QaikUcCs+dk= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= +github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= +github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c= +github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/decred/dcrd/lru v1.1.2 h1:KdCzlkxppuoIDGEvCGah1fZRicrDH36IipvlB1ROkFY= +github.com/decred/dcrd/lru v1.1.2/go.mod h1:gEdCVgXs1/YoBvFWt7Scgknbhwik3FgVSzlnCcXL2N8= +github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M= +github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= +github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fergusstrange/embedded-postgres v1.25.0 h1:sa+k2Ycrtz40eCRPOzI7Ry7TtkWXXJ+YRsxpKMDhxK0= +github.com/fergusstrange/embedded-postgres v1.25.0/go.mod h1:t/MLs0h9ukYM6FSt99R7InCHs1nW0ordoVCcnzmpTYw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU= +github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= +github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc= +github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk= +github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd h1:D8aRocHpoCv43hL8egXEMYyPmyOiefFHZ66338KQB2s= +github.com/lightninglabs/neutrino v0.16.1-0.20240425105051-602843d34ffd/go.mod h1:x3OmY2wsA18+Kc3TSV2QpSUewOCiscw2mKpXgZv2kZk= +github.com/lightninglabs/neutrino/cache v1.1.2 h1:C9DY/DAPaPxbFC+xNNEI/z1SJY9GS3shmlu5hIQ798g= +github.com/lightninglabs/neutrino/cache v1.1.2/go.mod h1:XJNcgdOw1LQnanGjw8Vj44CvguYA25IMKjWFZczwZuo= +github.com/lightningnetwork/lightning-onion v1.2.1-0.20230823005744-06182b1d7d2f h1:Pua7+5TcFEJXIIZ1I2YAUapmbcttmLj4TTi786bIi3s= +github.com/lightningnetwork/lightning-onion v1.2.1-0.20230823005744-06182b1d7d2f/go.mod h1:c0kvRShutpj3l6B9WtTsNTBUtjSmjZXbJd9ZBRQOSKI= +github.com/lightningnetwork/lnd v0.18.2-beta h1:Qv4xQ2ka05vqzmdkFdISHCHP6CzHoYNVKfD18XPjHsM= +github.com/lightningnetwork/lnd v0.18.2-beta/go.mod h1:cGQR1cVEZFZQcCx2VBbDY8xwGjCz+SupSopU1HpjP2I= +github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0= +github.com/lightningnetwork/lnd/clock v1.1.1/go.mod h1:mGnAhPyjYZQJmebS7aevElXKTFDuO+uNFFfMXK1W8xQ= +github.com/lightningnetwork/lnd/fn v1.2.1 h1:pPsVGrwi9QBwdLJzaEGK33wmiVKOxs/zc8H7+MamFf0= +github.com/lightningnetwork/lnd/fn v1.2.1/go.mod h1:SyFohpVrARPKH3XVAJZlXdVe+IwMYc4OMAvrDY32kw0= +github.com/lightningnetwork/lnd/healthcheck v1.2.4 h1:lLPLac+p/TllByxGSlkCwkJlkddqMP5UCoawCj3mgFQ= +github.com/lightningnetwork/lnd/healthcheck v1.2.4/go.mod h1:G7Tst2tVvWo7cx6mSBEToQC5L1XOGxzZTPB29g9Rv2I= +github.com/lightningnetwork/lnd/kvdb v1.4.8 h1:xH0a5Vi1yrcZ5BEeF2ba3vlKBRxrL9uYXlWTjOjbNTY= +github.com/lightningnetwork/lnd/kvdb v1.4.8/go.mod h1:J2diNABOoII9UrMnxXS5w7vZwP7CA1CStrl8MnIrb3A= +github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= +github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= +github.com/lightningnetwork/lnd/sqldb v1.0.2 h1:PfuYzScYMD9/QonKo/QvgsbXfTnH5DfldIimkfdW4Bk= +github.com/lightningnetwork/lnd/sqldb v1.0.2/go.mod h1:V2Xl6JNWLTKE97WJnwfs0d0TYJdIQTqK8/3aAwkd3qI= +github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM= +github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= +github.com/lightningnetwork/lnd/tlv v1.2.6 h1:icvQG2yDr6k3ZuZzfRdG3EJp6pHurcuh3R6dg0gv/Mw= +github.com/lightningnetwork/lnd/tlv v1.2.6/go.mod h1:/CmY4VbItpOldksocmGT4lxiJqRP9oLxwSZOda2kzNQ= +github.com/lightningnetwork/lnd/tor v1.1.2 h1:3zv9z/EivNFaMF89v3ciBjCS7kvCj4ZFG7XvD2Qq0/k= +github.com/lightningnetwork/lnd/tor v1.1.2/go.mod h1:j7T9uJ2NLMaHwE7GiBGnpYLn4f7NRoTM6qj+ul6/ycA= +github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw= +github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796/go.mod h1:3p7ZTf9V1sNPI5H8P3NkTFF4LuwMdPl2DodF60qAKqY= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/meshapi/grpc-api-gateway v0.1.0 h1:0rGp4qZQ6T9Ud0KfzdHYsEju4AX/Q3AQOU7unoBLssY= +github.com/meshapi/grpc-api-gateway v0.1.0/go.mod h1:lkFQUbwq7i/JqEPZMzCIRskp9Jb7tm1uLODwsOdw064= +github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= +github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v1.1.12 h1:BOIssBaW1La0/qbNZHXOOa71dZfZEQOzW7dqQf3phss= +github.com/opencontainers/runc v1.1.12/go.mod h1:S+lQwSfncpBha7XTy/5lBwWgm5+y5Ma/O44Ekby9FK8= +github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= +github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.11.1 h1:+4eQaD7vAZ6DsfsxB15hbE0odUjGI5ARs9yskGu1v4s= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4dN7GR16kFc5fp3d1RIYzJW5onx8Ybykw2YQFA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= +go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= +go.etcd.io/etcd/api/v3 v3.5.7 h1:sbcmosSVesNrWOJ58ZQFitHMdncusIifYcrBfwrlJSY= +go.etcd.io/etcd/api/v3 v3.5.7/go.mod h1:9qew1gCdDDLu+VwmeG+iFpL+QlpHTo7iubavdVDgCAA= +go.etcd.io/etcd/client/pkg/v3 v3.5.7 h1:y3kf5Gbp4e4q7egZdn5T7W9TSHUvkClN6u+Rq9mEOmg= +go.etcd.io/etcd/client/pkg/v3 v3.5.7/go.mod h1:o0Abi1MK86iad3YrWhgUsbGx1pmTS+hrORWc2CamuhY= +go.etcd.io/etcd/client/v2 v2.305.7 h1:AELPkjNR3/igjbO7CjyF1fPuVPjrblliiKj+Y6xSGOU= +go.etcd.io/etcd/client/v2 v2.305.7/go.mod h1:GQGT5Z3TBuAQGvgPfhR7VPySu/SudxmEkRq9BgzFU6s= +go.etcd.io/etcd/client/v3 v3.5.7 h1:u/OhpiuCgYY8awOHlhIhmGIGpxfBU/GZBUP3m/3/Iz4= +go.etcd.io/etcd/client/v3 v3.5.7/go.mod h1:sOWmj9DZUMyAngS7QQwCyAXXAL6WhgTOPLNS/NabQgw= +go.etcd.io/etcd/pkg/v3 v3.5.7 h1:obOzeVwerFwZ9trMWapU/VjDcYUJb5OfgC1zqEGWO/0= +go.etcd.io/etcd/pkg/v3 v3.5.7/go.mod h1:kcOfWt3Ov9zgYdOiJ/o1Y9zFfLhQjylTgL4Lru8opRo= +go.etcd.io/etcd/raft/v3 v3.5.7 h1:aN79qxLmV3SvIq84aNTliYGmjwsW6NqJSnqmI1HLJKc= +go.etcd.io/etcd/raft/v3 v3.5.7/go.mod h1:TflkAb/8Uy6JFBxcRaH2Fr6Slm9mCPVdI2efzxY96yU= +go.etcd.io/etcd/server/v3 v3.5.7 h1:BTBD8IJUV7YFgsczZMHhMTS67XuA4KpRquL0MFOJGRk= +go.etcd.io/etcd/server/v3 v3.5.7/go.mod h1:gxBgT84issUVBRpZ3XkW1T55NjOb4vZZRI4wVvNhf4A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0 h1:Wx7nFnvCaissIUZxPkBqDz2963Z+Cl+PkYbDKzTxDqQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0/go.mod h1:E5NNboN0UqSAki0Atn9kVwaN7I+l25gGxDqBueo/74E= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1 h1:ofMbch7i29qIUf7VtF+r0HRF6ac0SBaPSziSsKp7wkk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1/go.mod h1:Kv8liBeVNFkkkbilbgWRpV+wWuu+H5xdOT6HAgd30iw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1 h1:CFMFNoz+CGprjFAFy+RJFrfEe4GBia3RRm2a4fREvCA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1/go.mod h1:xOvWoTOrQjxjW61xtOmD/WKGRYb/P4NzRo3bs65U6Rk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v0.9.0 h1:C0g6TWmQYvjKRnljRULLWUVJGy8Uvu0NEL/5frY2/t4= +go.opentelemetry.io/proto/otlp v0.9.0/go.mod h1:1vKfU9rv61e9EVGthD1zNvUbiwPcimSsOPU9brfSHJg= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM= +google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.29.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8= +modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/pkg/client-lib/identity/singlekey/identity.go b/pkg/client-wallet/identity/identity.go similarity index 86% rename from pkg/client-lib/identity/singlekey/identity.go rename to pkg/client-wallet/identity/identity.go index 2db68dfa1..fea2cf364 100644 --- a/pkg/client-lib/identity/singlekey/identity.go +++ b/pkg/client-wallet/identity/identity.go @@ -1,4 +1,4 @@ -package singlekeyidentity +package identity import ( "bytes" @@ -8,10 +8,8 @@ import ( "strings" "github.com/arkade-os/arkd/pkg/ark-lib/script" - "github.com/arkade-os/arkd/pkg/ark-lib/tree" - "github.com/arkade-os/arkd/pkg/client-lib/identity" - identitystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + identitystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/psbt" @@ -20,6 +18,11 @@ import ( "github.com/btcsuite/btcd/wire" ) +const ( + KVStore = "kv" + SQLStore = "sql" +) + var ( ErrNotInitialized = fmt.Errorf("identity not initialized") ErrIsLocked = fmt.Errorf("identity is locked") @@ -31,7 +34,7 @@ type service struct { data *identitystore.IdentityData } -func NewIdentity(store identitystore.IdentityStore) (identity.Identity, error) { +func NewIdentity(store identitystore.IdentityStore) (clientlib.Identity, error) { data, err := store.Get() if err != nil { return nil, err @@ -43,7 +46,7 @@ func NewIdentity(store identitystore.IdentityStore) (identity.Identity, error) { } func (s *service) GetType() string { - return identity.SingleKeyIdentity + return clientlib.SingleKeyIdentity } func (s *service) Create( @@ -51,7 +54,7 @@ func (s *service) Create( ) (string, error) { var privateKey *btcec.PrivateKey if len(seed) <= 0 { - prvkey, err := utils.GenerateRandomPrivateKey() + prvkey, err := btcec.NewPrivateKey() if err != nil { return "", err } @@ -66,10 +69,10 @@ func (s *service) Create( } pwd := []byte(password) - passwordHash := utils.HashPassword(pwd) + passwordHash := hashPassword(pwd) pubkey := privateKey.PubKey() buf := privateKey.Serialize() - encryptedPrivateKey, err := utils.EncryptAES256(buf, pwd) + encryptedPrivateKey, err := encryptAES256(buf, pwd) if err != nil { return "", err } @@ -113,13 +116,13 @@ func (s *service) Unlock( } pwd := []byte(password) - currentPassHash := utils.HashPassword(pwd) + currentPassHash := hashPassword(pwd) if !bytes.Equal(s.data.PasswordHash, currentPassHash) { return false, fmt.Errorf("invalid password") } - privateKeyBytes, err := utils.DecryptAES256(s.data.EncryptedPrvkey, pwd) + privateKeyBytes, err := decryptAES256(s.data.EncryptedPrvkey, pwd) if err != nil { return false, err } @@ -144,21 +147,21 @@ func (s *service) Dump(ctx context.Context) (string, error) { return hex.EncodeToString(s.privateKey.Serialize()), nil } -func (s *service) NewKey(ctx context.Context) (*identity.KeyRef, error) { +func (s *service) NewKey(ctx context.Context) (*clientlib.KeyRef, error) { if s.data == nil { return nil, ErrNotInitialized } - return &identity.KeyRef{ + return &clientlib.KeyRef{ Id: "m", PubKey: s.data.PubKey, }, nil } -func (s *service) GetKey(ctx context.Context, _ string) (*identity.KeyRef, error) { +func (s *service) GetKey(ctx context.Context, _ string) (*clientlib.KeyRef, error) { if s.data == nil { return nil, ErrNotInitialized } - return &identity.KeyRef{ + return &clientlib.KeyRef{ Id: "m", PubKey: s.data.PubKey, }, nil @@ -172,12 +175,12 @@ func (s *service) GetKeyIndex(ctx context.Context, _ string) (uint32, error) { return 0, nil } -func (s *service) ListKeys(ctx context.Context) ([]identity.KeyRef, error) { +func (s *service) ListKeys(ctx context.Context) ([]clientlib.KeyRef, error) { key, err := s.GetKey(ctx, "") if err != nil { return nil, err } - return []identity.KeyRef{*key}, nil + return []clientlib.KeyRef{*key}, nil } func (s *service) SignTransaction( ctx context.Context, tx string, _ map[string]string, @@ -258,6 +261,24 @@ func (s *service) SignTransaction( return ptx.B64Encode() } +func (s *service) SignMessage( + ctx context.Context, message []byte, +) (string, error) { + if s.data == nil { + return "", ErrNotInitialized + } + if s.IsLocked() { + return "", ErrIsLocked + } + + sig, err := schnorr.Sign(s.privateKey, message) + if err != nil { + return "", err + } + + return hex.EncodeToString(sig.Serialize()), nil +} + func (s *service) signTapscriptSpend( updater *psbt.Updater, input psbt.PInput, @@ -388,36 +409,3 @@ func (s *service) signTaprootKeySpend( return nil } - -func (s *service) NewVtxoTreeSigner(ctx context.Context) (tree.SignerSession, error) { - if s.data == nil { - return nil, ErrNotInitialized - } - if s.IsLocked() { - return nil, ErrIsLocked - } - - key, err := btcec.NewPrivateKey() - if err != nil { - return nil, err - } - return tree.NewTreeSignerSession(key), nil -} - -func (s *service) SignMessage( - ctx context.Context, message []byte, -) (string, error) { - if s.data == nil { - return "", ErrNotInitialized - } - if s.IsLocked() { - return "", ErrIsLocked - } - - sig, err := schnorr.Sign(s.privateKey, message) - if err != nil { - return "", err - } - - return hex.EncodeToString(sig.Serialize()), nil -} diff --git a/pkg/client-lib/identity/identity_test.go b/pkg/client-wallet/identity/identity_test.go similarity index 88% rename from pkg/client-lib/identity/identity_test.go rename to pkg/client-wallet/identity/identity_test.go index a00a41dc0..372acdbcf 100644 --- a/pkg/client-lib/identity/identity_test.go +++ b/pkg/client-wallet/identity/identity_test.go @@ -4,9 +4,9 @@ import ( "encoding/hex" "testing" - "github.com/arkade-os/arkd/pkg/client-lib/identity" - singlekeyidentity "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey" - identityinmemorystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store/inmemory" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-wallet/identity" + identityinmemorystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store/inmemory" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/chaincfg" "github.com/stretchr/testify/require" @@ -79,12 +79,12 @@ func TestGetKey(t *testing.T) { t.Run("invalid", func(t *testing.T) { tests := []struct { name string - setup func(t *testing.T) identity.Identity + setup func(t *testing.T) clientlib.Identity expErr string }{ { "not initialized", - func(t *testing.T) identity.Identity { return newTestIdentity(t) }, + func(t *testing.T) clientlib.Identity { return newTestIdentity(t) }, "identity not initialized", }, } @@ -114,12 +114,12 @@ func TestNewKey(t *testing.T) { t.Run("invalid", func(t *testing.T) { tests := []struct { name string - setup func(t *testing.T) identity.Identity + setup func(t *testing.T) clientlib.Identity expErr string }{ { "not initialized", - func(t *testing.T) identity.Identity { return newTestIdentity(t) }, + func(t *testing.T) clientlib.Identity { return newTestIdentity(t) }, "identity not initialized", }, } @@ -181,16 +181,16 @@ func TestListKeys(t *testing.T) { }) } -func newTestIdentity(t *testing.T) identity.Identity { +func newTestIdentity(t *testing.T) clientlib.Identity { t.Helper() store, err := identityinmemorystore.NewStore() require.NoError(t, err) - identitySvc, err := singlekeyidentity.NewIdentity(store) + identitySvc, err := identity.NewIdentity(store) require.NoError(t, err) return identitySvc } -func newUnlockedTestIdentity(t *testing.T) (identity.Identity, string) { +func newUnlockedTestIdentity(t *testing.T) (clientlib.Identity, string) { t.Helper() identitySvc := newTestIdentity(t) ctx := t.Context() diff --git a/pkg/client-lib/identity/singlekey/store/file/store.go b/pkg/client-wallet/identity/store/file/store.go similarity index 96% rename from pkg/client-lib/identity/singlekey/store/file/store.go rename to pkg/client-wallet/identity/store/file/store.go index 59e01732b..2f3456038 100644 --- a/pkg/client-lib/identity/singlekey/store/file/store.go +++ b/pkg/client-wallet/identity/store/file/store.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - identitystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store" + identitystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store" "github.com/btcsuite/btcd/btcec/v2" ) @@ -137,7 +137,7 @@ func (s *fileStore) write(data *identityData) error { return err } - err = os.WriteFile(s.filePath, jsonString, 0755) + err = os.WriteFile(s.filePath, jsonString, 0644) if err != nil { return err } diff --git a/pkg/client-lib/identity/singlekey/store/inmemory/store.go b/pkg/client-wallet/identity/store/inmemory/store.go similarity index 86% rename from pkg/client-lib/identity/singlekey/store/inmemory/store.go rename to pkg/client-wallet/identity/store/inmemory/store.go index 0f4f7c664..e4ef73b77 100644 --- a/pkg/client-lib/identity/singlekey/store/inmemory/store.go +++ b/pkg/client-wallet/identity/store/inmemory/store.go @@ -3,7 +3,7 @@ package identityinmemorystore import ( "sync" - identitystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store" + identitystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store" ) type inmemoryStore struct { diff --git a/pkg/client-lib/identity/singlekey/store/store.go b/pkg/client-wallet/identity/store/store.go similarity index 100% rename from pkg/client-lib/identity/singlekey/store/store.go rename to pkg/client-wallet/identity/store/store.go diff --git a/pkg/client-lib/identity/singlekey/store/store_test.go b/pkg/client-wallet/identity/store/store_test.go similarity index 63% rename from pkg/client-lib/identity/singlekey/store/store_test.go rename to pkg/client-wallet/identity/store/store_test.go index fdb61ae88..ef5be8f7b 100644 --- a/pkg/client-lib/identity/singlekey/store/store_test.go +++ b/pkg/client-wallet/identity/store/store_test.go @@ -3,10 +3,10 @@ package identitystore_test import ( "testing" - identitystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store" - identityfilestore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store/file" - identityinmemorystore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store/inmemory" - "github.com/arkade-os/arkd/pkg/client-lib/types" + identitystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store" + identityfilestore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store/file" + identityinmemorystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store/inmemory" + "github.com/arkade-os/arkd/pkg/client-wallet/types" "github.com/btcsuite/btcd/btcec/v2" "github.com/stretchr/testify/require" ) @@ -19,16 +19,19 @@ func TestWalletStore(t *testing.T) { PubKey: key.PubKey(), } + newKey, _ := btcec.NewPrivateKey() + newData := identitystore.IdentityData{ + EncryptedPrvkey: make([]byte, 32), + PasswordHash: make([]byte, 32), + PubKey: newKey.PubKey(), + } + tests := []struct { name string args []interface{} }{ - { - name: types.InMemoryStore, - }, - { - name: types.FileStore, - }, + {name: types.InMemoryStore}, + {name: types.FileStore}, } for i := range tests { @@ -58,8 +61,12 @@ func TestWalletStore(t *testing.T) { require.Equal(t, testData, *data) // Check overwriting the store. - err = storeSvc.Add(testData) + err = storeSvc.Add(newData) + require.NoError(t, err) + + data, err = storeSvc.Get() require.NoError(t, err) + require.Equal(t, newData, *data) }) } } diff --git a/pkg/client-wallet/identity/utils.go b/pkg/client-wallet/identity/utils.go new file mode 100644 index 000000000..d0baa91f1 --- /dev/null +++ b/pkg/client-wallet/identity/utils.go @@ -0,0 +1,95 @@ +package identity + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + + "golang.org/x/crypto/pbkdf2" +) + +func hashPassword(password []byte) []byte { + hash := sha256.Sum256(password) + return hash[:] +} + +func encryptAES256(privateKey, password []byte) ([]byte, error) { + if len(privateKey) == 0 { + return nil, fmt.Errorf("missing plaintext private key") + } + if len(password) == 0 { + return nil, fmt.Errorf("missing encryption password") + } + + key, salt, err := deriveKey(password, nil) + if err != nil { + return nil, err + } + + blockCipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(blockCipher) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return nil, err + } + + ciphertext := gcm.Seal(nonce, nonce, privateKey, nil) + ciphertext = append(ciphertext, salt...) + + return ciphertext, nil +} + +func decryptAES256(encrypted, password []byte) ([]byte, error) { + if len(encrypted) == 0 { + return nil, fmt.Errorf("missing encrypted mnemonic") + } + if len(password) == 0 { + return nil, fmt.Errorf("missing decryption password") + } + + salt := encrypted[len(encrypted)-32:] + data := encrypted[:len(encrypted)-32] + + key, _, err := deriveKey(password, salt) + if err != nil { + return nil, err + } + + blockCipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(blockCipher) + if err != nil { + return nil, err + } + nonce, text := data[:gcm.NonceSize()], data[gcm.NonceSize():] + // #nosec G407 + plaintext, err := gcm.Open(nil, nonce, text, nil) + if err != nil { + return nil, fmt.Errorf("invalid password") + } + return plaintext, nil +} + +// deriveKey derives a 32 byte array key from a custom passhprase +func deriveKey(password, salt []byte) ([]byte, []byte, error) { + if salt == nil { + salt = make([]byte, 32) + if _, err := rand.Read(salt); err != nil { + return nil, nil, err + } + } + iterations := 10000 + keySize := 32 + key := pbkdf2.Key(password, salt, iterations, keySize, sha256.New) + return key, salt, nil +} diff --git a/pkg/client-lib/init.go b/pkg/client-wallet/init.go similarity index 70% rename from pkg/client-lib/init.go rename to pkg/client-wallet/init.go index 5090936c7..9545f7b88 100644 --- a/pkg/client-lib/init.go +++ b/pkg/client-wallet/init.go @@ -5,29 +5,28 @@ import ( "fmt" arklib "github.com/arkade-os/arkd/pkg/ark-lib" - grpcclient "github.com/arkade-os/arkd/pkg/client-lib/client/grpc" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-lib/client" "github.com/arkade-os/arkd/pkg/client-lib/explorer" - mempoolexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" - grpcindexer "github.com/arkade-os/arkd/pkg/client-lib/indexer/grpc" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" + "github.com/arkade-os/arkd/pkg/client-lib/indexer" + "github.com/arkade-os/arkd/pkg/client-wallet/types" ) -func (a *service) Init(ctx context.Context, args InitArgs) error { +func (w *wallet) Init(ctx context.Context, args InitArgs) error { if err := args.validate(); err != nil { return fmt.Errorf("invalid args: %s", err) } - if a.identity == nil { + if w.identity == nil { return ErrNotInitialized } - return a.init(ctx, args.parse(), args.Explorer) + return w.init(ctx, args.parse(), args.Explorer) } -func (a *service) init( - ctx context.Context, args args, explorerSvc explorer.Explorer, +func (w *wallet) init( + ctx context.Context, args args, explorerSvc clientlib.Explorer, ) error { - clientSvc, err := grpcclient.NewClient(args.serverUrl) + clientSvc, err := client.NewClient(args.serverUrl) if err != nil { return fmt.Errorf("failed to setup client: %s", err) } @@ -37,27 +36,27 @@ func (a *service) init( return fmt.Errorf("failed to connect to server: %s", err) } - indexerSvc, err := grpcindexer.NewClient(args.serverUrl) + indexerSvc, err := indexer.NewClient(args.serverUrl) if err != nil { return fmt.Errorf("failed to setup indexer: %s", err) } if explorerSvc == nil { - explorerOpts := []mempoolexplorer.Option{ - mempoolexplorer.WithTracker(false), + explorerOpts := []explorer.Option{ + explorer.WithTracker(false), } - explorerSvc, err = mempoolexplorer.NewExplorer( - args.explorerURL, utils.NetworkFromString(info.Network), explorerOpts..., + explorerSvc, err = explorer.NewExplorer( + args.explorerURL, clientlib.NetworkFromString(info.Network), explorerOpts..., ) if err != nil { return fmt.Errorf("failed to setup explorer: %s", err) } } - network := utils.NetworkFromString(info.Network) + network := clientlib.NetworkFromString(info.Network) - if _, err := a.identity.Create( - ctx, utils.ToBitcoinNetwork(network), args.password, args.seed, + if _, err := w.identity.Create( + ctx, clientlib.ToBitcoinNetwork(network), args.password, args.seed, ); err != nil { return err } @@ -108,17 +107,17 @@ func (a *service) init( VtxoMinAmount: info.VtxoMinAmount, VtxoMaxAmount: info.VtxoMaxAmount, CheckpointTapscript: info.CheckpointTapscript, - Fees: info.Fees, + Fees: types.FeeInfo(info.Fees), } - if err := a.store.ConfigStore().AddData(ctx, cfgData); err != nil { + if err := w.store.AddData(ctx, cfgData); err != nil { return err } - a.Config = &cfgData - a.client = clientSvc - a.indexer = indexerSvc - if a.explorer == nil { - a.explorer = explorerSvc + w.Config = &cfgData + w.client = clientSvc + w.indexer = indexerSvc + if w.explorer == nil { + w.explorer = explorerSvc } return nil @@ -129,7 +128,7 @@ type InitArgs struct { Seed string Password string ExplorerURL string - Explorer explorer.Explorer + Explorer clientlib.Explorer } func (a InitArgs) validate() error { diff --git a/pkg/client-wallet/send.go b/pkg/client-wallet/send.go new file mode 100644 index 000000000..84fae69ee --- /dev/null +++ b/pkg/client-wallet/send.go @@ -0,0 +1,71 @@ +package wallet + +import ( + "context" + "time" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + offchaintx "github.com/arkade-os/arkd/pkg/client-lib/offchain-tx" +) + +func (w *wallet) SendOffChain( + ctx context.Context, receivers []clientlib.Receiver, opts ...offchaintx.Option, +) (*SendOffChainRes, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + vtxos, err := w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) + if err != nil { + return nil, err + } + + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + w.txLock.Lock() + defer w.txLock.Unlock() + + return offchaintx.Send(ctx, offchaintx.SendArgs{ + Client: w.client, + ServerInfo: w.Config.ClientInfo(), + SignTx: signTx, + Vtxos: vtxos, + ChangeAddr: offchainAddr.Address, + Receivers: receivers, + }, opts...) +} + +func (w *wallet) FinalizePendingTxs( + ctx context.Context, createdAfter *time.Time, +) ([]string, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + vtxos, err := w.getPendingVtxos(ctx, createdAfter) + if err != nil { + return nil, err + } + + if len(vtxos) <= 0 { + return nil, nil + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + return offchaintx.FinalizePendingTxs(ctx, offchaintx.FinalizePendingTxsArgs{ + Client: w.client, + SignTx: signTx, + Vtxos: vtxos, + CreatedAfter: createdAfter, + }) +} diff --git a/pkg/client-lib/store/file/config_store.go b/pkg/client-wallet/store/file/store.go similarity index 94% rename from pkg/client-lib/store/file/config_store.go rename to pkg/client-wallet/store/file/store.go index fbaa7cee2..d41c153e7 100644 --- a/pkg/client-lib/store/file/config_store.go +++ b/pkg/client-wallet/store/file/store.go @@ -8,7 +8,7 @@ import ( "os" "path/filepath" - "github.com/arkade-os/arkd/pkg/client-lib/types" + "github.com/arkade-os/arkd/pkg/client-wallet/types" ) const ( @@ -19,7 +19,7 @@ type configStore struct { filePath string } -func NewConfigStore(baseDir string) (types.ConfigStore, error) { +func NewStore(baseDir string) (types.Store, error) { if len(baseDir) <= 0 { return nil, fmt.Errorf("missing base directory") } @@ -96,7 +96,7 @@ func (s *configStore) GetData(_ context.Context) (*types.Config, error) { return &data, nil } -func (s *configStore) CleanData(_ context.Context) error { +func (s *configStore) Clean(_ context.Context) error { if err := s.write(&storeData{}); err != nil { return fmt.Errorf("failed to write to store: %s", err) } @@ -143,7 +143,7 @@ func (s *configStore) write(data *storeData) error { return err } - err = os.WriteFile(s.filePath, jsonString, 0755) + err = os.WriteFile(s.filePath, jsonString, 0644) if err != nil { return err } diff --git a/pkg/client-lib/store/file/types.go b/pkg/client-wallet/store/file/types.go similarity index 96% rename from pkg/client-lib/store/file/types.go rename to pkg/client-wallet/store/file/types.go index 81653b464..e083632f4 100644 --- a/pkg/client-lib/store/file/types.go +++ b/pkg/client-wallet/store/file/types.go @@ -6,8 +6,8 @@ import ( arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" - "github.com/arkade-os/arkd/pkg/client-lib/types" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-wallet/types" "github.com/btcsuite/btcd/btcec/v2" ) @@ -52,7 +52,7 @@ func (d storeData) isEmpty() bool { } func (d storeData) decode() types.Config { - network := utils.NetworkFromString(d.Network) + network := clientlib.NetworkFromString(d.Network) sessionDuration, _ := strconv.Atoi(d.SessionDuration) unilateralExitDelay, _ := strconv.Atoi(d.UnilateralExitDelay) boardingExitDelay, _ := strconv.Atoi(d.BoardingExitDelay) diff --git a/pkg/client-lib/store/file/utils.go b/pkg/client-wallet/store/file/utils.go similarity index 100% rename from pkg/client-lib/store/file/utils.go rename to pkg/client-wallet/store/file/utils.go diff --git a/pkg/client-wallet/store/inmemory/store.go b/pkg/client-wallet/store/inmemory/store.go new file mode 100644 index 000000000..f15d04fe3 --- /dev/null +++ b/pkg/client-wallet/store/inmemory/store.go @@ -0,0 +1,59 @@ +package inmemorystore + +import ( + "context" + "sync" + + "github.com/arkade-os/arkd/pkg/client-wallet/types" +) + +type service struct { + data *types.Config + lock *sync.RWMutex +} + +func NewStore() (types.Store, error) { + lock := &sync.RWMutex{} + return &service{lock: lock}, nil +} + +func (s *service) Close() {} + +func (s *service) GetType() string { + return "inmemory" +} + +func (s *service) GetDatadir() string { + return "" +} + +func (s *service) AddData( + _ context.Context, data types.Config, +) error { + s.lock.Lock() + defer s.lock.Unlock() + + dataCopy := data + s.data = &dataCopy + return nil +} + +func (s *service) GetData(_ context.Context) (*types.Config, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + if s.data == nil { + return nil, nil + } + + data := *s.data + return &data, nil +} + +func (s *service) Clean(_ context.Context) error { + s.lock.Lock() + defer s.lock.Unlock() + + s.data = nil + return nil +} diff --git a/pkg/client-wallet/store/service.go b/pkg/client-wallet/store/service.go new file mode 100644 index 000000000..fce7afee8 --- /dev/null +++ b/pkg/client-wallet/store/service.go @@ -0,0 +1,24 @@ +package store + +import ( + "fmt" + + filestore "github.com/arkade-os/arkd/pkg/client-wallet/store/file" + inmemorystore "github.com/arkade-os/arkd/pkg/client-wallet/store/inmemory" + "github.com/arkade-os/arkd/pkg/client-wallet/types" +) + +type service struct { + configStore types.Store +} + +func NewStore(storeType, datadir string) (types.Store, error) { + switch storeType { + case types.InMemoryStore: + return inmemorystore.NewStore() + case types.FileStore: + return filestore.NewStore(datadir) + default: + return nil, fmt.Errorf("unknown config store type") + } +} diff --git a/pkg/client-wallet/store/service_test.go b/pkg/client-wallet/store/service_test.go new file mode 100644 index 000000000..78de9d0d3 --- /dev/null +++ b/pkg/client-wallet/store/service_test.go @@ -0,0 +1,84 @@ +package store_test + +import ( + "testing" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/client-wallet/store" + "github.com/arkade-os/arkd/pkg/client-wallet/types" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/stretchr/testify/require" +) + +var ( + key, _ = btcec.NewPrivateKey() + forfeitkKey, _ = btcec.NewPrivateKey() + testConfigData = types.Config{ + ServerUrl: "127.0.0.1:7070", + SignerPubKey: key.PubKey(), + ForfeitPubKey: forfeitkKey.PubKey(), + Network: arklib.BitcoinRegTest, + SessionDuration: 10, + UnilateralExitDelay: arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 512}, + Dust: 1000, + BoardingExitDelay: arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 512}, + ForfeitAddress: "bcrt1qzvqj", + CheckpointTapscript: "abcdefghijklmnopqrtuvxyz", + } +) + +func TestStoreAddData(t *testing.T) { + forEach(t, func(t *testing.T, storeSvc types.Store) { + t.Run("valid", func(t *testing.T) { + ctx := t.Context() + + // Check empty data when store is empty. + data, err := storeSvc.GetData(ctx) + require.NoError(t, err) + require.Nil(t, data) + + // Check add and retrieve data. + err = storeSvc.AddData(ctx, testConfigData) + require.NoError(t, err) + + data, err = storeSvc.GetData(ctx) + require.NoError(t, err) + require.Equal(t, testConfigData, *data) + + // Check overwriting the store. + err = storeSvc.AddData(ctx, testConfigData) + require.NoError(t, err) + err = storeSvc.AddData(ctx, testConfigData) + require.NoError(t, err) + }) + }) +} + +func forEach(t *testing.T, fn func(t *testing.T, storeSvc types.Store)) { + t.Helper() + + tests := []struct { + name string + storeType string + datadir string + }{ + { + name: "inmemory", + storeType: types.InMemoryStore, + }, + { + name: "file", + storeType: types.FileStore, + datadir: t.TempDir(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, err := store.NewStore(tt.storeType, tt.datadir) + require.NoError(t, err) + require.NotNil(t, svc) + fn(t, svc) + }) + } +} diff --git a/pkg/client-wallet/types.go b/pkg/client-wallet/types.go new file mode 100644 index 000000000..602f51e95 --- /dev/null +++ b/pkg/client-wallet/types.go @@ -0,0 +1,99 @@ +package wallet + +import ( + "context" + "time" + + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsession "github.com/arkade-os/arkd/pkg/client-lib/batch-session" + offchaintx "github.com/arkade-os/arkd/pkg/client-lib/offchain-tx" + unroll "github.com/arkade-os/arkd/pkg/client-lib/unroll" + "github.com/arkade-os/arkd/pkg/client-wallet/types" +) + +var Version string + +type Wallet interface { + Identity() clientlib.Identity + Client() clientlib.Client + Indexer() clientlib.Indexer + Explorer() clientlib.Explorer + + GetVersion() string + GetConfigData(ctx context.Context) (*types.Config, error) + Init(ctx context.Context, args InitArgs) error + IsLocked(ctx context.Context) bool + Unlock(ctx context.Context, password string) error + Lock(ctx context.Context) error + Dump(ctx context.Context) (seed string, err error) + SignTransaction(ctx context.Context, tx string) (string, error) + Reset(ctx context.Context) + Stop() + // ** Funding ** + Receive( + ctx context.Context, + ) (onchainAddr string, offchainAddr, boardingAddr *clientlib.Address, err error) + GetAddresses(ctx context.Context) ( + onchainAddresses []string, + offchainAddresses, boardingAddresses, redemptionAddresses []clientlib.Address, err error, + ) + Balance(ctx context.Context) (*types.Balance, error) + ListVtxos( + ctx context.Context, opts ...ListVtxosOption, + ) (spendable, spent []clientlib.Vtxo, err error) + GetTransactionHistory(ctx context.Context) ([]clientlib.Transaction, error) + NotifyIncomingFunds(ctx context.Context, address string) ([]clientlib.Vtxo, error) + // ** Assets ** + IssueAsset( + ctx context.Context, amount uint64, controlAsset clientlib.ControlAsset, + metadata []asset.Metadata, opts ...offchaintx.Option, + ) (*IssueAssetRes, error) + ReissueAsset( + ctx context.Context, assetId string, amount uint64, opts ...offchaintx.Option, + ) (*ReissueAssetRes, error) + BurnAsset( + ctx context.Context, assetId string, amount uint64, opts ...offchaintx.Option, + ) (*BurnAssetRes, error) + // ** Offchain txs ** + SendOffChain( + ctx context.Context, receivers []clientlib.Receiver, opts ...offchaintx.Option, + ) (*SendOffChainRes, error) + FinalizePendingTxs(ctx context.Context, createdAfter *time.Time) ([]string, error) + // ** Batch session ** + Settle(ctx context.Context, opts ...batchsession.Option) (*SettleRes, error) + CollaborativeExit( + ctx context.Context, addr string, amount uint64, opts ...batchsession.Option, + ) (*CollaborativeExitRes, error) + RedeemNotes( + ctx context.Context, notes []string, opts ...batchsession.Option, + ) (*RedeemNotesRes, error) + RegisterIntent( + ctx context.Context, vtxos []clientlib.Vtxo, boardingUtxos []clientlib.Utxo, notes []string, + outputs []clientlib.Receiver, cosignersPublicKeys []string, + ) (intentID string, err error) + DeleteIntent( + ctx context.Context, vtxos []clientlib.Vtxo, boardingUtxos []clientlib.Utxo, notes []string, + ) error + // ** Unroll ** + Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes, error) + CompleteUnroll(ctx context.Context, opts ...UnrollOption) (string, error) + OnboardAgainAllExpiredBoardings(ctx context.Context) (string, error) + WithdrawFromAllExpiredBoardings(ctx context.Context, opts ...UnrollOption) (string, error) +} + +type SendOffChainRes = offchaintx.OffchainTxRes + +type ReissueAssetRes = offchaintx.OffchainTxRes + +type BurnAssetRes = offchaintx.OffchainTxRes + +type IssueAssetRes = offchaintx.IssueAssetRes + +type SettleRes = batchsession.BatchTxRes + +type CollaborativeExitRes = batchsession.BatchTxRes + +type RedeemNotesRes = batchsession.BatchTxRes + +type UnrollRes = unroll.UnrollRes diff --git a/pkg/client-lib/types/interfaces.go b/pkg/client-wallet/types/interfaces.go similarity index 60% rename from pkg/client-lib/types/interfaces.go rename to pkg/client-wallet/types/interfaces.go index 8e8a5b4ba..3cb9b4cf7 100644 --- a/pkg/client-lib/types/interfaces.go +++ b/pkg/client-wallet/types/interfaces.go @@ -5,16 +5,10 @@ import ( ) type Store interface { - ConfigStore() ConfigStore - Clean(ctx context.Context) - Close() -} - -type ConfigStore interface { GetType() string GetDatadir() string AddData(ctx context.Context, data Config) error GetData(ctx context.Context) (*Config, error) - CleanData(ctx context.Context) error + Clean(ctx context.Context) error Close() } diff --git a/pkg/client-wallet/types/types.go b/pkg/client-wallet/types/types.go new file mode 100644 index 000000000..dec44bf7c --- /dev/null +++ b/pkg/client-wallet/types/types.go @@ -0,0 +1,86 @@ +package types + +import ( + "encoding/hex" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/btcsuite/btcd/btcec/v2" +) + +const ( + InMemoryStore = "inmemory" + FileStore = "file" +) + +type FeeInfo clientlib.FeeInfo + +type Config struct { + ServerUrl string + SignerPubKey *btcec.PublicKey + ForfeitPubKey *btcec.PublicKey + Network arklib.Network + SessionDuration int64 + UnilateralExitDelay arklib.RelativeLocktime + Dust uint64 + BoardingExitDelay arklib.RelativeLocktime + ExplorerURL string + ForfeitAddress string + UtxoMinAmount int64 + UtxoMaxAmount int64 + VtxoMinAmount int64 + VtxoMaxAmount int64 + CheckpointTapscript string + Fees FeeInfo +} + +func (c Config) CheckpointExitPath() []byte { + // nolint + buf, _ := hex.DecodeString(c.CheckpointTapscript) + return buf +} + +func (c Config) ClientInfo() clientlib.Info { + return clientlib.Info{ + SignerPubKey: hex.EncodeToString(c.SignerPubKey.SerializeCompressed()), + ForfeitPubKey: hex.EncodeToString(c.ForfeitPubKey.SerializeCompressed()), + UnilateralExitDelay: int64(c.UnilateralExitDelay.Seconds()), + BoardingExitDelay: int64(c.BoardingExitDelay.Seconds()), + SessionDuration: c.SessionDuration, + Network: c.Network.Name, + Dust: c.Dust, + ForfeitAddress: c.ForfeitAddress, + UtxoMinAmount: c.UtxoMinAmount, + UtxoMaxAmount: c.UtxoMaxAmount, + VtxoMinAmount: c.VtxoMinAmount, + VtxoMaxAmount: c.VtxoMaxAmount, + CheckpointTapscript: c.CheckpointTapscript, + } +} + +type Balance struct { + OnchainBalance OnchainBalance `json:"onchain_balance"` + OffchainBalance OffchainBalance `json:"offchain_balance"` + AssetBalances map[string]uint64 `json:"asset_balances,omitempty"` +} + +type OnchainBalance struct { + SpendableAmount uint64 `json:"spendable_amount"` + LockedAmount []LockedOnchainBalance `json:"locked_amount,omitempty"` +} + +type LockedOnchainBalance struct { + SpendableAt string `json:"spendable_at"` + Amount uint64 `json:"amount"` +} + +type OffchainBalance struct { + Total uint64 `json:"total"` + NextExpiration string `json:"next_expiration,omitempty"` + Details []VtxoDetails `json:"details"` +} + +type VtxoDetails struct { + ExpiryTime string `json:"expiry_time"` + Amount uint64 `json:"amount"` +} diff --git a/pkg/client-wallet/unroll.go b/pkg/client-wallet/unroll.go new file mode 100644 index 000000000..e666ed6f3 --- /dev/null +++ b/pkg/client-wallet/unroll.go @@ -0,0 +1,395 @@ +package wallet + +import ( + "context" + "encoding/hex" + "fmt" + "math" + "strings" + "time" + + "github.com/arkade-os/arkd/pkg/ark-lib/script" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-lib/unroll" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +func (w *wallet) Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes, error) { + if err := w.safeCheck(); err != nil { + return nil, err + } + + o := newDefaultUnrollOptions() + for _, opt := range opts { + if err := opt.applyUnroll(o); err != nil { + return nil, err + } + } + + w.txLock.Lock() + defer w.txLock.Unlock() + + vtxos := o.vtxos + if len(vtxos) <= 0 { + var err error + vtxos, err = w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) + if err != nil { + return nil, err + } + } + + if len(vtxos) == 0 { + return nil, fmt.Errorf("no vtxos to unroll") + } + + onchainAddr, _, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + keyRef, err := w.identity.GetKey(ctx, "") + if err != nil { + return nil, err + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + return unroll.Unroll(ctx, unroll.UnrollArgs{ + Explorer: w.explorer, + Indexer: w.indexer, + SignTx: signTx, + ServerInfo: w.Config.ClientInfo(), + Vtxos: vtxos, + BumpAddr: onchainAddr.Address, + BumpPubKey: keyRef.PubKey, + }) +} + +func (w *wallet) CompleteUnroll(ctx context.Context, opts ...UnrollOption) (string, error) { + if err := w.safeCheck(); err != nil { + return "", err + } + + options := newDefaultUnrollOptions() + for _, opt := range opts { + if err := opt.applyUnroll(options); err != nil { + return "", err + } + } + + onchainAddr, _, _, arkAddr, err := w.getAddresses(ctx) + if err != nil { + return "", err + } + + to := options.receiver + if len(to) <= 0 { + to = onchainAddr.Address + } + + signTx := func(ctx context.Context, tx string) (string, error) { + return w.identity.SignTransaction(ctx, tx, nil) + } + + return unroll.CompleteUnroll(ctx, unroll.CompleteUnrollArgs{ + Explorer: w.explorer, + SignTx: signTx, + ServerInfo: w.Config.ClientInfo(), + ArkAddr: *arkAddr, + Receiver: to, + }) +} + +func (w *wallet) WithdrawFromAllExpiredBoardings( + ctx context.Context, opts ...UnrollOption, +) (string, error) { + if err := w.safeCheck(); err != nil { + return "", err + } + + o := newDefaultUnrollOptions() + for _, opt := range opts { + if err := opt.applyUnroll(o); err != nil { + return "", err + } + } + + to := o.receiver + if len(to) <= 0 { + onchainAddr, _, _, _, err := w.getAddresses(ctx) + if err != nil { + return "", err + } + + to = onchainAddr.Address + } + if _, err := btcutil.DecodeAddress(to, nil); err != nil { + return "", fmt.Errorf("invalid receiver address '%s': must be onchain", to) + } + + return w.sendExpiredBoardingUtxos(ctx, to) +} + +func (w *wallet) OnboardAgainAllExpiredBoardings(ctx context.Context) (string, error) { + if err := w.safeCheck(); err != nil { + return "", err + } + + if w.UtxoMaxAmount == 0 { + return "", fmt.Errorf("operation not allowed by the server") + } + + _, _, boardingAddr, _, err := w.getAddresses(ctx) + if err != nil { + return "", err + } + + return w.sendExpiredBoardingUtxos(ctx, boardingAddr.Address) +} + +func (w *wallet) sendExpiredBoardingUtxos(ctx context.Context, to string) (string, error) { + pkscript, err := toOutputScript(to, w.Network) + if err != nil { + return "", err + } + + w.txLock.Lock() + defer w.txLock.Unlock() + + utxos, err := w.getExpiredBoardingUtxos(ctx) + if err != nil { + return "", err + } + + targetAmount := uint64(0) + for _, u := range utxos { + targetAmount += u.Amount + } + + if targetAmount == 0 { + return "", fmt.Errorf("no expired boarding funds available") + } + + ptx, err := psbt.New(nil, nil, 2, 0, nil) + if err != nil { + return "", err + } + + updater, err := psbt.NewUpdater(ptx) + if err != nil { + return "", err + } + + updater.Upsbt.UnsignedTx.AddTxOut(&wire.TxOut{ + Value: int64(targetAmount), + PkScript: pkscript, + }) + updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{}) + + if err := w.addInputs(ctx, updater, utxos); err != nil { + return "", err + } + + vbytes := computeVSize(updater.Upsbt.UnsignedTx) + feeRate, err := w.explorer.GetFeeRate() + if err != nil { + return "", err + } + feeAmount := uint64(math.Ceil(float64(vbytes)*feeRate) + 50) + + if targetAmount-feeAmount <= w.Dust { + return "", fmt.Errorf("not enough funds to cover network fees") + } + + updater.Upsbt.UnsignedTx.TxOut[0].Value -= int64(feeAmount) + + unsignedTx, err := ptx.B64Encode() + if err != nil { + return "", err + } + + signedTx, err := w.identity.SignTransaction(ctx, unsignedTx, nil) + if err != nil { + return "", err + } + + ptx, err = psbt.NewFromRawBytes(strings.NewReader(signedTx), true) + if err != nil { + return "", err + } + + for i := range ptx.Inputs { + if err := psbt.Finalize(ptx, i); err != nil { + return "", err + } + } + + return ptx.B64Encode() +} + +func (w *wallet) getExpiredBoardingUtxos(ctx context.Context) ([]clientlib.Utxo, error) { + _, _, boardingAddr, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + utxos, err := w.getUtxos(ctx, *boardingAddr, getUtxosFilter{expired: true}) + if err != nil { + return nil, err + } + + now := time.Now() + expiredUtxos := make([]clientlib.Utxo, 0, len(utxos)) + for _, u := range utxos { + if u.RedeemableAt.Before(now) || u.RedeemableAt.Equal(now) { + expiredUtxos = append(expiredUtxos, u) + } + } + + return expiredUtxos, nil +} + +func (w *wallet) addInputs( + ctx context.Context, updater *psbt.Updater, utxos []clientlib.Utxo, +) error { + for _, utxo := range utxos { + vtxoScript, err := script.ParseVtxoScript(utxo.Tapscripts) + if err != nil { + return err + } + + previousHash, err := chainhash.NewHashFromStr(utxo.Txid) + if err != nil { + return err + } + + sequence, err := utxo.Sequence() + if err != nil { + return err + } + + pkScript, err := hex.DecodeString(utxo.Script) + if err != nil { + return err + } + + updater.Upsbt.UnsignedTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *previousHash, + Index: utxo.VOut, + }, + Sequence: sequence, + }) + + exitClosures := vtxoScript.ExitClosures() + if len(exitClosures) <= 0 { + return fmt.Errorf("no exit closures found") + } + + exitClosure := exitClosures[0] + + exitScript, err := exitClosure.Script() + if err != nil { + return err + } + + _, taprootTree, err := vtxoScript.TapTree() + if err != nil { + return err + } + + exitLeaf := txscript.NewBaseTapLeaf(exitScript) + leafProof, err := taprootTree.GetTaprootMerkleProof(exitLeaf.TapHash()) + if err != nil { + return fmt.Errorf("failed to get taproot merkle proof: %s", err) + } + + updater.Upsbt.Inputs = append(updater.Upsbt.Inputs, psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: int64(utxo.Amount), + PkScript: pkScript, + }, + TaprootLeafScript: []*psbt.TaprootTapLeafScript{ + { + ControlBlock: leafProof.ControlBlock, + Script: leafProof.Script, + LeafVersion: txscript.BaseLeafVersion, + }, + }, + }) + } + + return nil +} + +type getUtxosFilter struct { + expired bool + claimable bool +} + +func (w *wallet) getUtxos( + _ context.Context, addr clientlib.Address, opts getUtxosFilter, +) ([]clientlib.Utxo, error) { + rawScript, err := addr.RawScript() + if err != nil { + return nil, err + } + + var signingClosure script.Closure + if opts.expired { + signingClosure, err = addr.ExitClosure() + if err != nil { + return nil, err + } + } + if opts.claimable { + signingClosure, err = addr.CollaborativeClosure() + if err != nil { + return nil, err + } + } + + exitDelay, err := rawScript.SmallestExitDelay() + if err != nil { + return nil, err + } + + fetchedUtxos, err := w.explorer.GetUtxos([]string{addr.Address}) + if err != nil { + return nil, err + } + + utxos := make([]clientlib.Utxo, 0, len(fetchedUtxos)) + for _, u := range fetchedUtxos { + utxos = append(utxos, u.ToUtxo(*exitDelay, addr.Tapscripts, signingClosure)) + } + + now := time.Now() + if opts.expired { + filtered := make([]clientlib.Utxo, 0) + for _, u := range utxos { + if !u.RedeemableAt.After(now) { + filtered = append(filtered, u) + } + } + utxos = filtered + } + if opts.claimable { + filtered := make([]clientlib.Utxo, 0) + for _, u := range utxos { + if u.RedeemableAt.After(now) { + filtered = append(filtered, u) + } + } + utxos = filtered + } + + return utxos, nil +} + diff --git a/pkg/client-wallet/unroll_ops.go b/pkg/client-wallet/unroll_ops.go new file mode 100644 index 000000000..4e9859923 --- /dev/null +++ b/pkg/client-wallet/unroll_ops.go @@ -0,0 +1,51 @@ +package wallet + +import ( + "fmt" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" +) + +type UnrollOption interface { + applyUnroll(*unrollOptions) error +} + +func WithVtxos(vtxos []clientlib.Vtxo) UnrollOption { + return unrollOptFn(func(o *unrollOptions) error { + if len(vtxos) <= 0 { + return fmt.Errorf("missing vtxos") + } + if len(o.vtxos) > 0 { + return fmt.Errorf("vtxos already set") + } + o.vtxos = make([]clientlib.Vtxo, len(vtxos)) + copy(o.vtxos, vtxos) + return nil + }) +} + +func WithReceiver(receiver string) UnrollOption { + return unrollOptFn(func(o *unrollOptions) error { + if len(receiver) <= 0 { + return fmt.Errorf("missing receiver") + } + if len(o.receiver) > 0 { + return fmt.Errorf("receiver already set") + } + o.receiver = receiver + return nil + }) +} + +type unrollOptFn func(*unrollOptions) error + +func (f unrollOptFn) applyUnroll(o *unrollOptions) error { return f(o) } + +type unrollOptions struct { + vtxos []clientlib.Vtxo + receiver string +} + +func newDefaultUnrollOptions() *unrollOptions { + return &unrollOptions{} +} diff --git a/pkg/client-wallet/utils.go b/pkg/client-wallet/utils.go new file mode 100644 index 000000000..2b24f1d88 --- /dev/null +++ b/pkg/client-wallet/utils.go @@ -0,0 +1,374 @@ +package wallet + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "math" + "slices" + "strconv" + "strings" + "time" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/script" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-wallet/identity" + identitystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store" + identityfilestore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store/file" + identityinmemorystore "github.com/arkade-os/arkd/pkg/client-wallet/identity/store/inmemory" + "github.com/arkade-os/arkd/pkg/client-wallet/types" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lntypes" +) + +func getClient( + supportedClients supportedType[clientFactory], + clientType, serverUrl string, withMonitorConn bool, +) (clientlib.Client, error) { + factory := supportedClients[clientType] + return factory(serverUrl, withMonitorConn) +} + +func getIndexer( + supportedIndexers supportedType[indexerFactory], + clientType, serverUrl string, withMonitorConn bool, +) (clientlib.Indexer, error) { + factory := supportedIndexers[clientType] + return factory(serverUrl, withMonitorConn) +} + +func getSingleKeyIdentity(datadir, storeType string) (clientlib.Identity, error) { + store, err := getIdentityStore(storeType, datadir) + if err != nil { + return nil, err + } + + return identity.NewIdentity(store) +} + +func getIdentityStore(storeType, datadir string) (identitystore.IdentityStore, error) { + switch storeType { + case InMemoryStore: + return identityinmemorystore.NewStore() + case FileStore: + return identityfilestore.NewStore(datadir) + default: + return nil, fmt.Errorf("unknown identity store type") + } +} + +func filterByOutpoints(vtxos []clientlib.Vtxo, outpoints []clientlib.Outpoint) []clientlib.Vtxo { + filtered := make([]clientlib.Vtxo, 0, len(vtxos)) + for _, vtxo := range vtxos { + for _, outpoint := range outpoints { + if vtxo.Outpoint == outpoint { + filtered = append(filtered, vtxo) + } + } + } + return filtered +} + +func inputsToDerivationPath(inputs []clientlib.Outpoint, notesInputs []string) string { + // sort arknotes + slices.SortStableFunc(notesInputs, func(i, j string) int { + return strings.Compare(i, j) + }) + + // sort outpoints + slices.SortStableFunc(inputs, func(i, j clientlib.Outpoint) int { + txidCmp := strings.Compare(i.Txid, j.Txid) + if txidCmp != 0 { + return txidCmp + } + return int(i.VOut - j.VOut) + }) + + // serialize outpoints and arknotes + + var buf bytes.Buffer + + for _, input := range inputs { + buf.WriteString(input.Txid) + buf.WriteString(strconv.Itoa(int(input.VOut))) + } + + for _, note := range notesInputs { + buf.WriteString(note) + } + + // hash the serialized data + hash := sha256.Sum256(buf.Bytes()) + + // convert hash to bip32 derivation path + // split the 32-byte hash into 8 uint32 values (4 bytes each) + path := "m" + for i := 0; i < 8; i++ { + // Convert 4 bytes to uint32 using big-endian encoding + segment := binary.BigEndian.Uint32(hash[i*4 : (i+1)*4]) + path += fmt.Sprintf("/%d'", segment) + } + + return path +} + +func extractCollaborativePath(tapscripts []string) ([]byte, *arklib.TaprootMerkleProof, error) { + vtxoScript, err := script.ParseVtxoScript(tapscripts) + if err != nil { + return nil, nil, err + } + + forfeitClosures := vtxoScript.ForfeitClosures() + if len(forfeitClosures) <= 0 { + return nil, nil, fmt.Errorf("no exit closures found") + } + + forfeitClosure := forfeitClosures[0] + forfeitScript, err := forfeitClosure.Script() + if err != nil { + return nil, nil, err + } + + taprootKey, taprootTree, err := vtxoScript.TapTree() + if err != nil { + return nil, nil, err + } + + forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) + leafProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) + if err != nil { + return nil, nil, fmt.Errorf("failed to get taproot merkle proof: %s", err) + } + pkScript, err := script.P2TRScript(taprootKey) + if err != nil { + return nil, nil, err + } + + return pkScript, leafProof, nil +} + +func getOffchainBalanceDetails( + amountByExpiration map[int64]uint64, +) (int64, []types.VtxoDetails) { + nextExpiration := int64(0) + details := make([]types.VtxoDetails, 0) + for timestamp, amount := range amountByExpiration { + if nextExpiration == 0 || timestamp < nextExpiration { + nextExpiration = timestamp + } + + fancyTime := time.Unix(timestamp, 0).Format(time.RFC3339) + details = append( + details, types.VtxoDetails{ + ExpiryTime: fancyTime, + Amount: amount, + }, + ) + } + return nextExpiration, details +} + +func getFancyTimeExpiration(nextExpiration int64) string { + if nextExpiration == 0 { + return "" + } + + fancyTimeExpiration := "" + t := time.Unix(nextExpiration, 0) + if t.Before(time.Now().Add(48 * time.Hour)) { + // print the duration instead of the absolute time + until := time.Until(t) + seconds := math.Abs(until.Seconds()) + minutes := math.Abs(until.Minutes()) + hours := math.Abs(until.Hours()) + + if hours < 1 { + if minutes < 1 { + fancyTimeExpiration = fmt.Sprintf("%d seconds", int(seconds)) + } else { + fancyTimeExpiration = fmt.Sprintf("%d minutes", int(minutes)) + } + } else { + fancyTimeExpiration = fmt.Sprintf("%d hours", int(hours)) + } + } else { + fancyTimeExpiration = t.Format(time.RFC3339) + } + return fancyTimeExpiration +} + +func computeVSize(tx *wire.MsgTx) lntypes.VByte { + baseSize := tx.SerializeSizeStripped() + totalSize := tx.SerializeSize() // including witness + weight := totalSize + baseSize*3 + return lntypes.WeightUnit(uint64(weight)).ToVB() +} + +func findVtxosSpentInSettlement(vtxos []clientlib.Vtxo, vtxo clientlib.Vtxo) []clientlib.Vtxo { + if vtxo.Preconfirmed { + return nil + } + return findVtxosSettled(vtxos, vtxo.CommitmentTxids[0]) +} + +func findVtxosSettled(vtxos []clientlib.Vtxo, id string) []clientlib.Vtxo { + var result []clientlib.Vtxo + leftVtxos := make([]clientlib.Vtxo, 0) + for _, v := range vtxos { + if v.SettledBy == id { + result = append(result, v) + } else { + leftVtxos = append(leftVtxos, v) + } + } + // Update the given list with only the left vtxos. + copy(vtxos, leftVtxos) + return result +} + +func findVtxosResultedFromSettledBy(vtxos []clientlib.Vtxo, commitmentTxid string) []clientlib.Vtxo { + var result []clientlib.Vtxo + for _, v := range vtxos { + if v.Preconfirmed || len(v.CommitmentTxids) != 1 { + continue + } + if v.CommitmentTxids[0] == commitmentTxid { + result = append(result, v) + } + } + return result +} + +func findVtxosSpent(vtxos []clientlib.Vtxo, id string) []clientlib.Vtxo { + var result []clientlib.Vtxo + leftVtxos := make([]clientlib.Vtxo, 0) + for _, v := range vtxos { + if v.ArkTxid == id { + result = append(result, v) + } else { + leftVtxos = append(leftVtxos, v) + } + } + // Update the given list with only the left vtxos. + copy(vtxos, leftVtxos) + return result +} + +func reduceVtxosAmount(vtxos []clientlib.Vtxo) uint64 { + var total uint64 + for _, v := range vtxos { + total += v.Amount + } + return total +} + +func findVtxosSpentInPayment(vtxos []clientlib.Vtxo, vtxo clientlib.Vtxo) []clientlib.Vtxo { + return findVtxosSpent(vtxos, vtxo.Txid) +} + +func findVtxosResultedFromSpentBy(vtxos []clientlib.Vtxo, spentByTxid string) []clientlib.Vtxo { + var result []clientlib.Vtxo + for _, v := range vtxos { + if v.Txid == spentByTxid { + result = append(result, v) + } + } + return result +} + +func getVtxo(usedVtxos []clientlib.Vtxo, spentByVtxos []clientlib.Vtxo) clientlib.Vtxo { + if len(usedVtxos) > 0 { + return usedVtxos[0] + } else if len(spentByVtxos) > 0 { + return spentByVtxos[0] + } + return clientlib.Vtxo{} +} + +func ecPubkeyFromHex(pubkey string) (*btcec.PublicKey, error) { + buf, err := hex.DecodeString(pubkey) + if err != nil { + return nil, err + } + return btcec.ParsePubKey(buf) +} + +func toOutputScript(onchainAddress string, network arklib.Network) ([]byte, error) { + netParams := clientlib.ToBitcoinNetwork(network) + rcvAddr, err := btcutil.DecodeAddress(onchainAddress, &netParams) + if err != nil { + return nil, err + } + + return txscript.PayToAddrScript(rcvAddr) +} + +// validateOffchainAddress rejects everything that is not a valid offchain ark +// address. Used by methods whose receiver MUST be a vtxo destination +// (SendOffChain change, asset ops, RedeemNotes). +func validateOffchainAddress(addr string) error { + if addr == "" { + return fmt.Errorf("missing receiver address") + } + if _, err := arklib.DecodeAddressV0(addr); err != nil { + return fmt.Errorf("invalid offchain receiver address: %w", err) + } + return nil +} + +// validateOnchainAddress rejects everything that is not a valid onchain +// bitcoin address on the given network. Used by OnboardAgainAllExpiredBoardings. +func validateOnchainAddress(addr string, network arklib.Network) error { + if addr == "" { + return fmt.Errorf("missing receiver address") + } + netParams := clientlib.ToBitcoinNetwork(network) + if _, err := btcutil.DecodeAddress(addr, &netParams); err != nil { + return fmt.Errorf("invalid onchain receiver address: %w", err) + } + return nil +} + +// validateOffchainOrOnchainAddress accepts either an ark offchain address or +// a bitcoin onchain address on the given network. Used by Settle / +// CollaborativeExit, where batch-session outputs may legally be either. +func validateOffchainOrOnchainAddress(addr string, network arklib.Network) error { + if addr == "" { + return fmt.Errorf("missing receiver address") + } + if _, offErr := arklib.DecodeAddressV0(addr); offErr == nil { + return nil + } + netParams := clientlib.ToBitcoinNetwork(network) + if _, onErr := btcutil.DecodeAddress(addr, &netParams); onErr == nil { + return nil + } + return fmt.Errorf( + "invalid receiver address: not a valid offchain or onchain bitcoin address", + ) +} + +type supportedType[V any] map[string]V + +func (t supportedType[V]) String() string { + types := make([]string, 0, len(t)) + for tt := range t { + types = append(types, tt) + } + return strings.Join(types, " | ") +} + +func (t supportedType[V]) supports(typeStr string) bool { + _, ok := t[typeStr] + return ok +} + +type clientFactory func(string, bool) (clientlib.Client, error) + +type indexerFactory func(string, bool) (clientlib.Indexer, error) diff --git a/pkg/client-wallet/wallet.go b/pkg/client-wallet/wallet.go new file mode 100644 index 000000000..d466f0b3c --- /dev/null +++ b/pkg/client-wallet/wallet.go @@ -0,0 +1,419 @@ +package wallet + +import ( + "context" + "fmt" + "sync" + "time" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + "github.com/arkade-os/arkd/pkg/client-lib/client" + "github.com/arkade-os/arkd/pkg/client-lib/explorer" + "github.com/arkade-os/arkd/pkg/client-lib/indexer" + storetypes "github.com/arkade-os/arkd/pkg/client-wallet/types" + types "github.com/arkade-os/arkd/pkg/client-wallet/types" + log "github.com/sirupsen/logrus" +) + +const ( + // identity + SingleKeyIdentity = clientlib.SingleKeyIdentity + // store + InMemoryStore = types.InMemoryStore + FileStore = types.FileStore +) + +var ( + ErrAlreadyInitialized = fmt.Errorf("wallet already initialized") + ErrNotInitialized = fmt.Errorf("wallet not initialized") + ErrIsLocked = fmt.Errorf("wallet is locked") + + supportedIdentities = supportedType[struct{}]{ + SingleKeyIdentity: struct{}{}, + } +) + +type wallet struct { + *storetypes.Config + identity clientlib.Identity + store types.Store + explorer clientlib.Explorer + client clientlib.Client + indexer clientlib.Indexer + + txLock *sync.RWMutex + verbose bool + withFinalizePendingTxs bool +} + +func NewWallet(storeSvc types.Store, opts ...WalletOption) (Wallet, error) { + if storeSvc == nil { + return nil, fmt.Errorf("missing store") + } + + cfgData, err := storeSvc.GetData(context.Background()) + if err != nil { + return nil, err + } + + if cfgData != nil { + return nil, ErrAlreadyInitialized + } + + wallet := &wallet{ + store: storeSvc, + txLock: &sync.RWMutex{}, + withFinalizePendingTxs: true, + } + for _, opt := range opts { + opt(wallet) + } + + if wallet.identity == nil { + storeType := storeSvc.GetType() + datadir := storeSvc.GetDatadir() + identitySvc, err := getSingleKeyIdentity(datadir, storeType) + if err != nil { + return nil, fmt.Errorf("failed to setup identity: %s", err) + } + wallet.identity = identitySvc + } + + return wallet, nil +} + +func LoadWallet(storeSvc types.Store, opts ...WalletOption) (Wallet, error) { + if storeSvc == nil { + return nil, fmt.Errorf("missing sdk repository") + } + + cfgData, err := storeSvc.GetData(context.Background()) + if err != nil { + return nil, err + } + if cfgData == nil { + return nil, ErrNotInitialized + } + + wallet := &wallet{ + Config: cfgData, + store: storeSvc, + txLock: &sync.RWMutex{}, + withFinalizePendingTxs: true, + } + for _, opt := range opts { + opt(wallet) + } + + if wallet.identity == nil { + storeType := storeSvc.GetType() + datadir := storeSvc.GetDatadir() + identitySvc, err := getSingleKeyIdentity(datadir, storeType) + if err != nil { + return nil, fmt.Errorf("failed to setup identity: %s", err) + } + wallet.identity = identitySvc + } + + if wallet.explorer == nil { + explorerOpts := []explorer.Option{explorer.WithTracker(false)} + explorerSvc, err := explorer.NewExplorer( + cfgData.ExplorerURL, cfgData.Network, explorerOpts..., + ) + if err != nil { + return nil, fmt.Errorf("failed to setup explorer: %s", err) + } + wallet.explorer = explorerSvc + } + + clientSvc, err := client.NewClient(cfgData.ServerUrl) + if err != nil { + return nil, fmt.Errorf("failed to setup transport client: %s", err) + } + indexerSvc, err := indexer.NewClient(cfgData.ServerUrl) + if err != nil { + return nil, fmt.Errorf("failed to setup indexer: %s", err) + } + + wallet.client = clientSvc + wallet.indexer = indexerSvc + + return wallet, nil +} + +func (w *wallet) Identity() clientlib.Identity { + return w.identity +} + +func (w *wallet) Client() clientlib.Client { + return w.client +} + +func (w *wallet) Indexer() clientlib.Indexer { + return w.indexer +} + +func (w *wallet) Explorer() clientlib.Explorer { + return w.explorer +} + +func (w *wallet) GetVersion() string { + return Version +} + +func (w *wallet) GetConfigData(_ context.Context) (*storetypes.Config, error) { + if w.Config == nil { + return nil, fmt.Errorf("client sdk not initialized") + } + return w.Config, nil +} + +func (w *wallet) Unlock(ctx context.Context, password string) error { + if _, err := w.identity.Unlock(ctx, password); err != nil { + return err + } + + log.SetLevel(log.DebugLevel) + if !w.verbose { + log.SetLevel(log.WarnLevel) + } + + if w.withFinalizePendingTxs { + txids, err := w.FinalizePendingTxs(ctx, nil) + if err != nil { + return err + } + switch len(txids) { + case 0: + log.Debug("no pending txs to finalize") + case 1: + log.Debug("finalized 1 pending tx") + default: + log.Debugf("finalized %d pending txs", len(txids)) + } + } + + return nil +} + +func (w *wallet) Lock(ctx context.Context) error { + if w.identity == nil { + return ErrNotInitialized + } + return w.identity.Lock(ctx) +} + +func (w *wallet) IsLocked(ctx context.Context) bool { + if w.identity == nil { + return true + } + return w.identity.IsLocked() +} + +func (w *wallet) Dump(ctx context.Context) (string, error) { + if err := w.safeCheck(); err != nil { + return "", err + } + return w.identity.Dump(ctx) +} + +func (w *wallet) Reset(ctx context.Context) { + if w.client != nil { + w.client.Close() + } + if w.indexer != nil { + w.indexer.Close() + } + + if w.store != nil { + w.store.Clean(ctx) + } +} + +func (w *wallet) Stop() { + if w.client != nil { + w.client.Close() + } + if w.indexer != nil { + w.indexer.Close() + } + + if w.store != nil { + w.store.Close() + } +} + +func (w *wallet) SignTransaction(ctx context.Context, tx string) (string, error) { + if err := w.safeCheck(); err != nil { + return "", err + } + + return w.identity.SignTransaction(ctx, tx, nil) +} + +func (w *wallet) safeCheck() error { + if w.identity == nil { + return ErrNotInitialized + } + if w.identity.IsLocked() { + return ErrIsLocked + } + return nil +} + +type getVtxosFilter struct { + // If specified, returns only vtxos matching given outpoints + outpoints []clientlib.Outpoint + // If true, excludes recoverable vtxos from the list + excludeRecoverableVtxos bool + // If true, excludes vtxos holding assets from the list + excludeAssetVtxos bool +} + +func (w *wallet) getSpendableVtxos( + ctx context.Context, opts *getVtxosFilter, +) ([]clientlib.Vtxo, error) { + vtxos, _, err := w.getVtxos(ctx) + if err != nil { + return nil, err + } + + if opts != nil && len(opts.outpoints) > 0 { + vtxos = filterByOutpoints(vtxos, opts.outpoints) + } + + if opts != nil && opts.excludeRecoverableVtxos { + filteredVtxos := make([]clientlib.Vtxo, 0, len(vtxos)) + for _, vtxo := range vtxos { + if vtxo.IsRecoverable() { + continue + } + filteredVtxos = append(filteredVtxos, vtxo) + } + vtxos = filteredVtxos + } + + if opts != nil && opts.excludeAssetVtxos { + filteredVtxos := make([]clientlib.Vtxo, 0, len(vtxos)) + for _, vtxo := range vtxos { + if len(vtxo.Assets) > 0 { + continue + } + filteredVtxos = append(filteredVtxos, vtxo) + } + vtxos = filteredVtxos + } + + return vtxos, nil +} + +func (w *wallet) getPendingVtxos( + ctx context.Context, createdAfter *time.Time, +) ([]clientlib.Vtxo, error) { + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + _, vtxos, err := w.getVtxos(ctx, clientlib.WithPendingOnly()) + if err != nil { + return nil, err + } + + if createdAfter != nil { + filtered := make([]clientlib.Vtxo, 0, len(vtxos)) + for _, vtxo := range vtxos { + if !createdAfter.IsZero() { + if !vtxo.CreatedAt.After(*createdAfter) { + continue + } + } + filtered = append(filtered, vtxo) + } + vtxos = filtered + } + + vtxos, _, err = w.populateVtxosWithTapscripts(ctx, vtxos, nil, offchainAddr, nil) + return vtxos, err +} + +func (w *wallet) getVtxos( + ctx context.Context, opts ...clientlib.GetVtxosOption, +) ([]clientlib.Vtxo, []clientlib.Vtxo, error) { + _, offchainAddr, _, _, err := w.getAddresses(ctx) + if err != nil { + return nil, nil, err + } + + script, err := offchainAddr.Script() + if err != nil { + return nil, nil, err + } + + scripts := []string{script} + opts = append(opts, clientlib.WithScripts(scripts)) + resp, err := w.indexer.GetVtxos(ctx, opts...) + if err != nil { + return nil, nil, err + } + + spendableVtxos := make([]clientlib.Vtxo, 0, len(resp.Vtxos)) + spentVtxos := make([]clientlib.Vtxo, 0, len(resp.Vtxos)) + for _, vtxo := range resp.Vtxos { + if vtxo.Spent || vtxo.Unrolled { + spentVtxos = append(spentVtxos, vtxo) + continue + } + + if vtxo.IsRecoverable() { + spendableVtxos = append(spendableVtxos, vtxo) + continue + } + + spendableVtxos = append(spendableVtxos, vtxo) + } + + spendableVtxos, _, err = w.populateVtxosWithTapscripts( + ctx, spendableVtxos, nil, offchainAddr, nil, + ) + if err != nil { + return nil, nil, err + } + return spendableVtxos, spentVtxos, nil +} + +func (w *wallet) populateVtxosWithTapscripts( + ctx context.Context, vtxos []clientlib.Vtxo, boardingUtxos []clientlib.Utxo, + offchainAddr, boardingAddr *clientlib.Address, +) ([]clientlib.Vtxo, []clientlib.Utxo, error) { + var vtxosWithSignInfo []clientlib.Vtxo + if len(vtxos) > 0 { + vtxosWithSignInfo = make([]clientlib.Vtxo, len(vtxos)) + copy(vtxosWithSignInfo, vtxos) + vtxoSigningClosure, err := offchainAddr.CollaborativeClosure() + if err != nil { + return nil, nil, err + } + for i := range vtxosWithSignInfo { + vtxosWithSignInfo[i].Tapscripts = offchainAddr.Tapscripts + vtxosWithSignInfo[i].SigningClosure = vtxoSigningClosure + } + } + + var utxosWithSignInfo []clientlib.Utxo + if len(boardingUtxos) > 0 { + utxosWithSignInfo = make([]clientlib.Utxo, len(boardingUtxos)) + copy(utxosWithSignInfo, boardingUtxos) + utxoSigningClosure, err := boardingAddr.CollaborativeClosure() + if err != nil { + return nil, nil, err + } + + for i := range utxosWithSignInfo { + utxosWithSignInfo[i].Tapscripts = boardingAddr.Tapscripts + utxosWithSignInfo[i].SigningClosure = utxoSigningClosure + } + } + + return vtxosWithSignInfo, utxosWithSignInfo, nil +} diff --git a/pkg/client-wallet/wallet_opts.go b/pkg/client-wallet/wallet_opts.go new file mode 100644 index 000000000..a3b505a58 --- /dev/null +++ b/pkg/client-wallet/wallet_opts.go @@ -0,0 +1,31 @@ +package wallet + +import ( + clientlib "github.com/arkade-os/arkd/pkg/client-lib" +) + +type WalletOption func(*wallet) + +func WithVerbose() WalletOption { + return func(c *wallet) { + c.verbose = true + } +} + +func WithExplorer(explorer clientlib.Explorer) WalletOption { + return func(c *wallet) { + c.explorer = explorer + } +} + +func WithIdentity(identitySvc clientlib.Identity) WalletOption { + return func(c *wallet) { + c.identity = identitySvc + } +} + +func WithoutFinalizePendingTxs() WalletOption { + return func(c *wallet) { + c.withFinalizePendingTxs = false + } +}