From f3b03ace709a56a5a0a0bd6536ffeb8f78fef191 Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Thu, 21 May 2026 12:53:27 +0200 Subject: [PATCH 1/9] Refactor: Move signer session from identity to ark-lib, extract client-wallet from client-lib and leave there only types, interface and functions --- go.mod | 4 + pkg/ark-lib/tree/signer.go | 11 + pkg/client-lib/asset.go | 543 --------- pkg/client-lib/batch-session/batch_session.go | 201 +++ .../batch-session/batch_session_opts.go | 157 +++ .../batch-session/batch_session_opts_test.go | 60 + .../batch-session/batch_session_test.go | 85 ++ .../batch-session/batch_session_types.go | 131 ++ .../batch-session/collaborative_exit.go | 94 ++ .../batch-session/collaborative_exit_test.go | 88 ++ .../batch-session/handler/default_handler.go | 631 ++++++++++ .../batch-session/handler/handler.go | 230 ++++ .../batch-session/handler/handler_opts.go | 32 + pkg/client-lib/batch-session/handler/types.go | 36 + pkg/client-lib/batch-session/handler/utils.go | 204 ++++ pkg/client-lib/batch-session/intent.go | 96 ++ pkg/client-lib/batch-session/intent_test.go | 128 ++ pkg/client-lib/batch-session/redeem_notes.go | 74 ++ .../batch-session/redeem_notes_test.go | 65 + pkg/client-lib/batch-session/settle.go | 171 +++ pkg/client-lib/batch-session/settle_test.go | 92 ++ pkg/client-lib/batch-session/utils.go | 426 +++++++ pkg/client-lib/batch-session/utils_test.go | 64 + pkg/client-lib/batch_session.go | 774 ------------ pkg/client-lib/batch_session_handler.go | 922 -------------- pkg/client-lib/batch_session_opts.go | 133 -- pkg/client-lib/client.go | 172 +++ pkg/client-lib/client/client.go | 705 +++++++++-- pkg/client-lib/client/grpc/client.go | 622 ---------- .../client/{grpc => }/reconnect_test.go | 14 +- pkg/client-lib/client/{grpc => }/types.go | 39 +- .../explorer/{mempool => }/connection_pool.go | 2 +- .../explorer/{mempool => }/listeners.go | 18 +- pkg/client-lib/explorer/mempool/explorer.go | 1034 ---------------- pkg/client-lib/explorer/mempool/utils_test.go | 156 --- pkg/client-lib/explorer/{mempool => }/opts.go | 2 +- pkg/client-lib/explorer/service.go | 1082 +++++++++++++++-- pkg/client-lib/explorer/service_test.go | 104 +- .../explorer/{mempool => }/types.go | 58 +- .../explorer/{mempool => }/utils.go | 17 +- pkg/client-lib/explorer/utils_test.go | 231 ++-- pkg/client-lib/go.mod | 70 +- pkg/client-lib/go.sum | 483 -------- pkg/client-lib/{identity => }/identity.go | 4 +- .../{indexer/service.go => indexer.go} | 30 +- pkg/client-lib/indexer/{grpc => }/cache.go | 2 +- .../indexer/{grpc => }/cache_test.go | 2 +- pkg/client-lib/indexer/{grpc => }/client.go | 188 +-- .../{grpc => }/paginated_fetch_test.go | 2 +- .../reconnect_get_subscription_stream_test.go | 15 +- .../{indexer/opts.go => indexer_opts.go} | 8 +- .../opts_test.go => indexer_opts_test.go} | 91 +- .../internal/utils/listener_test.go | 268 ++++ pkg/client-lib/internal/utils/reconnect.go | 73 -- pkg/client-lib/internal/utils/stream_retry.go | 69 +- .../internal/utils/stream_retry_test.go | 109 +- pkg/client-lib/internal/utils/types.go | 55 - pkg/client-lib/internal/utils/utils.go | 272 +---- pkg/client-lib/internal/utils/utils_test.go | 73 -- pkg/client-lib/offchain-tx/args.go | 247 ++++ pkg/client-lib/offchain-tx/asset.go | 192 +++ pkg/client-lib/offchain-tx/asset_test.go | 436 +++++++ pkg/client-lib/offchain-tx/build.go | 426 +++++++ pkg/client-lib/offchain-tx/opts.go | 59 + pkg/client-lib/offchain-tx/pending.go | 47 + pkg/client-lib/offchain-tx/pending_test.go | 55 + pkg/client-lib/offchain-tx/send.go | 47 + pkg/client-lib/offchain-tx/send_test.go | 156 +++ pkg/client-lib/offchain-tx/types.go | 58 + pkg/client-lib/offchain-tx/utils.go | 670 ++++++++++ .../utils_test.go} | 70 +- pkg/client-lib/offchain-tx/verify.go | 59 + pkg/client-lib/offchain-tx/verify_test.go | 147 +++ pkg/client-lib/receiver_opts.go | 115 -- pkg/client-lib/receiver_opts_test.go | 134 -- pkg/client-lib/redemption/redeem.go | 42 +- pkg/client-lib/send.go | 505 -------- pkg/client-lib/send_opts.go | 65 - pkg/client-lib/service.go | 507 ++------ pkg/client-lib/service_opts.go | 32 - pkg/client-lib/sign_opts.go | 56 - pkg/client-lib/store/inmemory/config_store.go | 57 - pkg/client-lib/store/service.go | 55 - pkg/client-lib/store/service_test.go | 96 -- pkg/client-lib/types.go | 504 +++++++- pkg/client-lib/types/types.go | 408 ------- pkg/client-lib/unroll_ops.go | 40 - pkg/client-lib/utils.go | 1082 +---------------- pkg/client-lib/utils_test.go | 53 + pkg/client-lib/vtxos_opts.go | 51 - pkg/client-lib/wallet.go | 136 --- pkg/client-wallet/asset.go | 146 +++ pkg/client-wallet/batch_session.go | 224 ++++ .../example/README.md | 2 +- .../example/alice_to_bob/alice_to_bob.go | 13 +- .../multi_connection_demo.go | 8 +- pkg/{client-lib => client-wallet}/funding.go | 443 +++---- .../funding_opts.go | 0 .../funding_opts_test.go | 2 +- pkg/client-wallet/go.mod | 79 ++ pkg/client-wallet/go.sum | 440 +++++++ .../identity}/identity.go | 90 +- .../identity/identity_test.go | 20 +- .../identity}/store/file/store.go | 4 +- .../identity}/store/inmemory/store.go | 2 +- .../identity}/store/store.go | 0 .../identity}/store/store_test.go | 29 +- pkg/client-wallet/identity/utils.go | 95 ++ pkg/{client-lib => client-wallet}/init.go | 53 +- pkg/client-wallet/send.go | 75 ++ .../store/file/store.go} | 8 +- .../store/file/types.go | 6 +- .../store/file/utils.go | 0 pkg/client-wallet/store/inmemory/store.go | 59 + pkg/client-wallet/store/service.go | 24 + pkg/client-wallet/store/service_test.go | 84 ++ pkg/client-wallet/types.go | 103 ++ .../types/interfaces.go | 8 +- pkg/client-wallet/types/types.go | 86 ++ pkg/{client-lib => client-wallet}/unroll.go | 388 +++--- pkg/client-wallet/unroll_ops.go | 51 + pkg/client-wallet/utils.go | 374 ++++++ pkg/client-wallet/wallet.go | 419 +++++++ pkg/client-wallet/wallet_opts.go | 31 + 124 files changed, 11836 insertions(+), 9659 deletions(-) create mode 100644 pkg/ark-lib/tree/signer.go delete mode 100644 pkg/client-lib/asset.go create mode 100644 pkg/client-lib/batch-session/batch_session.go create mode 100644 pkg/client-lib/batch-session/batch_session_opts.go create mode 100644 pkg/client-lib/batch-session/batch_session_opts_test.go create mode 100644 pkg/client-lib/batch-session/batch_session_test.go create mode 100644 pkg/client-lib/batch-session/batch_session_types.go create mode 100644 pkg/client-lib/batch-session/collaborative_exit.go create mode 100644 pkg/client-lib/batch-session/collaborative_exit_test.go create mode 100644 pkg/client-lib/batch-session/handler/default_handler.go create mode 100644 pkg/client-lib/batch-session/handler/handler.go create mode 100644 pkg/client-lib/batch-session/handler/handler_opts.go create mode 100644 pkg/client-lib/batch-session/handler/types.go create mode 100644 pkg/client-lib/batch-session/handler/utils.go create mode 100644 pkg/client-lib/batch-session/intent.go create mode 100644 pkg/client-lib/batch-session/intent_test.go create mode 100644 pkg/client-lib/batch-session/redeem_notes.go create mode 100644 pkg/client-lib/batch-session/redeem_notes_test.go create mode 100644 pkg/client-lib/batch-session/settle.go create mode 100644 pkg/client-lib/batch-session/settle_test.go create mode 100644 pkg/client-lib/batch-session/utils.go create mode 100644 pkg/client-lib/batch-session/utils_test.go delete mode 100644 pkg/client-lib/batch_session.go delete mode 100644 pkg/client-lib/batch_session_handler.go delete mode 100644 pkg/client-lib/batch_session_opts.go create mode 100644 pkg/client-lib/client.go delete mode 100644 pkg/client-lib/client/grpc/client.go rename pkg/client-lib/client/{grpc => }/reconnect_test.go (95%) rename pkg/client-lib/client/{grpc => }/types.go (83%) rename pkg/client-lib/explorer/{mempool => }/connection_pool.go (99%) rename pkg/client-lib/explorer/{mempool => }/listeners.go (65%) delete mode 100644 pkg/client-lib/explorer/mempool/explorer.go delete mode 100644 pkg/client-lib/explorer/mempool/utils_test.go rename pkg/client-lib/explorer/{mempool => }/opts.go (96%) rename pkg/client-lib/explorer/{mempool => }/types.go (78%) rename pkg/client-lib/explorer/{mempool => }/utils.go (91%) rename pkg/client-lib/{identity => }/identity.go (90%) rename pkg/client-lib/{indexer/service.go => indexer.go} (84%) rename pkg/client-lib/indexer/{grpc => }/cache.go (99%) rename pkg/client-lib/indexer/{grpc => }/cache_test.go (99%) rename pkg/client-lib/indexer/{grpc => }/client.go (79%) rename pkg/client-lib/indexer/{grpc => }/paginated_fetch_test.go (99%) rename pkg/client-lib/indexer/{grpc => }/reconnect_get_subscription_stream_test.go (92%) rename pkg/client-lib/{indexer/opts.go => indexer_opts.go} (95%) rename pkg/client-lib/{indexer/opts_test.go => indexer_opts_test.go} (62%) create mode 100644 pkg/client-lib/internal/utils/listener_test.go delete mode 100644 pkg/client-lib/internal/utils/reconnect.go delete mode 100644 pkg/client-lib/internal/utils/types.go delete mode 100644 pkg/client-lib/internal/utils/utils_test.go create mode 100644 pkg/client-lib/offchain-tx/args.go create mode 100644 pkg/client-lib/offchain-tx/asset.go create mode 100644 pkg/client-lib/offchain-tx/asset_test.go create mode 100644 pkg/client-lib/offchain-tx/build.go create mode 100644 pkg/client-lib/offchain-tx/opts.go create mode 100644 pkg/client-lib/offchain-tx/pending.go create mode 100644 pkg/client-lib/offchain-tx/pending_test.go create mode 100644 pkg/client-lib/offchain-tx/send.go create mode 100644 pkg/client-lib/offchain-tx/send_test.go create mode 100644 pkg/client-lib/offchain-tx/types.go create mode 100644 pkg/client-lib/offchain-tx/utils.go rename pkg/client-lib/{send_opts_test.go => offchain-tx/utils_test.go} (97%) create mode 100644 pkg/client-lib/offchain-tx/verify.go create mode 100644 pkg/client-lib/offchain-tx/verify_test.go delete mode 100644 pkg/client-lib/receiver_opts.go delete mode 100644 pkg/client-lib/receiver_opts_test.go delete mode 100644 pkg/client-lib/send.go delete mode 100644 pkg/client-lib/send_opts.go delete mode 100644 pkg/client-lib/service_opts.go delete mode 100644 pkg/client-lib/sign_opts.go delete mode 100644 pkg/client-lib/store/inmemory/config_store.go delete mode 100644 pkg/client-lib/store/service.go delete mode 100644 pkg/client-lib/store/service_test.go delete mode 100644 pkg/client-lib/types/types.go delete mode 100644 pkg/client-lib/unroll_ops.go create mode 100644 pkg/client-lib/utils_test.go delete mode 100644 pkg/client-lib/vtxos_opts.go delete mode 100644 pkg/client-lib/wallet.go create mode 100644 pkg/client-wallet/asset.go create mode 100644 pkg/client-wallet/batch_session.go rename pkg/{client-lib => client-wallet}/example/README.md (97%) rename pkg/{client-lib => client-wallet}/example/alice_to_bob/alice_to_bob.go (93%) rename pkg/{client-lib => client-wallet}/example/multi_connection_demo/multi_connection_demo.go (95%) rename pkg/{client-lib => client-wallet}/funding.go (56%) rename pkg/{client-lib => client-wallet}/funding_opts.go (100%) rename pkg/{client-lib => client-wallet}/funding_opts_test.go (97%) create mode 100644 pkg/client-wallet/go.mod create mode 100644 pkg/client-wallet/go.sum rename pkg/{client-lib/identity/singlekey => client-wallet/identity}/identity.go (86%) rename pkg/{client-lib => client-wallet}/identity/identity_test.go (88%) rename pkg/{client-lib/identity/singlekey => client-wallet/identity}/store/file/store.go (96%) rename pkg/{client-lib/identity/singlekey => client-wallet/identity}/store/inmemory/store.go (86%) rename pkg/{client-lib/identity/singlekey => client-wallet/identity}/store/store.go (100%) rename pkg/{client-lib/identity/singlekey => client-wallet/identity}/store/store_test.go (63%) create mode 100644 pkg/client-wallet/identity/utils.go rename pkg/{client-lib => client-wallet}/init.go (70%) create mode 100644 pkg/client-wallet/send.go rename pkg/{client-lib/store/file/config_store.go => client-wallet/store/file/store.go} (94%) rename pkg/{client-lib => client-wallet}/store/file/types.go (96%) rename pkg/{client-lib => client-wallet}/store/file/utils.go (100%) create mode 100644 pkg/client-wallet/store/inmemory/store.go create mode 100644 pkg/client-wallet/store/service.go create mode 100644 pkg/client-wallet/store/service_test.go create mode 100644 pkg/client-wallet/types.go rename pkg/{client-lib => client-wallet}/types/interfaces.go (60%) create mode 100644 pkg/client-wallet/types/types.go rename pkg/{client-lib => client-wallet}/unroll.go (64%) create mode 100644 pkg/client-wallet/unroll_ops.go create mode 100644 pkg/client-wallet/utils.go create mode 100644 pkg/client-wallet/wallet.go create mode 100644 pkg/client-wallet/wallet_opts.go diff --git a/go.mod b/go.mod index 98b1c17bd..a290f59b7 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/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..b60dc3ebb --- /dev/null +++ b/pkg/client-lib/batch-session/batch_session.go @@ -0,0 +1,201 @@ +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 + } + + utxoOuts := make([]clientlib.Receiver, 0, len(args.Outputs)) + indexedOutputs := make(map[string]struct{}) + for _, output := range args.Outputs { + if output.IsOnchain() { + utxoOuts = append(utxoOuts, output) + continue + } + + txOut, _, err := output.ToTxOut() + if err != nil { + return nil, err + } + indexedOutputs[hex.EncodeToString(txOut.PkScript)] = struct{}{} + } + + 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 { + if _, ok := indexedOutputs[hex.EncodeToString(out.PkScript)]; ok { + 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, + }) + break + } + } + } + + 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 { + time.Sleep(100 * time.Millisecond) + 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..02a250a72 --- /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 sooner 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..85d0403b1 --- /dev/null +++ b/pkg/client-lib/batch-session/batch_session_test.go @@ -0,0 +1,85 @@ +package batchsession + +import ( + "context" + "testing" + + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "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: batchsessionhandler.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..eb5467ddd --- /dev/null +++ b/pkg/client-lib/batch-session/batch_session_types.go @@ -0,0 +1,131 @@ +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" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "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 batchsessionhandler.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..abd79ccb0 --- /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" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "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 + FeeEstimator *arkfee.Estimator + ServerInfo clientlib.Info + SignTx batchsessionhandler.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 a.FeeEstimator == nil { + return fmt.Errorf("missing fee estimator") + } + 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 + } + } + + vtxos, _, outputs, err := selectFunds( + ctx, args.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..e73f09162 --- /dev/null +++ b/pkg/client-lib/batch-session/collaborative_exit_test.go @@ -0,0 +1,88 @@ +package batchsession + +import ( + "context" + "testing" + + "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "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 fee estimator", + mutate: func(a *CollaborativeExitArgs) { a.FeeEstimator = nil }, + errSubstr: "missing fee estimator", + }, + { + 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() + feeEstimator, err := arkfee.New(arkfee.Config{}) + require.NoError(t, err) + return CollaborativeExitArgs{ + Client: mockClient{}, + FeeEstimator: feeEstimator, + ServerInfo: clientlib.Info{Dust: 1000, Network: "regtest"}, + SignTx: batchsessionhandler.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..58d7d1aa5 --- /dev/null +++ b/pkg/client-lib/batch-session/handler/default_handler.go @@ -0,0 +1,631 @@ +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/arkade-os/arkd/pkg/client-lib/internal/utils" + "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 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) + + 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 *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 !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 *defaultHandler) createAndSignForfeits( + ctx context.Context, vtxosToSign []clientlib.Vtxo, connectorsLeaves []*psbt.Packet, +) ([]string, error) { + parsedForfeitAddr, err := btcutil.DecodeAddress(h.ServerInfo.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.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..2c6fc024e --- /dev/null +++ b/pkg/client-lib/batch-session/handler/handler.go @@ -0,0 +1,230 @@ +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 { + go func() { + 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..8cd0ac05d --- /dev/null +++ b/pkg/client-lib/batch-session/handler/handler_opts.go @@ -0,0 +1,32 @@ +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 + keysByScript map[string]string // 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..6f7cf0a69 --- /dev/null +++ b/pkg/client-lib/batch-session/handler/types.go @@ -0,0 +1,36 @@ +package batchsessionhandler + +import ( + "context" + "time" + + "github.com/arkade-os/arkd/pkg/ark-lib/tree" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" +) + +type SignFn func(ctx context.Context, tx string) (string, error) + +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..9842a5458 --- /dev/null +++ b/pkg/client-lib/batch-session/handler/utils.go @@ -0,0 +1,204 @@ +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/arkade-os/arkd/pkg/client-lib/internal/utils" + "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 := 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 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 +} 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..9ecf8f23b --- /dev/null +++ b/pkg/client-lib/batch-session/intent_test.go @@ -0,0 +1,128 @@ +package batchsession + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "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: batchsessionhandler.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..9f1c19f3b --- /dev/null +++ b/pkg/client-lib/batch-session/redeem_notes.go @@ -0,0 +1,74 @@ +package batchsession + +import ( + "context" + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/note" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" +) + +// 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 batchsessionhandler.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.ServerInfo.Network) <= 0 { + return fmt.Errorf("missing server info") + } + if len(a.Notes) <= 0 { + return fmt.Errorf("missing notes to redeem") + } + if len(a.ReceiverAddr) <= 0 { + return fmt.Errorf("missing receiver") + } + 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..a81cf7b0a --- /dev/null +++ b/pkg/client-lib/batch-session/redeem_notes_test.go @@ -0,0 +1,65 @@ +package batchsession + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "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: batchsessionhandler.SignFn(mockSignTx), + ServerInfo: clientlib.Info{Network: "regtest"}, + 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..b3a698642 --- /dev/null +++ b/pkg/client-lib/batch-session/settle.go @@ -0,0 +1,171 @@ +package batchsession + +import ( + "context" + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" +) + +// SettleArgs configures a Settle call: the BoardingUtxos and Vtxos to settle +// into a fresh vtxo at ReceiverAddr. ExpiryThreshold (in seconds) filters out +// vtxos expiring sooner 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 batchsessionhandler.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 := utils.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..bc632d76a --- /dev/null +++ b/pkg/client-lib/batch-session/settle_test.go @@ -0,0 +1,92 @@ +package batchsession + +import ( + "context" + "testing" + + clientlib "github.com/arkade-os/arkd/pkg/client-lib" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + "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" + +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: batchsessionhandler.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..d945cbe3e --- /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, fmt.Errorf("connection closed by server") + } + 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 9a743665a..000000000 --- a/pkg/client-lib/explorer/mempool/explorer.go +++ /dev/null @@ -1,1034 +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" - pongInterval = 60 * time.Second - pingInterval = (pongInterval * 9) / 10 -) - -var ( - defaultExplorerUrls = utils.SupportedType[string]{ - arklib.Bitcoin.Name: "https://mempool.space/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.space/signet/api", - arklib.BitcoinMutinyNet.Name: "https://mutinynet.com/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{} - for _, opt := range opts { - opt(svcOpts) - } - - if svcOpts.noTracking { - return &explorerSvc{ - cache: utils.NewCache[string](), - baseUrl: baseUrl, - net: net, - noTracking: svcOpts.noTracking, - }, nil - } - - 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..33f93db32 100644 --- a/pkg/client-lib/explorer/service.go +++ b/pkg/client-lib/explorer/service.go @@ -1,139 +1,1031 @@ +// 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" + pongInterval = 60 * time.Second + pingInterval = (pongInterval * 9) / 10 +) + +var ( + supportedExplorers = supportedType[string]{ + arklib.Bitcoin.Name: "https://mempool.space/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.space/signet/api", + arklib.BitcoinMutinyNet.Name: "https://mutinynet.com/api", + arklib.BitcoinRegTest.Name: "http://127.0.0.1:3000", + } +) + +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 +} + +// 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] + } + + if _, err := deriveWsURL(baseUrl); err != nil { + return nil, fmt.Errorf("invalid base url: %s", err) + } + + svcOpts := &explorerSvc{} + for _, opt := range opts { + opt(svcOpts) + } + + if svcOpts.noTracking { + return &explorerSvc{ + cache: newCache[string](), + baseUrl: baseUrl, + net: net, + noTracking: svcOpts.noTracking, + }, nil + } + + svc := &explorerSvc{ + cache: 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, 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") +} + +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 clientlib.OnchainAddressEvent { + ch := make(chan clientlib.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) ([]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), + ) + } - // GetTxHex retrieves the raw transaction hex for a given transaction ID. - GetTxHex(txid string) (string, error) + 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) + } + } - // 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) + // Add new addresses to the subscribed map + for _, addr := range addressesToSubscribe { + e.subscribedMap[addr] = addressData{script: scripts[addr]} + } - // GetTxs retrieves all transactions associated with a given address. - GetTxs(addr string) ([]Tx, error) + if numAddressesLeftToSubscribe > 0 { + return fmt.Errorf( + "can't subscribe for any more addresses (max=%d) (left=%d)", + len(e.subscribedMap), numAddressesLeftToSubscribe, + ) + } + return nil +} - // GetTxOutspends returns the spent status of all outputs for a given transaction. - GetTxOutspends(tx string) ([]SpentStatus, error) +func (e *explorerSvc) UnsubscribeForAddresses(addresses []string) error { + if e.noTracking { + return nil + } - // GetUtxos retrieves all unspent transaction outputs (UTXOs) for the given addresses. - GetUtxos(addresses []string) ([]Utxo, error) + e.subscribedMu.Lock() + defer e.subscribedMu.Unlock() - // 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) + addressesToUnsubscribe := make([]string, 0, len(addresses)) + for _, addr := range addresses { + if _, ok := e.subscribedMap[addr]; !ok { + continue + } + addressesToUnsubscribe = append(addressesToUnsubscribe, addr) + } - // GetTxBlockTime returns whether a transaction is confirmed and its block time. - GetTxBlockTime(txid string) (confirmed bool, blocktime int64, err error) + // Nothing to do if no addresses to unsubscribe. + if len(addressesToUnsubscribe) == 0 { + return nil + } - // BaseUrl returns the base URL of the explorer service. - BaseUrl() string + 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) + } + } - // GetFeeRate retrieves the current recommended fee rate in sat/vB. - GetFeeRate() (float64, error) + for _, addr := range addresses { + delete(e.subscribedMap, addr) + } - // GetConnectionCount returns the number of active WebSocket connections. - GetConnectionCount() int + return nil +} - // GetSubscribedAddresses returns a list of all currently subscribed addresses. - GetSubscribedAddresses() []string +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 + } - // IsAddressSubscribed checks if a specific address is currently subscribed. - IsAddressSubscribed(address string) bool + // 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)) + } - // GetAddressesEvents returns a channel that receives onchain address events - // (new UTXOs, spent UTXOs, confirmed UTXOs) for all subscribed addresses. - GetAddressesEvents() <-chan types.OnchainAddressEvent + 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) + } - // 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 + outputScript, err := txscript.PayToAddrScript(decoded) + if err != nil { + return nil, fmt.Errorf("invalid address: %s", err) + } - // UnsubscribeForAddresses removes address subscriptions and updates the WebSocket connections. - UnsubscribeForAddresses(addresses []string) error + 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++ - // Stop gracefully shuts down the explorer, closing all WebSocket connections and channels. - Stop() + // Throttle requests to not overload the clientlib. + if count%20 == 0 { + time.Sleep(time.Second) + } + } + return allUtxos, nil } -type SpentStatus struct { - Spent bool - SpentBy string +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 } -type Output struct { - Script string - Address string - Amount uint64 +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 } -type Input struct { - Output - Txid string - Vout uint32 +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) + } + } -type Tx struct { - Txid string - Vin []Input - Vout []Output - Status ConfirmedStatus +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) + } + } } -type ConfirmedStatus struct { - Confirmed bool - BlockTime int64 +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, + }) + } } -// 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) 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 } -// 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) 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 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) 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() - 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, + 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 9cea65a4b..c5bf6a470 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" ) @@ -1117,7 +1123,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() }) @@ -1125,3 +1131,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..589b0fb7a 100644 --- a/pkg/client-lib/go.mod +++ b/pkg/client-lib/go.mod @@ -19,108 +19,40 @@ 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 github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // 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/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/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/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..d31663369 100644 --- a/pkg/client-lib/go.sum +++ b/pkg/client-lib/go.sum @@ -1,25 +1,8 @@ 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/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/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= 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= @@ -41,45 +24,17 @@ github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufo 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.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/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/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 +47,13 @@ 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/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/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-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/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,191 +64,37 @@ 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/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-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= -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/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/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/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/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/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/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/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/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= -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.20240712235311-98bd56499dfb h1:yfM05S8DXKhuCBp5qSMZdtSwvJ+GFzl94KbXMNB1JDY= -github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb/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/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/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/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/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/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/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= @@ -340,113 +104,31 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5 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.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/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/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/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.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/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= 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.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/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/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 +137,51 @@ 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.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/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= 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/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 +193,14 @@ 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/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/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/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= 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 84% rename from pkg/client-lib/indexer/service.go rename to pkg/client-lib/indexer.go index b9f255745..ed3bd3d2e 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) @@ -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 79% rename from pkg/client-lib/indexer/grpc/client.go rename to pkg/client-lib/indexer/client.go index 00cad60fd..f02f157b6 100644 --- a/pkg/client-lib/indexer/grpc/client.go +++ b/pkg/client-lib/indexer/client.go @@ -1,4 +1,4 @@ -package grpcindexer +package indexer import ( "context" @@ -10,9 +10,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 +31,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 +79,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 +88,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 +98,7 @@ func (a *grpcClient) GetCommitmentTx( } } - return &indexer.CommitmentTx{ + return &clientlib.CommitmentTx{ StartedAt: resp.GetStartedAt(), EndedAt: resp.GetEndedAt(), TotalInputAmount: resp.GetTotalInputAmount(), @@ -111,9 +110,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 +137,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 +164,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 +190,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 +217,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 +256,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 +287,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 +333,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 +376,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 +400,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 +436,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 +462,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 +474,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 +513,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 +538,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 +551,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 +606,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,7 +625,7 @@ func (a *grpcClient) GetAsset(ctx context.Context, assetID string) ( } } - return &indexer.AssetInfo{ + return &clientlib.AssetInfo{ AssetId: resp.GetAssetId(), Supply: resp.GetSupply(), ControlAssetId: resp.GetControlAsset(), @@ -685,15 +685,15 @@ 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() vtxos, err := 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: o.Scripts, Outpoints: o.FormattedOutpoints(), @@ -713,12 +713,12 @@ func (a *grpcClient) paginatedGetVtxos( if err != nil { return nil, err } - return &indexer.VtxosResponse{Vtxos: vtxos}, nil + return &clientlib.VtxosResponse{Vtxos: vtxos}, nil } func (a *grpcClient) paginatedGetVirtualTxs( ctx context.Context, txids []string, -) (*indexer.VirtualTxsResponse, error) { +) (*clientlib.VirtualTxsResponse, error) { svc := a.svc() txs, err := paginatedFetch(ctx, func( @@ -736,7 +736,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 @@ -784,49 +784,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 f8e1aa1a7..c98a19768 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 index 777a86499..3ab840eaa 100644 --- a/pkg/client-lib/internal/utils/utils.go +++ b/pkg/client-lib/internal/utils/utils.go @@ -1,40 +1,25 @@ 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" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" "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) + boardingUtxos []clientlib.Utxo, vtxos []clientlib.Vtxo, + outputs []clientlib.Receiver, dust uint64, feeEstimator *arkfee.Estimator, +) ([]clientlib.Utxo, []clientlib.Vtxo, uint64, error) { + selected, notSelected := make([]clientlib.Vtxo, 0), make([]clientlib.Vtxo, 0) + selectedBoarding, notSelectedBoarding := make([]clientlib.Utxo, 0), make([]clientlib.Utxo, 0) selectedAmount := uint64(0) amount := uint64(0) @@ -56,16 +41,14 @@ func CoinSelect( } } - 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 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) - }) - } + sort.SliceStable(boardingUtxos, func(i, j int) bool { + return boardingUtxos[i].RedeemableAt.Before(boardingUtxos[j].RedeemableAt) + }) for _, boardingUtxo := range boardingUtxos { if selectedAmount >= amount { @@ -153,13 +136,13 @@ func CoinSelect( // 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, + vtxos []clientlib.Vtxo, amount uint64, assetID string, withoutExpirySorting bool, -) ([]types.VtxoWithTapTree, uint64, error) { - selected := make([]types.VtxoWithTapTree, 0) +) ([]clientlib.Vtxo, uint64, error) { + selected := make([]clientlib.Vtxo, 0) selectedAmount := uint64(0) - filteredVtxos := make([]types.VtxoWithTapTree, 0) + filteredVtxos := make([]clientlib.Vtxo, 0) // filter out vtxos holding other assets (or no assets) for _, vtxo := range vtxos { @@ -176,9 +159,9 @@ func CoinSelectAsset( vtxos = filteredVtxos if !withoutExpirySorting { - // sort vtxos by expiration (older first) + // Sort vtxos by expiration (oldest last) sort.SliceStable(vtxos, func(i, j int) bool { - return vtxos[i].ExpiresAt.Before(vtxos[j].ExpiresAt) + return !vtxos[i].ExpiresAt.Before(vtxos[j].ExpiresAt) }) } @@ -218,7 +201,7 @@ func ParseBitcoinAddress(addr string, net chaincfg.Params) ( return true, onchainScript, nil } -func IsOnchainOnly(receivers []types.Receiver) bool { +func IsOnchainOnly(receivers []clientlib.Receiver) bool { for _, receiver := range receivers { if !receiver.IsOnchain() { return false @@ -227,218 +210,3 @@ func IsOnchainOnly(receivers []types.Receiver) bool { 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..8d3c8727e --- /dev/null +++ b/pkg/client-lib/offchain-tx/args.go @@ -0,0 +1,247 @@ +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: the same inputs as +// BuildAndSignTxArgs plus a Client used to submit and finalize the tx. +type SendArgs struct { + BuildAndSignTxArgs + Client clientlib.Client +} + +func (a SendArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + return a.BuildAndSignTxArgs.validate() +} + +// 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: the same inputs as +// BuildAndSignIssuanceTxArgs plus a Client used to submit and finalize the tx. +type IssueAssetArgs struct { + BuildAndSignIssuanceTxArgs + Client clientlib.Client +} + +func (a IssueAssetArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + return a.BuildAndSignIssuanceTxArgs.validate() +} + +// 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 + AssetId string + ControlAssetId string + Amount uint64 +} + +func (a BuildAndSignReissuanceTxArgs) validate() error { + if err := a.validateBase(); err != nil { + return err + } + if a.AssetId == "" { + return fmt.Errorf("missing asset id") + } + if a.ControlAssetId == "" { + return fmt.Errorf("missing control asset id") + } + if a.Amount == 0 { + return fmt.Errorf("amount must be > 0") + } + return nil +} + +// ReissueAssetArgs configures the ReissueAsset orchestrator: the same inputs +// as BuildAndSignReissuanceTxArgs plus a Client used to submit and finalize +// the tx. +type ReissueAssetArgs struct { + BuildAndSignReissuanceTxArgs + Client clientlib.Client +} + +func (a ReissueAssetArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + return a.BuildAndSignReissuanceTxArgs.validate() +} + +// 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 + AssetId string + Amount uint64 +} + +func (a BuildAndSignBurnTxArgs) validate() error { + if err := a.validateBase(); err != nil { + return err + } + if a.AssetId == "" { + return fmt.Errorf("missing asset id") + } + if a.Amount == 0 { + return fmt.Errorf("amount must be > 0") + } + return nil +} + +// BurnAssetArgs configures the BurnAsset orchestrator: the same inputs as +// BuildAndSignBurnTxArgs plus a Client used to submit and finalize the tx. +type BurnAssetArgs struct { + BuildAndSignBurnTxArgs + Client clientlib.Client +} + +func (a BurnAssetArgs) validate() error { + if a.Client == nil { + return fmt.Errorf("missing client") + } + return a.BuildAndSignBurnTxArgs.validate() +} + +// 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 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 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") + } + 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..d9b575c16 --- /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) + } + + signerPubKey, err := args.signerPubKey() + if err != nil { + return nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + + build, err := BuildAndSignIssuanceTx(ctx, args.BuildAndSignIssuanceTxArgs, 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: 1, + }) + } + 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) + } + + signerPubKey, err := args.signerPubKey() + if err != nil { + return nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + + build, err := BuildAndSignReissuanceTx(ctx, args.BuildAndSignReissuanceTxArgs, 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{ + {AssetId: args.ControlAssetId, Amount: 1}, + {AssetId: args.AssetId, Amount: args.Amount}, + }, + } + + 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) + } + + signerPubKey, err := args.signerPubKey() + if err != nil { + return nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + + build, err := BuildAndSignBurnTx(ctx, args.BuildAndSignBurnTxArgs, 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..72994fcad --- /dev/null +++ b/pkg/client-lib/offchain-tx/asset_test.go @@ -0,0 +1,436 @@ +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.AssetId = "" }, + errSubstr: "missing asset id", + }, + { + name: "missing control asset id", + mutate: func(a *ReissueAssetArgs) { a.ControlAssetId = "" }, + errSubstr: "missing control asset id", + }, + { + name: "zero amount", + mutate: func(a *ReissueAssetArgs) { a.Amount = 0 }, + errSubstr: "amount must be > 0", + }, + } + + 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.AssetId = "" }, + errSubstr: "missing asset id", + }, + { + name: "missing control asset id", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ControlAssetId = "" }, + errSubstr: "missing control asset id", + }, + { + name: "zero amount", + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.Amount = 0 }, + errSubstr: "amount must be > 0", + }, + } + + 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.AssetId = "" }, + errSubstr: "missing asset id", + }, + { + name: "zero amount", + mutate: func(a *BurnAssetArgs) { a.Amount = 0 }, + 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.AssetId = "" }, + errSubstr: "missing asset id", + }, + { + name: "zero amount", + mutate: func(a *BuildAndSignBurnTxArgs) { a.Amount = 0 }, + 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 { + return IssueAssetArgs{ + BuildAndSignIssuanceTxArgs: newTestIssueAssetBuildArgs(), + Client: mockClient{}, + } +} + +// 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 { + return ReissueAssetArgs{ + BuildAndSignReissuanceTxArgs: newTestReissueAssetBuildArgs(), + Client: mockClient{}, + } +} + +// 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", + }, + AssetId: "fakeassetid", + ControlAssetId: "fakecontrolassetid", + Amount: 100, + } +} + +// newTestBurnAssetArgs returns a valid baseline BurnAssetArgs. +func newTestBurnAssetArgs() BurnAssetArgs { + return BurnAssetArgs{ + BuildAndSignBurnTxArgs: newTestBurnAssetBuildArgs(), + Client: mockClient{}, + } +} + +// 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", + }, + 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..fab43c1e1 --- /dev/null +++ b/pkg/client-lib/offchain-tx/build.go @@ -0,0 +1,426 @@ +package offchaintx + +import ( + "context" + "fmt" + "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 + } + + 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: 1, + }) + } + + 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{{ + AssetId: args.ControlAssetId, + Amount: 1, // TODO: should send all denominated amount of the asset vtxo + }}, + } + + 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.Amount) + if err != nil { + return nil, err + } + + groupIndex := -1 + for i, g := range assetPacket { + if g.AssetId == nil { + continue + } + if g.AssetId.String() == args.AssetId { + groupIndex = i + } + } + + if groupIndex == -1 { + reissueAssetId, err := asset.NewAssetIdFromString(args.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{{ + AssetId: args.AssetId, + Amount: args.Amount, + }}, + } + + 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 +} diff --git a/pkg/client-lib/offchain-tx/opts.go b/pkg/client-lib/offchain-tx/opts.go new file mode 100644 index 000000000..bd879570c --- /dev/null +++ b/pkg/client-lib/offchain-tx/opts.go @@ -0,0 +1,59 @@ +package offchaintx + +import ( + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/asset" + "github.com/arkade-os/arkd/pkg/ark-lib/extension" +) + +// 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 + }) +} + +type optFn func(*options) error + +func (f optFn) apply(o *options) error { return f(o) } + +type options struct { + extraPackets []extension.Packet +} + +func newOptions() *options { + return &options{} +} diff --git a/pkg/client-lib/offchain-tx/pending.go b/pkg/client-lib/offchain-tx/pending.go new file mode 100644 index 000000000..42df2397f --- /dev/null +++ b/pkg/client-lib/offchain-tx/pending.go @@ -0,0 +1,47 @@ +package offchaintx + +import ( + "context" + "fmt" + + batchsession "github.com/arkade-os/arkd/pkg/client-lib/batch-session" + batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" + 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: batchsessionhandler.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..b00c6cc72 --- /dev/null +++ b/pkg/client-lib/offchain-tx/send.go @@ -0,0 +1,47 @@ +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) + } + + signerPubKey, err := args.signerPubKey() + if err != nil { + return nil, fmt.Errorf("invalid signer pubkey: %w", err) + } + + build, err := BuildAndSignTx(ctx, args.BuildAndSignTxArgs, 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..57763b05d --- /dev/null +++ b/pkg/client-lib/offchain-tx/send_test.go @@ -0,0 +1,156 @@ +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 { + return SendArgs{ + BuildAndSignTxArgs: newTestSendBuildArgs(), + Client: mockClient{}, + } +} + +// 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..af99d1580 --- /dev/null +++ b/pkg/client-lib/offchain-tx/types.go @@ -0,0 +1,58 @@ +package offchaintx + +import ( + "context" + + "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" +) + +// 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) + +// 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..f8d57877e --- /dev/null +++ b/pkg/client-lib/offchain-tx/utils.go @@ -0,0 +1,670 @@ +package offchaintx + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "math" + + 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/arkade-os/arkd/pkg/client-lib/internal/utils" + "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 := utils.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 := 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) + []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(math.Abs(float64(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 := utils.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 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 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 97% rename from pkg/client-lib/send_opts_test.go rename to pkg/client-lib/offchain-tx/utils_test.go index 0e98e86ab..cfe1002e3 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" @@ -20,38 +20,6 @@ var ( ) 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}} @@ -80,9 +48,9 @@ func TestWithExtraPacket(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - opts := newDefaultSendOptions() + opts := newOptions() for _, callPackets := range tc.applyPackets { - require.NoError(t, WithExtraPacket(callPackets...).applySend(opts)) + require.NoError(t, WithExtraPacket(callPackets...).apply(opts)) } require.Len(t, opts.extraPackets, len(tc.expectTypes)) for i, wantType := range tc.expectTypes { @@ -91,6 +59,38 @@ 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 := 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) + }) + } + }) } // TestAddExtension exercises the refactored addExtension helper. It covers 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/redemption/redeem.go b/pkg/client-lib/redemption/redeem.go index 6fa47c46a..351b4243b 100644 --- a/pkg/client-lib/redemption/redeem.go +++ b/pkg/client-lib/redemption/redeem.go @@ -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/send.go b/pkg/client-lib/send.go deleted file mode 100644 index 8fee28c0f..000000000 --- a/pkg/client-lib/send.go +++ /dev/null @@ -1,505 +0,0 @@ -package wallet - -import ( - "bytes" - "context" - "fmt" - "math" - "slices" - "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 - } - - 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 -} - -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 4898e36fa..000000000 --- a/pkg/client-lib/send_opts.go +++ /dev/null @@ -1,65 +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/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 - }) -} - -type sendOptions struct { - withoutExpirySorting bool - vtxos []types.VtxoWithTapTree - signingKeys map[string]string - extraPackets []extension.Packet - receiver string -} - -func newDefaultSendOptions() *sendOptions { - return &sendOptions{} -} diff --git a/pkg/client-lib/service.go b/pkg/client-lib/service.go index faeb1e9e8..af7cb8566 100644 --- a/pkg/client-lib/service.go +++ b/pkg/client-lib/service.go @@ -1,451 +1,138 @@ -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 { + utxoTime := u.Status.BlockTime + createdAt := time.Unix(utxoTime, 0) + if utxoTime == 0 { + createdAt = time.Time{} + utxoTime = time.Now().Unix() } - 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: time.Unix(utxoTime, 0).Add(time.Duration(delay.Seconds()) * time.Second), + 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..756893862 100644 --- a/pkg/client-lib/types.go +++ b/pkg/client-lib/types.go @@ -1,49 +1,481 @@ -package wallet +package clientlib -import "github.com/arkade-os/arkd/pkg/client-lib/types" +import ( + "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 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 + 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 +} + +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", + TxsUpdated: "TXS_UPDATED", + }[e] +} + +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, + } } -type OnchainBalance struct { - SpendableAmount uint64 `json:"spendable_amount"` - LockedAmount []LockedOnchainBalance `json:"locked_amount,omitempty"` +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 LockedOnchainBalance struct { - SpendableAt string `json:"spendable_at"` - Amount uint64 `json:"amount"` +type OnchainAddressEvent struct { + Error error + SpentUtxos []OnchainOutput + NewUtxos []OnchainOutput + ConfirmedUtxos []OnchainOutput + Replacements map[string]string // replacedTxid -> replacementTxid } -type OffchainBalance struct { - Total uint64 `json:"total"` - NextExpiration string `json:"next_expiration,omitempty"` - Details []VtxoDetails `json:"details"` +type SyncEvent struct { + Synced bool + Err error } -type VtxoDetails struct { - ExpiryTime string `json:"expiry_time"` - Amount uint64 `json:"amount"` +// 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() } -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 +// 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() {} + +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()) + } + if len(tapscripts) <= 0 { + return nil, nil, fmt.Errorf("%s has no tapscripts", outpoint.String()) + } + + vtxoScript, err := script.ParseVtxoScript(tapscripts) + if err != nil { + return nil, nil, fmt.Errorf("%s has invalid tapscripts: %w", outpoint.String(), err) + } + forfeitScript, err := closure.Script() + if err != nil { + return nil, nil, fmt.Errorf( + "%s has invalid signing closure: %w", outpoint.String(), err, + ) + } + + taprootKey, taprootTree, err := vtxoScript.TapTree() + if err != nil { + return nil, nil, fmt.Errorf("%s has invalid taptree: %w", outpoint.String(), err) + } + + forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) + leafProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) + if err != nil { + return nil, nil, fmt.Errorf( + "%s has invalid signing script: %w", outpoint.String(), err, + ) + } + pkScript, err := script.P2TRScript(taprootKey) + if err != nil { + return nil, nil, fmt.Errorf("%s has invalid tapkey: %w", outpoint.String(), err) + } + + return pkScript, leafProof, nil } 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_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..4376be24c 100644 --- a/pkg/client-lib/utils.go +++ b/pkg/client-lib/utils.go @@ -1,1054 +1,44 @@ -package wallet +package clientlib 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/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/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/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/lightningnetwork/lnd/lntypes" + "github.com/btcsuite/btcd/chaincfg" ) -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) +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 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, - ) - } - found = true - break - } - } - 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 - } - - 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 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") - } - - // 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 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 - } - - 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 - } - 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++ - } - - 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) - arkFields = append(arkFields, input.Unknowns) - } - - 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 - } - - 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)) - } - } 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) - } - - 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 -} - -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) - } - - 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 -} - -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) - } - } - // 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) -} - -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) - } - } - 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] - } - return types.Vtxo{} -} - -func ecPubkeyFromHex(pubkey string) (*btcec.PublicKey, error) { - buf, err := hex.DecodeString(pubkey) - if err != nil { - return nil, 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) - 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 -} - -func verifySignedArk(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 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), - ) - } - - 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 { - 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 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 } - return nil } diff --git a/pkg/client-lib/utils_test.go b/pkg/client-lib/utils_test.go new file mode 100644 index 000000000..943046756 --- /dev/null +++ b/pkg/client-lib/utils_test.go @@ -0,0 +1,53 @@ +package clientlib_test + +import ( + "testing" + + 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) + }) + } +} 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..fd8f6517a --- /dev/null +++ b/pkg/client-wallet/asset.go @@ -0,0 +1,146 @@ +package wallet + +import ( + "context" + "fmt" + + "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, nil) + 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.IssueAsset(ctx, offchaintx.IssueAssetArgs{ + BuildAndSignIssuanceTxArgs: offchaintx.BuildAndSignIssuanceTxArgs{ + BaseArgs: offchaintx.BaseArgs{ + ServerInfo: w.Config.ClientInfo(), + SignTx: signTx, + Vtxos: vtxos, + ChangeAddr: offchainAddr.Address, + }, + Amount: amount, + ControlAsset: controlAsset, + Metadata: metadata, + }, + Client: w.client, + }, 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 + } + + controlAssetId, err := w.getControlAssetId(ctx, assetId) + if err != nil { + return nil, fmt.Errorf("failed to get control asset: %w", err) + } + if controlAssetId == "" { + return nil, fmt.Errorf("%s can't be reissued, no control asset", assetId) + } + + vtxos, err := w.getSpendableVtxos(ctx, nil) + 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{ + BuildAndSignReissuanceTxArgs: offchaintx.BuildAndSignReissuanceTxArgs{ + BaseArgs: offchaintx.BaseArgs{ + ServerInfo: w.Config.ClientInfo(), + SignTx: signTx, + Vtxos: vtxos, + ChangeAddr: offchainAddr.Address, + }, + AssetId: assetId, + ControlAssetId: controlAssetId, + Amount: amount, + }, + Client: w.client, + }, 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, nil) + 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{ + BuildAndSignBurnTxArgs: offchaintx.BuildAndSignBurnTxArgs{ + BaseArgs: offchaintx.BaseArgs{ + ServerInfo: w.Config.ClientInfo(), + SignTx: signTx, + Vtxos: vtxos, + ChangeAddr: offchainAddr.Address, + }, + AssetId: assetId, + Amount: amount, + }, + Client: w.client, + }, opts...) +} + +func (w *wallet) getControlAssetId(ctx context.Context, assetId string) (string, error) { + indexerAssetInfo, err := w.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-wallet/batch_session.go b/pkg/client-wallet/batch_session.go new file mode 100644 index 000000000..41ff48a96 --- /dev/null +++ b/pkg/client-wallet/batch_session.go @@ -0,0 +1,224 @@ +package wallet + +import ( + "context" + "fmt" + + "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" + 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 + } + + feeEstimator, err := arkfee.New(info.Fees.IntentFees) + 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, + FeeEstimator: feeEstimator, + 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..d883fc411 --- /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.2 + +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..5042efd31 --- /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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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..39c4e560f --- /dev/null +++ b/pkg/client-wallet/send.go @@ -0,0 +1,75 @@ +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, nil) + 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{ + BuildAndSignTxArgs: offchaintx.BuildAndSignTxArgs{ + BaseArgs: offchaintx.BaseArgs{ + ServerInfo: w.Config.ClientInfo(), + SignTx: signTx, + Vtxos: vtxos, + ChangeAddr: offchainAddr.Address, + }, + Receivers: receivers, + }, + Client: w.client, + }, 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..2886d5c1e --- /dev/null +++ b/pkg/client-wallet/types.go @@ -0,0 +1,103 @@ +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" + "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 struct { + ParentTx string + ParentTxid string + ChildTx string + ChildTxid string +} 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-lib/unroll.go b/pkg/client-wallet/unroll.go similarity index 64% rename from pkg/client-lib/unroll.go rename to pkg/client-wallet/unroll.go index 9f1762ebe..e128a02ee 100644 --- a/pkg/client-lib/unroll.go +++ b/pkg/client-wallet/unroll.go @@ -11,9 +11,8 @@ 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" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" "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" @@ -27,24 +26,25 @@ import ( 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 { +func (w *wallet) Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes, error) { + if err := w.safeCheck(); err != nil { return nil, err } - options := newDefaultUnrollOptions() + + o := newDefaultUnrollOptions() for _, opt := range opts { - if err := opt.applyUnroll(options); err != nil { + if err := opt.applyUnroll(o); err != nil { return nil, err } } - a.txLock.Lock() - defer a.txLock.Unlock() + w.txLock.Lock() + defer w.txLock.Unlock() - vtxos := options.vtxos - var err error + vtxos := o.vtxos if len(vtxos) <= 0 { - vtxos, err = a.getSpendableVtxos(ctx, nil) + var err error + vtxos, err = w.getSpendableVtxos(ctx, nil) if err != nil { return nil, err } @@ -63,14 +63,14 @@ func (a *service) Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes transactionsMap := make(map[string]struct{}, 0) transactions := make([]string, 0) - redeemBranches, err := a.getRedeemBranches(ctx, vtxos) + branches, err := w.getBranchesToUnroll(ctx, vtxos) if err != nil { return nil, err } isWaitingForConfirmation := false - for _, branch := range redeemBranches { + for _, branch := range branches { nextTx, err := branch.NextRedeemTx() if err != nil { if err, ok := err.(redemption.ErrPendingConfirmation); ok { @@ -106,13 +106,13 @@ func (a *service) Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes return nil, err } - childTxid, child, err := a.bumpAnchorTx(ctx, &parentTx) + childTxid, child, err := w.bumpAnchorTx(ctx, &parentTx) if err != nil { return nil, err } // broadcast the package (parent + child) - packageResponse, err := a.explorer.Broadcast(parent, child) + packageResponse, err := w.explorer.Broadcast(parent, child) if err != nil { return nil, err } @@ -129,10 +129,8 @@ func (a *service) Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes return res, nil } -func (a *service) CompleteUnroll( - ctx context.Context, to string, opts ...UnrollOption, -) (string, error) { - if err := a.safeCheck(); err != nil { +func (w *wallet) CompleteUnroll(ctx context.Context, opts ...UnrollOption) (string, error) { + if err := w.safeCheck(); err != nil { return "", err } @@ -143,70 +141,72 @@ func (a *service) CompleteUnroll( } } - if len(to) == 0 { - newAddr, _, _, err := a.newAddress(ctx) + to := options.receiver + if len(to) <= 0 { + onchainAddr, _, _, _, err := w.getAddresses(ctx) if err != nil { return "", err } - to = newAddr - } else if _, err := btcutil.DecodeAddress(to, nil); err != nil { + to = onchainAddr.Address + } + 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) + return w.completeUnroll(ctx, to) } -func (a *service) WithdrawFromAllExpiredBoardings( - ctx context.Context, to string, opts ...UnrollOption, +func (w *wallet) WithdrawFromAllExpiredBoardings( + ctx context.Context, opts ...UnrollOption, ) (string, error) { - if err := a.safeCheck(); err != nil { + if err := w.safeCheck(); err != nil { return "", err } - options := newDefaultUnrollOptions() + o := newDefaultUnrollOptions() for _, opt := range opts { - if err := opt.applyUnroll(options); err != nil { + 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 a.sendExpiredBoardingUtxos(ctx, to, options) + return w.sendExpiredBoardingUtxos(ctx, to) } -func (a *service) OnboardAgainAllExpiredBoardings( - ctx context.Context, opts ...UnrollOption, -) (string, error) { - if err := a.safeCheck(); err != nil { +func (w *wallet) OnboardAgainAllExpiredBoardings(ctx context.Context) (string, error) { + if err := w.safeCheck(); err != nil { return "", err } - if a.UtxoMaxAmount == 0 { + if w.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) + _, _, boardingAddr, _, err := w.getAddresses(ctx) if err != nil { return "", err } - return a.sendExpiredBoardingUtxos(ctx, addr, options) + return w.sendExpiredBoardingUtxos(ctx, boardingAddr.Address) } // 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) { +func (w *wallet) bumpAnchorTx(ctx context.Context, parent *wire.MsgTx) (string, string, error) { anchor, err := txutils.FindAnchorOutpoint(parent) if err != nil { return "", "", err @@ -226,40 +226,44 @@ func (a *service) bumpAnchorTx(ctx context.Context, parent *wire.MsgTx) (string, childVSize := weightEstimator.Weight().ToVB() packageSize := childVSize + computeVSize(parent) - feeRate, err := a.explorer.GetFeeRate() + feeRate, err := w.explorer.GetFeeRate() if err != nil { return "", "", err } fees := uint64(math.Ceil(float64(packageSize) * feeRate)) - addresses, _, _, _, err := a.getAddresses(ctx) + onchainAddr, _, _, _, err := w.getAddresses(ctx) if err != nil { return "", "", err } - selectedCoins := make([]explorer.Utxo, 0) + addr := onchainAddr.Address + pkScript, err := toOutputScript(addr, w.Network) + if err != nil { + return "", "", err + } + + keyRef, err := w.identity.GetKey(ctx, "") + if err != nil { + return "", "", err + } + + selectedCoins := make([]clientlib.ExplorerUtxo, 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 - } + utxos, err := w.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 } } @@ -269,16 +273,6 @@ func (a *service) bumpAnchorTx(ctx context.Context, parent *wire.MsgTx) (string, 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, @@ -314,18 +308,6 @@ func (a *service) bumpAnchorTx(ctx context.Context, parent *wire.MsgTx) (string, 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), @@ -339,7 +321,7 @@ func (a *service) bumpAnchorTx(ctx context.Context, parent *wire.MsgTx) (string, return "", "", err } - tx, err := a.identity.SignTransaction(ctx, b64, keys) + tx, err := w.identity.SignTransaction(ctx, b64, nil) if err != nil { return "", "", err } @@ -368,20 +350,17 @@ func (a *service) bumpAnchorTx(ctx context.Context, parent *wire.MsgTx) (string, return childTx.TxID(), hex.EncodeToString(serializedTx.Bytes()), nil } -func (a *service) completeUnroll( - ctx context.Context, to string, opts *unrollOptions, +func (w *wallet) completeUnroll( + ctx context.Context, to string, ) (string, error) { - pkscript, err := toOutputScript(to, a.Network) + pkscript, err := toOutputScript(to, w.Network) if err != nil { return "", err } - utxos := opts.utxos - if len(utxos) <= 0 { - utxos, err = a.getMatureUtxos(ctx) - if err != nil { - return "", err - } + utxos, err := w.getMatureUtxos(ctx) + if err != nil { + return "", err } targetAmount := uint64(0) @@ -409,27 +388,30 @@ func (a *service) completeUnroll( }) updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{}) - if err := a.addInputs(ctx, updater, utxos); err != nil { + if err := w.addInputs(ctx, updater, utxos); err != nil { return "", err } vbytes := computeVSize(updater.Upsbt.UnsignedTx) - feeRate, err := a.explorer.GetFeeRate() + feeRate, err := w.explorer.GetFeeRate() if err != nil { return "", err } feeAmount := uint64(math.Ceil(float64(vbytes)*feeRate) + 100) - if targetAmount-feeAmount <= a.Dust { + if targetAmount-feeAmount <= w.Dust { return "", fmt.Errorf("not enough funds to cover network fees") } updater.Upsbt.UnsignedTx.TxOut[0].Value -= int64(feeAmount) - unsignedTx, _ := ptx.B64Encode() + unsignedTx, err := ptx.B64Encode() + if err != nil { + return "", err + } - signedTx, err := a.identity.SignTransaction(ctx, unsignedTx, opts.signingKeys) + signedTx, err := w.identity.SignTransaction(ctx, unsignedTx, nil) if err != nil { return "", err } @@ -456,21 +438,19 @@ func (a *service) completeUnroll( } txHex := hex.EncodeToString(buf.Bytes()) - return a.explorer.Broadcast(txHex) + return w.explorer.Broadcast(txHex) } -func (a *service) sendExpiredBoardingUtxos( - ctx context.Context, to string, opts *unrollOptions, -) (string, error) { - pkscript, err := toOutputScript(to, a.Network) +func (w *wallet) sendExpiredBoardingUtxos(ctx context.Context, to string) (string, error) { + pkscript, err := toOutputScript(to, w.Network) if err != nil { return "", err } - a.txLock.Lock() - defer a.txLock.Unlock() + w.txLock.Lock() + defer w.txLock.Unlock() - utxos, err := a.getExpiredBoardingUtxos(ctx, nil) + utxos, err := w.getExpiredBoardingUtxos(ctx) if err != nil { return "", err } @@ -500,26 +480,29 @@ func (a *service) sendExpiredBoardingUtxos( }) updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{}) - if err := a.addInputs(ctx, updater, utxos); err != nil { + if err := w.addInputs(ctx, updater, utxos); err != nil { return "", err } vbytes := computeVSize(updater.Upsbt.UnsignedTx) - feeRate, err := a.explorer.GetFeeRate() + feeRate, err := w.explorer.GetFeeRate() if err != nil { return "", err } feeAmount := uint64(math.Ceil(float64(vbytes)*feeRate) + 50) - if targetAmount-feeAmount <= a.Dust { + if targetAmount-feeAmount <= w.Dust { return "", fmt.Errorf("not enough funds to cover network fees") } updater.Upsbt.UnsignedTx.TxOut[0].Value -= int64(feeAmount) - unsignedTx, _ := ptx.B64Encode() + unsignedTx, err := ptx.B64Encode() + if err != nil { + return "", err + } - signedTx, err := a.identity.SignTransaction(ctx, unsignedTx, opts.signingKeys) + signedTx, err := w.identity.SignTransaction(ctx, unsignedTx, nil) if err != nil { return "", err } @@ -538,64 +521,30 @@ func (a *service) sendExpiredBoardingUtxos( return ptx.B64Encode() } -func (a *service) getExpiredBoardingUtxos( - ctx context.Context, opts *getVtxosFilter, -) ([]types.Utxo, error) { - _, _, boardingAddrs, _, err := a.getAddresses(ctx) +func (w *wallet) getExpiredBoardingUtxos(ctx context.Context) ([]clientlib.Utxo, error) { + _, _, boardingAddr, _, err := w.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 - } - } + utxos, err := w.getUtxos(ctx, *boardingAddr, getUtxosFilter{expired: true}) + if err != nil { + return nil, err + } - u := utxo.ToUtxo(*boardingTimeout, addr.Tapscripts) - if u.SpendableAt.Before(now) || u.SpendableAt.Equal(now) { - expired = append(expired, u) - } + 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 expired, nil + return expiredUtxos, nil } -func (a *service) addInputs( - ctx context.Context, updater *psbt.Updater, utxos []types.Utxo, +func (w *wallet) addInputs( + ctx context.Context, updater *psbt.Updater, utxos []clientlib.Utxo, ) error { for _, utxo := range utxos { vtxoScript, err := script.ParseVtxoScript(utxo.Tapscripts) @@ -667,47 +616,122 @@ func (a *service) addInputs( return nil } -func (a *service) getMatureUtxos(ctx context.Context) ([]types.Utxo, error) { - _, _, _, redemptionAddrs, err := a.getAddresses(ctx) +func (w *wallet) getMatureUtxos(ctx context.Context) ([]clientlib.Utxo, error) { + _, _, _, addr, err := w.getAddresses(ctx) + if err != nil { + return nil, err + } + + rawScript, err := addr.RawScript() + if err != nil { + return nil, err + } + + signingClosure, err := addr.ExitClosure() + if err != nil { + return nil, err + } + + exitDelay, err := rawScript.SmallestExitDelay() 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 - } + // nolint + script, _ := toOutputScript(addr.Address, w.Network) + addrTapscripts[hex.EncodeToString(script)] = addr.Tapscripts - fetchedUtxos, err := a.explorer.GetUtxos(addresses) + fetchedUtxos, err := w.explorer.GetUtxos([]string{addr.Address}) if err != nil { return nil, err } + utxos := make([]clientlib.Utxo, 0) for _, utxo := range fetchedUtxos { tapscripts := addrTapscripts[utxo.Script] - u := utxo.ToUtxo(a.UnilateralExitDelay, tapscripts) - if u.SpendableAt.Before(now) { + u := utxo.ToUtxo(*exitDelay, tapscripts, signingClosure) + if u.RedeemableAt.Before(now) { utxos = append(utxos, u) } } + return utxos, 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 } -func (a *service) getRedeemBranches( - ctx context.Context, vtxos []types.Vtxo, -) (map[string]*redemption.CovenantlessRedeemBranch, error) { - redeemBranches := make(map[string]*redemption.CovenantlessRedeemBranch, 0) +func (w *wallet) getBranchesToUnroll( + ctx context.Context, vtxos []clientlib.Vtxo, +) (map[string]*redemption.RedeemBranch, error) { + redeemBranches := make(map[string]*redemption.RedeemBranch, 0) for _, vtxo := range vtxos { - redeemBranch, err := redemption.NewRedeemBranch(ctx, a.explorer, a.indexer, vtxo) + redeemBranch, err := redemption.NewRedeemBranch(ctx, w.explorer, w.indexer, vtxo) if err != nil { return nil, err } 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 + } +} From e928358f14184d0ff31976ff1d96d124f0353e75 Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Thu, 21 May 2026 12:53:38 +0200 Subject: [PATCH 2/9] Update tests --- internal/test/e2e/delegate_utils_test.go | 86 +-- internal/test/e2e/e2e_test.go | 612 +++++++++++-------- internal/test/e2e/single_batch_smoke_test.go | 2 +- internal/test/e2e/utils_test.go | 42 +- 4 files changed, 419 insertions(+), 323 deletions(-) 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..b0f4ac00f 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" + 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/redemption" - "github.com/arkade-os/arkd/pkg/client-lib/types" + 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 @@ -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 @@ -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 @@ -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) @@ -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) @@ -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) From 29b8a5a63723d9dbff52c434aa523d2ff752e8d4 Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Thu, 21 May 2026 12:53:46 +0200 Subject: [PATCH 3/9] Update ark cli --- pkg/ark-cli/go.mod | 8 +- pkg/ark-cli/go.sum | 197 ++++++++++++++++++++------------------------ pkg/ark-cli/main.go | 56 +++++++------ 3 files changed, 123 insertions(+), 138 deletions(-) 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..ba381674b 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" ) @@ -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 @@ -411,13 +412,13 @@ func send(ctx *cli.Context) error { 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}} } } @@ -464,12 +465,16 @@ func redeem(ctx *cli.Context) error { } if complete { - txID, err := arkSdkClient.CompleteUnroll(ctx.Context, address) + var opts []wallet.UnrollOption + if address != "" { + opts = append(opts, wallet.WithReceiver(address)) + } + txid, err := arkSdkClient.CompleteUnroll(ctx.Context, opts...) if err != nil { return err } return printJSON(map[string]interface{}{ - "txid": txID, + "txid": txid, }) } @@ -569,9 +574,9 @@ func issue(ctx *cli.Context) error { return err } - controlAssetPolicy := types.ControlAsset(types.ExistingControlAsset{ID: controlAssetId}) + controlAssetPolicy := clientlib.ControlAsset(clientlib.ExistingControlAsset{ID: controlAssetId}) if controlAssetAmount > 0 { - controlAssetPolicy = types.NewControlAsset{Amount: controlAssetAmount} + controlAssetPolicy = clientlib.NewControlAsset{Amount: controlAssetAmount} } res, err := arkSdkClient.IssueAsset( @@ -662,16 +667,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 +683,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 +694,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 +718,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() { From 32c1977275d40372be6138f4b76ee551257da71a Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Thu, 21 May 2026 12:58:53 +0200 Subject: [PATCH 4/9] Go mod tidy --- pkg/client-wallet/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/client-wallet/go.mod b/pkg/client-wallet/go.mod index d883fc411..b42b3212b 100644 --- a/pkg/client-wallet/go.mod +++ b/pkg/client-wallet/go.mod @@ -10,7 +10,7 @@ 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.2 +go 1.26.3 require ( github.com/arkade-os/arkd/pkg/ark-lib v0.0.0-00010101000000-000000000000 From ac9f8c1d1a3cd88be44d4e619ce059e7a85b999d Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Thu, 21 May 2026 13:11:42 +0200 Subject: [PATCH 5/9] Polish --- .../batch-session/batch_session_test.go | 3 +- .../batch-session/batch_session_types.go | 3 +- .../batch-session/collaborative_exit.go | 3 +- .../batch-session/collaborative_exit_test.go | 3 +- .../batch-session/handler/default_handler.go | 2 +- pkg/client-lib/batch-session/handler/types.go | 2 - pkg/client-lib/batch-session/intent_test.go | 3 +- pkg/client-lib/batch-session/redeem_notes.go | 3 +- .../batch-session/redeem_notes_test.go | 3 +- pkg/client-lib/batch-session/settle.go | 3 +- pkg/client-lib/batch-session/settle_test.go | 3 +- pkg/client-lib/offchain-tx/args.go | 4 +- pkg/client-lib/offchain-tx/pending.go | 4 +- pkg/client-lib/offchain-tx/types.go | 6 - pkg/client-lib/offchain-tx/utils.go | 4 +- pkg/client-lib/types.go | 108 +----------------- pkg/client-lib/utils.go | 45 ++++++++ 17 files changed, 65 insertions(+), 137 deletions(-) diff --git a/pkg/client-lib/batch-session/batch_session_test.go b/pkg/client-lib/batch-session/batch_session_test.go index 85d0403b1..eb3381a10 100644 --- a/pkg/client-lib/batch-session/batch_session_test.go +++ b/pkg/client-lib/batch-session/batch_session_test.go @@ -6,7 +6,6 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/tree" clientlib "github.com/arkade-os/arkd/pkg/client-lib" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" "github.com/stretchr/testify/require" ) @@ -75,7 +74,7 @@ func newTestJoinBatchArgs(t *testing.T) JoinBatchArgs { Amount: 10000, }}, Outputs: []clientlib.Receiver{{To: "tark1qexample", Amount: 10000}}, - SignTx: batchsessionhandler.SignFn(mockSignTx), + SignTx: clientlib.SignFn(mockSignTx), }, Client: mockClient{}, ServerInfo: clientlib.Info{Network: "regtest", Dust: 1000}, diff --git a/pkg/client-lib/batch-session/batch_session_types.go b/pkg/client-lib/batch-session/batch_session_types.go index eb5467ddd..6dadbfa56 100644 --- a/pkg/client-lib/batch-session/batch_session_types.go +++ b/pkg/client-lib/batch-session/batch_session_types.go @@ -8,7 +8,6 @@ import ( "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" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" "github.com/btcsuite/btcd/btcutil/psbt" ) @@ -123,7 +122,7 @@ type BaseArgs struct { Vtxos []clientlib.Vtxo BoardingUtxos []clientlib.Utxo Outputs []clientlib.Receiver - SignTx batchsessionhandler.SignFn + SignTx clientlib.SignFn } func (a BaseArgs) signingRequired() bool { diff --git a/pkg/client-lib/batch-session/collaborative_exit.go b/pkg/client-lib/batch-session/collaborative_exit.go index abd79ccb0..f704c407a 100644 --- a/pkg/client-lib/batch-session/collaborative_exit.go +++ b/pkg/client-lib/batch-session/collaborative_exit.go @@ -6,7 +6,6 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" 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" ) @@ -18,7 +17,7 @@ type CollaborativeExitArgs struct { Client clientlib.Client FeeEstimator *arkfee.Estimator ServerInfo clientlib.Info - SignTx batchsessionhandler.SignFn + SignTx clientlib.SignFn Vtxos []clientlib.Vtxo Receiver clientlib.Receiver ChangeAddr string diff --git a/pkg/client-lib/batch-session/collaborative_exit_test.go b/pkg/client-lib/batch-session/collaborative_exit_test.go index e73f09162..5d1934693 100644 --- a/pkg/client-lib/batch-session/collaborative_exit_test.go +++ b/pkg/client-lib/batch-session/collaborative_exit_test.go @@ -6,7 +6,6 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" clientlib "github.com/arkade-os/arkd/pkg/client-lib" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" "github.com/stretchr/testify/require" ) @@ -78,7 +77,7 @@ func newTestCollaborativeExitArgs(t *testing.T) CollaborativeExitArgs { Client: mockClient{}, FeeEstimator: feeEstimator, ServerInfo: clientlib.Info{Dust: 1000, Network: "regtest"}, - SignTx: batchsessionhandler.SignFn(mockSignTx), + SignTx: clientlib.SignFn(mockSignTx), Vtxos: []clientlib.Vtxo{{ Outpoint: clientlib.Outpoint{Txid: "deadbeef", VOut: 0}, Amount: 10000, diff --git a/pkg/client-lib/batch-session/handler/default_handler.go b/pkg/client-lib/batch-session/handler/default_handler.go index 58d7d1aa5..b53609bcf 100644 --- a/pkg/client-lib/batch-session/handler/default_handler.go +++ b/pkg/client-lib/batch-session/handler/default_handler.go @@ -29,7 +29,7 @@ import ( type Args struct { Client clientlib.Client ServerInfo clientlib.Info - SignTx SignFn + SignTx clientlib.SignFn IntentId string Vtxos []clientlib.Vtxo diff --git a/pkg/client-lib/batch-session/handler/types.go b/pkg/client-lib/batch-session/handler/types.go index 6f7cf0a69..78f44c25b 100644 --- a/pkg/client-lib/batch-session/handler/types.go +++ b/pkg/client-lib/batch-session/handler/types.go @@ -8,8 +8,6 @@ import ( clientlib "github.com/arkade-os/arkd/pkg/client-lib" ) -type SignFn func(ctx context.Context, tx string) (string, error) - type Handler interface { OnBatchStarted( ctx context.Context, event clientlib.BatchStartedEvent, diff --git a/pkg/client-lib/batch-session/intent_test.go b/pkg/client-lib/batch-session/intent_test.go index 9ecf8f23b..be5236036 100644 --- a/pkg/client-lib/batch-session/intent_test.go +++ b/pkg/client-lib/batch-session/intent_test.go @@ -5,7 +5,6 @@ import ( "testing" clientlib "github.com/arkade-os/arkd/pkg/client-lib" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" "github.com/stretchr/testify/require" ) @@ -119,7 +118,7 @@ func newTestIntentArgs() IntentArgs { Amount: 10000, }}, Outputs: []clientlib.Receiver{{To: "tark1qexample", Amount: 10000}}, - SignTx: batchsessionhandler.SignFn(mockSignTx), + 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 index 9f1c19f3b..d3212e336 100644 --- a/pkg/client-lib/batch-session/redeem_notes.go +++ b/pkg/client-lib/batch-session/redeem_notes.go @@ -6,7 +6,6 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/note" clientlib "github.com/arkade-os/arkd/pkg/client-lib" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" ) // RedeemNotesArgs configures a RedeemNotes call: the Notes to redeem and the @@ -14,7 +13,7 @@ import ( // proof, and Client/ServerInfo are used to talk to the server. type RedeemNotesArgs struct { Client clientlib.Client - SignTx batchsessionhandler.SignFn + SignTx clientlib.SignFn ServerInfo clientlib.Info Notes []string ReceiverAddr string diff --git a/pkg/client-lib/batch-session/redeem_notes_test.go b/pkg/client-lib/batch-session/redeem_notes_test.go index a81cf7b0a..1e767a131 100644 --- a/pkg/client-lib/batch-session/redeem_notes_test.go +++ b/pkg/client-lib/batch-session/redeem_notes_test.go @@ -5,7 +5,6 @@ import ( "testing" clientlib "github.com/arkade-os/arkd/pkg/client-lib" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" "github.com/stretchr/testify/require" ) @@ -57,7 +56,7 @@ func TestRedeemNotes(t *testing.T) { func newTestRedeemNotesArgs() RedeemNotesArgs { return RedeemNotesArgs{ Client: mockClient{}, - SignTx: batchsessionhandler.SignFn(mockSignTx), + SignTx: clientlib.SignFn(mockSignTx), ServerInfo: clientlib.Info{Network: "regtest"}, Notes: []string{"somenote"}, ReceiverAddr: "tark1qexample", diff --git a/pkg/client-lib/batch-session/settle.go b/pkg/client-lib/batch-session/settle.go index b3a698642..8b40ebd9e 100644 --- a/pkg/client-lib/batch-session/settle.go +++ b/pkg/client-lib/batch-session/settle.go @@ -6,7 +6,6 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" clientlib "github.com/arkade-os/arkd/pkg/client-lib" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" ) @@ -18,7 +17,7 @@ import ( type SettleArgs struct { Client clientlib.Client ServerInfo clientlib.Info - SignTx batchsessionhandler.SignFn + SignTx clientlib.SignFn BoardingUtxos []clientlib.Utxo Vtxos []clientlib.Vtxo ReceiverAddr string diff --git a/pkg/client-lib/batch-session/settle_test.go b/pkg/client-lib/batch-session/settle_test.go index bc632d76a..7377f2cb1 100644 --- a/pkg/client-lib/batch-session/settle_test.go +++ b/pkg/client-lib/batch-session/settle_test.go @@ -5,7 +5,6 @@ import ( "testing" clientlib "github.com/arkade-os/arkd/pkg/client-lib" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" "github.com/stretchr/testify/require" ) @@ -82,7 +81,7 @@ func newTestSettleArgs(t *testing.T) SettleArgs { return SettleArgs{ Client: mockClient{}, ServerInfo: clientlib.Info{Dust: 1000, Network: "regtest"}, - SignTx: batchsessionhandler.SignFn(mockSignTx), + SignTx: clientlib.SignFn(mockSignTx), Vtxos: []clientlib.Vtxo{{ Outpoint: clientlib.Outpoint{Txid: "deadbeef", VOut: 0}, Amount: 10000, diff --git a/pkg/client-lib/offchain-tx/args.go b/pkg/client-lib/offchain-tx/args.go index 8d3c8727e..68b593af2 100644 --- a/pkg/client-lib/offchain-tx/args.go +++ b/pkg/client-lib/offchain-tx/args.go @@ -163,7 +163,7 @@ func (a BurnAssetArgs) validate() error { // txs were considered. type FinalizePendingTxsArgs struct { Client clientlib.Client - SignTx SignFn + SignTx clientlib.SignFn Vtxos []clientlib.Vtxo CreatedAfter *time.Time // informational only; caller already filtered Vtxos } @@ -185,7 +185,7 @@ func (a FinalizePendingTxsArgs) validate() error { // and the orchestrators that wrap them. type BaseArgs struct { ServerInfo clientlib.Info // provides Dust, SignerPubKey (hex), CheckpointTapscript (hex) - SignTx SignFn // signs ark tx + checkpoint txs + 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 diff --git a/pkg/client-lib/offchain-tx/pending.go b/pkg/client-lib/offchain-tx/pending.go index 42df2397f..844454ef1 100644 --- a/pkg/client-lib/offchain-tx/pending.go +++ b/pkg/client-lib/offchain-tx/pending.go @@ -4,8 +4,8 @@ import ( "context" "fmt" + clientlib "github.com/arkade-os/arkd/pkg/client-lib" batchsession "github.com/arkade-os/arkd/pkg/client-lib/batch-session" - batchsessionhandler "github.com/arkade-os/arkd/pkg/client-lib/batch-session/handler" log "github.com/sirupsen/logrus" ) @@ -21,7 +21,7 @@ func FinalizePendingTxs( proofTx, message, err := batchsession.BuildAndSignGetPendingTxIntent( ctx, batchsession.IntentArgs{BaseArgs: batchsession.BaseArgs{ Vtxos: args.Vtxos, - SignTx: batchsessionhandler.SignFn(args.SignTx), + SignTx: clientlib.SignFn(args.SignTx), }}, ) if err != nil { diff --git a/pkg/client-lib/offchain-tx/types.go b/pkg/client-lib/offchain-tx/types.go index af99d1580..57d2bf9a9 100644 --- a/pkg/client-lib/offchain-tx/types.go +++ b/pkg/client-lib/offchain-tx/types.go @@ -1,17 +1,11 @@ package offchaintx import ( - "context" - "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" ) -// 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) - // BuildAndSignTxRes is the output of every BuildAndSign...Tx primitive // except BuildAndSignIssuanceTx (which also adds the derived asset IDs). type BuildAndSignTxRes struct { diff --git a/pkg/client-lib/offchain-tx/utils.go b/pkg/client-lib/offchain-tx/utils.go index f8d57877e..f894471b7 100644 --- a/pkg/client-lib/offchain-tx/utils.go +++ b/pkg/client-lib/offchain-tx/utils.go @@ -607,7 +607,7 @@ func createOffchainTx( // 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 SignFn, + ctx context.Context, c clientlib.Client, signTx clientlib.SignFn, signerPubKey *btcec.PublicKey, build *BuildAndSignTxRes, ) (string, string, []string, error) { arkTxid, signedArk, signedCps, err := c.SubmitTx( @@ -640,7 +640,7 @@ func submitAndFinalize( // FinalizeTx on the client, and returns the ark txid plus the finalized // checkpoint txs. func finalizeTx( - ctx context.Context, c clientlib.Client, signTx SignFn, + ctx context.Context, c clientlib.Client, signTx clientlib.SignFn, acceptedTx clientlib.AcceptedOffchainTx, ) (string, []string, error) { finalCheckpoints := make([]string, 0, len(acceptedTx.SignedCheckpointTxs)) diff --git a/pkg/client-lib/types.go b/pkg/client-lib/types.go index 756893862..3645e6fcf 100644 --- a/pkg/client-lib/types.go +++ b/pkg/client-lib/types.go @@ -1,6 +1,7 @@ package clientlib import ( + "context" "encoding/hex" "encoding/json" "fmt" @@ -199,50 +200,6 @@ func (v Vtxo) ParseClosure() ([]byte, *arklib.TaprootMerkleProof, error) { return pkScript, leafProof, nil } -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" @@ -282,26 +239,6 @@ func (t Transaction) String() string { 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", - TxsUpdated: "TXS_UPDATED", - }[e] -} - type Utxo struct { Outpoint Amount uint64 @@ -439,43 +376,6 @@ type ExistingControlAsset struct { func (ExistingControlAsset) isControlAsset() {} -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()) - } - if len(tapscripts) <= 0 { - return nil, nil, fmt.Errorf("%s has no tapscripts", outpoint.String()) - } - - vtxoScript, err := script.ParseVtxoScript(tapscripts) - if err != nil { - return nil, nil, fmt.Errorf("%s has invalid tapscripts: %w", outpoint.String(), err) - } - forfeitScript, err := closure.Script() - if err != nil { - return nil, nil, fmt.Errorf( - "%s has invalid signing closure: %w", outpoint.String(), err, - ) - } - - taprootKey, taprootTree, err := vtxoScript.TapTree() - if err != nil { - return nil, nil, fmt.Errorf("%s has invalid taptree: %w", outpoint.String(), err) - } - - forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) - leafProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) - if err != nil { - return nil, nil, fmt.Errorf( - "%s has invalid signing script: %w", outpoint.String(), err, - ) - } - pkScript, err := script.P2TRScript(taprootKey) - if err != nil { - return nil, nil, fmt.Errorf("%s has invalid tapkey: %w", outpoint.String(), err) - } - - return pkScript, leafProof, nil -} +// 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/utils.go b/pkg/client-lib/utils.go index 4376be24c..b64ad22de 100644 --- a/pkg/client-lib/utils.go +++ b/pkg/client-lib/utils.go @@ -1,8 +1,12 @@ package clientlib import ( + "fmt" + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/script" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" ) func NetworkFromString(net string) arklib.Network { @@ -42,3 +46,44 @@ func ToBitcoinNetwork(net arklib.Network) chaincfg.Params { return chaincfg.MainNetParams } } + +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()) + } + if len(tapscripts) <= 0 { + return nil, nil, fmt.Errorf("%s has no tapscripts", outpoint.String()) + } + + vtxoScript, err := script.ParseVtxoScript(tapscripts) + if err != nil { + return nil, nil, fmt.Errorf("%s has invalid tapscripts: %w", outpoint.String(), err) + } + forfeitScript, err := closure.Script() + if err != nil { + return nil, nil, fmt.Errorf( + "%s has invalid signing closure: %w", outpoint.String(), err, + ) + } + + taprootKey, taprootTree, err := vtxoScript.TapTree() + if err != nil { + return nil, nil, fmt.Errorf("%s has invalid taptree: %w", outpoint.String(), err) + } + + forfeitLeaf := txscript.NewBaseTapLeaf(forfeitScript) + leafProof, err := taprootTree.GetTaprootMerkleProof(forfeitLeaf.TapHash()) + if err != nil { + return nil, nil, fmt.Errorf( + "%s has invalid signing script: %w", outpoint.String(), err, + ) + } + pkScript, err := script.P2TRScript(taprootKey) + if err != nil { + return nil, nil, fmt.Errorf("%s has invalid tapkey: %w", outpoint.String(), err) + } + + return pkScript, leafProof, nil +} From 631314b69fa16c5feebf243c8d472fb9b0d5c1e2 Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Thu, 21 May 2026 13:38:51 +0200 Subject: [PATCH 6/9] Fix --- internal/test/e2e/e2e_test.go | 2 +- pkg/client-lib/indexer.go | 2 +- pkg/client-lib/indexer/client.go | 8 ++++- pkg/client-lib/offchain-tx/args.go | 16 +++++---- pkg/client-lib/offchain-tx/asset.go | 9 ++--- pkg/client-lib/offchain-tx/asset_test.go | 43 ++++++++++++++++-------- pkg/client-lib/offchain-tx/build.go | 17 ++++------ pkg/client-lib/types.go | 3 +- pkg/client-wallet/asset.go | 27 ++++++++++----- 9 files changed, 77 insertions(+), 50 deletions(-) diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index b0f4ac00f..a2199c074 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -5384,7 +5384,7 @@ func TestAsset(t *testing.T) { res2, err := alice.IssueAsset( ctx, 1, - clientlib.ExistingControlAsset{ID: controlAssetId}, + clientlib.ExistingControlAsset{Id: controlAssetId, Amount: 1}, nil, ) require.NoError(t, err) diff --git a/pkg/client-lib/indexer.go b/pkg/client-lib/indexer.go index ed3bd3d2e..2ddda1d69 100644 --- a/pkg/client-lib/indexer.go +++ b/pkg/client-lib/indexer.go @@ -45,7 +45,7 @@ type Indexer interface { type AssetInfo struct { AssetId string - Supply string + Supply uint64 ControlAssetId string Metadata []asset.Metadata } diff --git a/pkg/client-lib/indexer/client.go b/pkg/client-lib/indexer/client.go index f02f157b6..c53fff9e4 100644 --- a/pkg/client-lib/indexer/client.go +++ b/pkg/client-lib/indexer/client.go @@ -3,6 +3,7 @@ package indexer import ( "context" "fmt" + "strconv" "strings" "sync" "time" @@ -625,9 +626,14 @@ func (a *grpcClient) GetAsset(ctx context.Context, assetID string) ( } } + 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 diff --git a/pkg/client-lib/offchain-tx/args.go b/pkg/client-lib/offchain-tx/args.go index 68b593af2..cd77d9a9f 100644 --- a/pkg/client-lib/offchain-tx/args.go +++ b/pkg/client-lib/offchain-tx/args.go @@ -84,23 +84,25 @@ func (a IssueAssetArgs) validate() error { // expected to resolve it from the indexer); Amount is the quantity to mint. type BuildAndSignReissuanceTxArgs struct { BaseArgs - AssetId string - ControlAssetId string - Amount uint64 + Asset clientlib.Asset + ControlAsset clientlib.Asset } func (a BuildAndSignReissuanceTxArgs) validate() error { if err := a.validateBase(); err != nil { return err } - if a.AssetId == "" { + if len(a.Asset.AssetId) <= 0 { return fmt.Errorf("missing asset id") } - if a.ControlAssetId == "" { + 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.Amount == 0 { - return fmt.Errorf("amount must be > 0") + if a.ControlAsset.Amount == 0 { + return fmt.Errorf("missing control assset amount") } return nil } diff --git a/pkg/client-lib/offchain-tx/asset.go b/pkg/client-lib/offchain-tx/asset.go index d9b575c16..7fc4d4edf 100644 --- a/pkg/client-lib/offchain-tx/asset.go +++ b/pkg/client-lib/offchain-tx/asset.go @@ -44,8 +44,8 @@ func IssueAsset( } if existing, ok := args.ControlAsset.(clientlib.ExistingControlAsset); ok { receiver.Assets = append(receiver.Assets, clientlib.Asset{ - AssetId: existing.ID, - Amount: 1, + AssetId: existing.Id, + Amount: existing.Amount, }) } for i, id := range build.IssuedAssets { @@ -115,10 +115,7 @@ func ReissueAsset( receiver := clientlib.Receiver{ To: args.ChangeAddr, Amount: args.ServerInfo.Dust, - Assets: []clientlib.Asset{ - {AssetId: args.ControlAssetId, Amount: 1}, - {AssetId: args.AssetId, Amount: args.Amount}, - }, + Assets: []clientlib.Asset{args.ControlAsset, args.Asset}, } outs := []clientlib.Receiver{receiver} diff --git a/pkg/client-lib/offchain-tx/asset_test.go b/pkg/client-lib/offchain-tx/asset_test.go index 72994fcad..c022fad62 100644 --- a/pkg/client-lib/offchain-tx/asset_test.go +++ b/pkg/client-lib/offchain-tx/asset_test.go @@ -156,18 +156,23 @@ func TestReissueAsset(t *testing.T) { }, { name: "missing asset id", - mutate: func(a *ReissueAssetArgs) { a.AssetId = "" }, + mutate: func(a *ReissueAssetArgs) { a.Asset.AssetId = "" }, errSubstr: "missing asset id", }, { name: "missing control asset id", - mutate: func(a *ReissueAssetArgs) { a.ControlAssetId = "" }, + mutate: func(a *ReissueAssetArgs) { a.ControlAsset.AssetId = "" }, errSubstr: "missing control asset id", }, { - name: "zero amount", - mutate: func(a *ReissueAssetArgs) { a.Amount = 0 }, - errSubstr: "amount must be > 0", + 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", }, } @@ -218,18 +223,23 @@ func TestBuildAndSignReissuanceTx(t *testing.T) { }, { name: "missing asset id", - mutate: func(a *BuildAndSignReissuanceTxArgs) { a.AssetId = "" }, + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.Asset.AssetId = "" }, errSubstr: "missing asset id", }, { name: "missing control asset id", - mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ControlAssetId = "" }, + mutate: func(a *BuildAndSignReissuanceTxArgs) { a.ControlAsset.AssetId = "" }, errSubstr: "missing control asset id", }, { - name: "zero amount", - mutate: func(a *BuildAndSignReissuanceTxArgs) { a.Amount = 0 }, - errSubstr: "amount must be > 0", + 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", }, } @@ -393,7 +403,7 @@ func newTestIssueAssetBuildArgs() BuildAndSignIssuanceTxArgs { func newTestReissueAssetArgs() ReissueAssetArgs { return ReissueAssetArgs{ BuildAndSignReissuanceTxArgs: newTestReissueAssetBuildArgs(), - Client: mockClient{}, + Client: mockClient{}, } } @@ -407,9 +417,14 @@ func newTestReissueAssetBuildArgs() BuildAndSignReissuanceTxArgs { SignTx: mockSignTx, ChangeAddr: "tark1qexample", }, - AssetId: "fakeassetid", - ControlAssetId: "fakecontrolassetid", - Amount: 100, + Asset: clientlib.Asset{ + AssetId: "fakeassetid", + Amount: 100, + }, + ControlAsset: clientlib.Asset{ + AssetId: "fakecontrolassetid", + Amount: 2, + }, } } diff --git a/pkg/client-lib/offchain-tx/build.go b/pkg/client-lib/offchain-tx/build.go index fab43c1e1..b71c3c3c4 100644 --- a/pkg/client-lib/offchain-tx/build.go +++ b/pkg/client-lib/offchain-tx/build.go @@ -104,8 +104,8 @@ func BuildAndSignIssuanceTx( receiverAsset := make([]clientlib.Asset, 0) if existing, ok := args.ControlAsset.(clientlib.ExistingControlAsset); ok { receiverAsset = append(receiverAsset, clientlib.Asset{ - AssetId: existing.ID, - Amount: 1, + AssetId: existing.Id, + Amount: existing.Amount, }) } @@ -155,7 +155,7 @@ func BuildAndSignIssuanceTx( assetGroups = append(assetGroups, *controlAssetGroup) assetRef = &asset.AssetRef{Type: asset.AssetRefByGroup, GroupIndex: 0} case clientlib.ExistingControlAsset: - controlAssetId, err := asset.NewAssetIdFromString(ca.ID) + controlAssetId, err := asset.NewAssetIdFromString(ca.Id) if err != nil { return nil, err } @@ -251,10 +251,7 @@ func BuildAndSignReissuanceTx( receiver := clientlib.Receiver{ To: args.ChangeAddr, Amount: args.ServerInfo.Dust, - Assets: []clientlib.Asset{{ - AssetId: args.ControlAssetId, - Amount: 1, // TODO: should send all denominated amount of the asset vtxo - }}, + Assets: []clientlib.Asset{args.ControlAsset}, } receivers := []clientlib.Receiver{receiver} @@ -281,7 +278,7 @@ func BuildAndSignReissuanceTx( return nil, fmt.Errorf("failed to create asset packet") } - issuedAssetOutput, err := asset.NewAssetOutput(0, args.Amount) + issuedAssetOutput, err := asset.NewAssetOutput(0, args.Asset.Amount) if err != nil { return nil, err } @@ -291,13 +288,13 @@ func BuildAndSignReissuanceTx( if g.AssetId == nil { continue } - if g.AssetId.String() == args.AssetId { + if g.AssetId.String() == args.Asset.AssetId { groupIndex = i } } if groupIndex == -1 { - reissueAssetId, err := asset.NewAssetIdFromString(args.AssetId) + reissueAssetId, err := asset.NewAssetIdFromString(args.Asset.AssetId) if err != nil { return nil, err } diff --git a/pkg/client-lib/types.go b/pkg/client-lib/types.go index 3645e6fcf..2b6dda886 100644 --- a/pkg/client-lib/types.go +++ b/pkg/client-lib/types.go @@ -371,7 +371,8 @@ func (NewControlAsset) isControlAsset() {} // ExistingControlAsset references an existing control asset by its ID. type ExistingControlAsset struct { - ID string + Id string + Amount uint64 } func (ExistingControlAsset) isControlAsset() {} diff --git a/pkg/client-wallet/asset.go b/pkg/client-wallet/asset.go index fd8f6517a..ce7b18187 100644 --- a/pkg/client-wallet/asset.go +++ b/pkg/client-wallet/asset.go @@ -57,11 +57,11 @@ func (w *wallet) ReissueAsset( return nil, err } - controlAssetId, err := w.getControlAssetId(ctx, assetId) + controlAsset, err := w.getControlAsset(ctx, assetId) if err != nil { return nil, fmt.Errorf("failed to get control asset: %w", err) } - if controlAssetId == "" { + if controlAsset == nil { return nil, fmt.Errorf("%s can't be reissued, no control asset", assetId) } @@ -90,9 +90,11 @@ func (w *wallet) ReissueAsset( Vtxos: vtxos, ChangeAddr: offchainAddr.Address, }, - AssetId: assetId, - ControlAssetId: controlAssetId, - Amount: amount, + Asset: clientlib.Asset{ + AssetId: assetId, + Amount: amount, + }, + ControlAsset: *controlAsset, }, Client: w.client, }, opts...) @@ -137,10 +139,17 @@ func (w *wallet) BurnAsset( }, opts...) } -func (w *wallet) getControlAssetId(ctx context.Context, assetId string) (string, error) { - indexerAssetInfo, err := w.indexer.GetAsset(ctx, assetId) +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 "", fmt.Errorf("failed to fetch asset from indexer: %w", err) + return nil, fmt.Errorf("failed to fetch control asset data: %w", err) } - return indexerAssetInfo.ControlAssetId, nil + return &clientlib.Asset{ + AssetId: controlAssetInfo.AssetId, + Amount: controlAssetInfo.Supply, + }, nil } From 01d6c5a403287c4eb279f1a1faf47b09d38ed5ef Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Thu, 21 May 2026 14:17:28 +0200 Subject: [PATCH 7/9] Fixes --- internal/test/e2e/e2e_test.go | 2 +- pkg/ark-cli/main.go | 77 ++++++++++++++++++----------------- pkg/client-wallet/asset.go | 19 ++++++++- 3 files changed, 59 insertions(+), 39 deletions(-) diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index a2199c074..ba5eaa67d 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -5384,7 +5384,7 @@ func TestAsset(t *testing.T) { res2, err := alice.IssueAsset( ctx, 1, - clientlib.ExistingControlAsset{Id: controlAssetId, Amount: 1}, + clientlib.ExistingControlAsset{Id: controlAssetId}, nil, ) require.NoError(t, err) diff --git a/pkg/ark-cli/main.go b/pkg/ark-cli/main.go index ba381674b..01465b10f 100644 --- a/pkg/ark-cli/main.go +++ b/pkg/ark-cli/main.go @@ -25,8 +25,8 @@ const ( ) var ( - Version string - arkSdkClient wallet.Wallet + Version string + client wallet.Wallet ) func main() { @@ -58,7 +58,7 @@ func main() { if err != nil { return fmt.Errorf("error initializing ark sdk client: %v", err) } - arkSdkClient = sdk + client = sdk return nil } @@ -305,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), @@ -316,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 } @@ -344,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 } @@ -359,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 } @@ -375,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 } @@ -408,7 +408,7 @@ 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 } @@ -426,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 } @@ -434,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 } @@ -446,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 } @@ -460,7 +460,7 @@ func redeem(ctx *cli.Context) error { } if force { - _, err := arkSdkClient.Unroll(ctx.Context) + _, err := client.Unroll(ctx.Context) return err } @@ -469,7 +469,7 @@ func redeem(ctx *cli.Context) error { if address != "" { opts = append(opts, wallet.WithReceiver(address)) } - txid, err := arkSdkClient.CompleteUnroll(ctx.Context, opts...) + txid, err := client.CompleteUnroll(ctx.Context, opts...) if err != nil { return err } @@ -481,7 +481,7 @@ func redeem(ctx *cli.Context) error { if amount == 0 { return fmt.Errorf("missing amount") } - res, err := arkSdkClient.CollaborativeExit( + res, err := client.CollaborativeExit( ctx.Context, address, amount, ) if err != nil { @@ -497,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 } @@ -517,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 } @@ -539,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") } @@ -570,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 := clientlib.ControlAsset(clientlib.ExistingControlAsset{ID: controlAssetId}) - if controlAssetAmount > 0 { - controlAssetPolicy = clientlib.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 @@ -614,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 } @@ -642,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 } @@ -656,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 } @@ -752,7 +755,7 @@ func sendOffchain(ctx *cli.Context, receivers []clientlib.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 { @@ -761,7 +764,7 @@ func sendOffchain(ctx *cli.Context, receivers []clientlib.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/client-wallet/asset.go b/pkg/client-wallet/asset.go index ce7b18187..913d78290 100644 --- a/pkg/client-wallet/asset.go +++ b/pkg/client-wallet/asset.go @@ -3,6 +3,7 @@ package wallet import ( "context" "fmt" + "slices" "github.com/arkade-os/arkd/pkg/ark-lib/asset" clientlib "github.com/arkade-os/arkd/pkg/client-lib" @@ -22,6 +23,22 @@ func (w *wallet) IssueAsset( 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 @@ -43,7 +60,7 @@ func (w *wallet) IssueAsset( ChangeAddr: offchainAddr.Address, }, Amount: amount, - ControlAsset: controlAsset, + ControlAsset: ctrlAsset, Metadata: metadata, }, Client: w.client, From a80645e18db5aa422c754638477f8d25ea9cd7e0 Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Fri, 22 May 2026 00:31:27 +0200 Subject: [PATCH 8/9] Finxes after review --- pkg/client-lib/batch-session/batch_session.go | 85 ++++--- .../batch-session/batch_session_opts.go | 2 +- .../batch-session/handler/default_handler.go | 30 ++- .../batch-session/handler/handler.go | 10 +- .../batch-session/handler/handler_opts.go | 7 +- pkg/client-lib/batch-session/handler/utils.go | 13 +- pkg/client-lib/batch-session/redeem_notes.go | 20 +- .../batch-session/redeem_notes_test.go | 10 +- pkg/client-lib/batch-session/settle.go | 5 +- pkg/client-lib/batch-session/settle_test.go | 4 + pkg/client-lib/batch-session/utils.go | 2 +- pkg/client-lib/explorer/service.go | 3 + pkg/client-lib/internal/utils/utils.go | 212 ----------------- pkg/client-lib/offchain-tx/args.go | 2 +- pkg/client-lib/offchain-tx/utils.go | 10 +- pkg/client-lib/service.go | 14 +- pkg/client-lib/types.go | 4 +- pkg/client-lib/utils.go | 216 +++++++++++++++++- pkg/client-lib/utils_test.go | 183 +++++++++++++++ 19 files changed, 542 insertions(+), 290 deletions(-) delete mode 100644 pkg/client-lib/internal/utils/utils.go diff --git a/pkg/client-lib/batch-session/batch_session.go b/pkg/client-lib/batch-session/batch_session.go index b60dc3ebb..3f51b33d3 100644 --- a/pkg/client-lib/batch-session/batch_session.go +++ b/pkg/client-lib/batch-session/batch_session.go @@ -51,8 +51,18 @@ func JoinBatch(ctx context.Context, args JoinBatchArgs, opts ...Option) (*BatchT 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[string]struct{}) + indexedOutputs := make(map[outKey]int) for _, output := range args.Outputs { if output.IsOnchain() { utxoOuts = append(utxoOuts, output) @@ -63,7 +73,10 @@ func JoinBatch(ctx context.Context, args JoinBatchArgs, opts ...Option) (*BatchT if err != nil { return nil, err } - indexedOutputs[hex.EncodeToString(txOut.PkScript)] = struct{}{} + indexedOutputs[outKey{ + script: hex.EncodeToString(txOut.PkScript), + amount: txOut.Value, + }]++ } var leaves []*psbt.Packet @@ -75,39 +88,45 @@ func JoinBatch(ctx context.Context, args JoinBatchArgs, opts ...Option) (*BatchT vtxoOuts := make([]clientlib.Vtxo, 0, len(args.Outputs)) for _, leaf := range leaves { for i, out := range leaf.UnsignedTx.TxOut { - if _, ok := indexedOutputs[hex.EncodeToString(out.PkScript)]; ok { - 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 - } + 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, - }) - 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, + }) } } @@ -184,7 +203,11 @@ func joinBatchWithRetry( }, opts...) if err != nil { if retryCount < maxRetry-1 { - time.Sleep(100 * time.Millisecond) + select { + case <-time.After(100 * time.Millisecond): + case <-ctx.Done(): + return nil, ctx.Err() + } deleteIntent() log.WithError(err).Warn("batch failed, retrying...") } diff --git a/pkg/client-lib/batch-session/batch_session_opts.go b/pkg/client-lib/batch-session/batch_session_opts.go index 02a250a72..c1748b553 100644 --- a/pkg/client-lib/batch-session/batch_session_opts.go +++ b/pkg/client-lib/batch-session/batch_session_opts.go @@ -76,7 +76,7 @@ func WithCancelCh(ch <-chan struct{}) Option { } // WithExpiryThreshold overrides the default vtxo-expiry filter (in seconds): -// vtxos expiring sooner than the threshold are excluded from coin selection. +// 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 diff --git a/pkg/client-lib/batch-session/handler/default_handler.go b/pkg/client-lib/batch-session/handler/default_handler.go index b53609bcf..488d3e096 100644 --- a/pkg/client-lib/batch-session/handler/default_handler.go +++ b/pkg/client-lib/batch-session/handler/default_handler.go @@ -16,7 +16,6 @@ import ( "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/arkade-os/arkd/pkg/client-lib/internal/utils" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" @@ -298,18 +297,32 @@ func (h *defaultHandler) OnTreeNonces( 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 { - return false, res.err + if firstErr == nil { + firstErr = res.err + } + continue } if res.signed { - h.countSigningDone++ - if h.countSigningDone == len(h.SignerSessions) { - return true, nil - } + signedCount++ } } + if firstErr != nil { + return false, firstErr + } + h.countSigningDone += signedCount + if h.countSigningDone == len(h.SignerSessions) { + return true, nil + } return false, nil } @@ -445,7 +458,7 @@ func (h *defaultHandler) validateVtxoTree( } // validate the vtxo tree is well formed - if !utils.IsOnchainOnly(h.Receivers) { + if !isOnchainOnly(h.Receivers) { if err := tree.ValidateVtxoTree( vtxoTree, commitmentPtx, h.forfeitPubkey, h.batchExpiry, ); err != nil { @@ -504,7 +517,8 @@ func (h *defaultHandler) validateVtxoTree( func (h *defaultHandler) createAndSignForfeits( ctx context.Context, vtxosToSign []clientlib.Vtxo, connectorsLeaves []*psbt.Packet, ) ([]string, error) { - parsedForfeitAddr, err := btcutil.DecodeAddress(h.ServerInfo.ForfeitAddress, nil) + network := clientlib.ToBitcoinNetwork(h.network) + parsedForfeitAddr, err := btcutil.DecodeAddress(h.ServerInfo.ForfeitAddress, &network) if err != nil { return nil, err } diff --git a/pkg/client-lib/batch-session/handler/handler.go b/pkg/client-lib/batch-session/handler/handler.go index 2c6fc024e..5927fd31b 100644 --- a/pkg/client-lib/batch-session/handler/handler.go +++ b/pkg/client-lib/batch-session/handler/handler.go @@ -57,12 +57,10 @@ func JoinBatchSession( } if options.replayEventsCh != nil { - go func() { - select { - case options.replayEventsCh <- notify.Event: - default: - } - }() + select { + case options.replayEventsCh <- notify.Event: + default: + } } switch event := notify.Event; event.(type) { diff --git a/pkg/client-lib/batch-session/handler/handler_opts.go b/pkg/client-lib/batch-session/handler/handler_opts.go index 8cd0ac05d..cfc3e68f6 100644 --- a/pkg/client-lib/batch-session/handler/handler_opts.go +++ b/pkg/client-lib/batch-session/handler/handler_opts.go @@ -21,10 +21,9 @@ func WithCancel(cancelCh <-chan struct{}) HandlerOption { } type options struct { - signVtxoTree bool // default: true - replayEventsCh chan<- any // default: nil - cancelCh <-chan struct{} // default: nil - keysByScript map[string]string // default: nil + signVtxoTree bool // default: true + replayEventsCh chan<- any // default: nil + cancelCh <-chan struct{} // default: nil } func newOptions() *options { diff --git a/pkg/client-lib/batch-session/handler/utils.go b/pkg/client-lib/batch-session/handler/utils.go index 9842a5458..5b8b60da0 100644 --- a/pkg/client-lib/batch-session/handler/utils.go +++ b/pkg/client-lib/batch-session/handler/utils.go @@ -10,7 +10,6 @@ import ( "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/arkade-os/arkd/pkg/client-lib/internal/utils" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/wire" @@ -55,7 +54,7 @@ func validateReceivers( ) error { netParams := clientlib.ToBitcoinNetwork(network) for _, receiver := range receivers { - isOnChain, onchainScript, err := utils.ParseBitcoinAddress(receiver.To, netParams) + isOnChain, onchainScript, err := clientlib.ParseBitcoinAddress(receiver.To, netParams) if err != nil { return fmt.Errorf("invalid receiver address: %s err = %s", receiver.To, err) } @@ -202,3 +201,13 @@ func validateAssetGroupOutput( } 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/redeem_notes.go b/pkg/client-lib/batch-session/redeem_notes.go index d3212e336..57cf2403d 100644 --- a/pkg/client-lib/batch-session/redeem_notes.go +++ b/pkg/client-lib/batch-session/redeem_notes.go @@ -2,10 +2,12 @@ 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 @@ -26,15 +28,27 @@ func (a RedeemNotesArgs) validate() error { if a.SignTx == nil { return fmt.Errorf("missing sign tx function") } - if len(a.ServerInfo.Network) <= 0 { - return fmt.Errorf("missing server info") - } 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 } diff --git a/pkg/client-lib/batch-session/redeem_notes_test.go b/pkg/client-lib/batch-session/redeem_notes_test.go index 1e767a131..4efe587ba 100644 --- a/pkg/client-lib/batch-session/redeem_notes_test.go +++ b/pkg/client-lib/batch-session/redeem_notes_test.go @@ -55,9 +55,13 @@ func TestRedeemNotes(t *testing.T) { // corresponding validation error. func newTestRedeemNotesArgs() RedeemNotesArgs { return RedeemNotesArgs{ - Client: mockClient{}, - SignTx: clientlib.SignFn(mockSignTx), - ServerInfo: clientlib.Info{Network: "regtest"}, + 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 index 8b40ebd9e..c11aba2ad 100644 --- a/pkg/client-lib/batch-session/settle.go +++ b/pkg/client-lib/batch-session/settle.go @@ -6,12 +6,11 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" clientlib "github.com/arkade-os/arkd/pkg/client-lib" - "github.com/arkade-os/arkd/pkg/client-lib/internal/utils" ) // SettleArgs configures a Settle call: the BoardingUtxos and Vtxos to settle // into a fresh vtxo at ReceiverAddr. ExpiryThreshold (in seconds) filters out -// vtxos expiring sooner than the threshold. FeeEstimator sizes the change +// 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 { @@ -153,7 +152,7 @@ func selectFunds( outs[0].Amount = totalAmount - totalFeeAmount } - selectedBoardingUtxos, selectedVtxos, changeAmount, err := utils.CoinSelect( + selectedBoardingUtxos, selectedVtxos, changeAmount, err := clientlib.CoinSelect( boardingUtxos, vtxos, outs, dust, feeEstimator, ) if err != nil { diff --git a/pkg/client-lib/batch-session/settle_test.go b/pkg/client-lib/batch-session/settle_test.go index 7377f2cb1..559a52554 100644 --- a/pkg/client-lib/batch-session/settle_test.go +++ b/pkg/client-lib/batch-session/settle_test.go @@ -13,6 +13,10 @@ import ( // 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 { diff --git a/pkg/client-lib/batch-session/utils.go b/pkg/client-lib/batch-session/utils.go index d945cbe3e..25cb2eedf 100644 --- a/pkg/client-lib/batch-session/utils.go +++ b/pkg/client-lib/batch-session/utils.go @@ -72,7 +72,7 @@ func handleBatchEvents( eventsCh, close, err := args.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, clientlib.ErrConnectionClosedByServer } return "", "", -1, nil, nil, err } diff --git a/pkg/client-lib/explorer/service.go b/pkg/client-lib/explorer/service.go index 33f93db32..ac1a68b55 100644 --- a/pkg/client-lib/explorer/service.go +++ b/pkg/client-lib/explorer/service.go @@ -274,6 +274,9 @@ func (e *explorerSvc) IsAddressSubscribed(address string) bool { } func (e *explorerSvc) GetAddressesEvents() <-chan clientlib.OnchainAddressEvent { + if e.listeners == nil { + return nil + } ch := make(chan clientlib.OnchainAddressEvent) e.listeners.add(ch) return ch diff --git a/pkg/client-lib/internal/utils/utils.go b/pkg/client-lib/internal/utils/utils.go deleted file mode 100644 index 3ab840eaa..000000000 --- a/pkg/client-lib/internal/utils/utils.go +++ /dev/null @@ -1,212 +0,0 @@ -package utils - -import ( - "fmt" - "sort" - - "github.com/arkade-os/arkd/pkg/ark-lib/arkfee" - clientlib "github.com/arkade-os/arkd/pkg/client-lib" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/txscript" -) - -// 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 []clientlib.Utxo, vtxos []clientlib.Vtxo, - outputs []clientlib.Receiver, dust uint64, feeEstimator *arkfee.Estimator, -) ([]clientlib.Utxo, []clientlib.Vtxo, uint64, error) { - selected, notSelected := make([]clientlib.Vtxo, 0), make([]clientlib.Vtxo, 0) - selectedBoarding, notSelectedBoarding := make([]clientlib.Utxo, 0), make([]clientlib.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()) - } - } - - // 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].RedeemableAt.Before(boardingUtxos[j].RedeemableAt) - }) - - 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 []clientlib.Vtxo, amount uint64, - assetID string, withoutExpirySorting bool, -) ([]clientlib.Vtxo, uint64, error) { - selected := make([]clientlib.Vtxo, 0) - selectedAmount := uint64(0) - - filteredVtxos := make([]clientlib.Vtxo, 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 (oldest last) - 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 []clientlib.Receiver) bool { - for _, receiver := range receivers { - if !receiver.IsOnchain() { - return false - } - } - - return true -} diff --git a/pkg/client-lib/offchain-tx/args.go b/pkg/client-lib/offchain-tx/args.go index cd77d9a9f..d9a5023e9 100644 --- a/pkg/client-lib/offchain-tx/args.go +++ b/pkg/client-lib/offchain-tx/args.go @@ -102,7 +102,7 @@ func (a BuildAndSignReissuanceTxArgs) validate() error { return fmt.Errorf("missing control asset id") } if a.ControlAsset.Amount == 0 { - return fmt.Errorf("missing control assset amount") + return fmt.Errorf("missing control asset amount") } return nil } diff --git a/pkg/client-lib/offchain-tx/utils.go b/pkg/client-lib/offchain-tx/utils.go index f894471b7..4321e7fda 100644 --- a/pkg/client-lib/offchain-tx/utils.go +++ b/pkg/client-lib/offchain-tx/utils.go @@ -5,7 +5,6 @@ import ( "context" "encoding/hex" "fmt" - "math" arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/arkade-os/arkd/pkg/ark-lib/asset" @@ -13,7 +12,6 @@ import ( "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/arkade-os/arkd/pkg/client-lib/internal/utils" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/psbt" @@ -431,7 +429,7 @@ func createOffchainTx( } } - assetCoins, assetChangeAmount, err := utils.CoinSelectAsset( + assetCoins, assetChangeAmount, err := clientlib.CoinSelectAsset( availableVtxos, amountToSelect, asset.AssetId, false, ) if err != nil { @@ -483,7 +481,7 @@ func createOffchainTx( btcAmountToSelect = int64(args.ServerInfo.Dust) } - _, selectedBtcCoins, changeBtcAmount, err := utils.CoinSelect( + _, 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) @@ -510,7 +508,7 @@ func createOffchainTx( } } } else { - changeAmount = uint64(math.Abs(float64(btcAmountToSelect))) + changeAmount = uint64(-btcAmountToSelect) } var changeReceiver *clientlib.Receiver @@ -535,7 +533,7 @@ func createOffchainTx( } } - _, selectedBtcCoins, changeBtcAmount, err := utils.CoinSelect( + _, selectedBtcCoins, changeBtcAmount, err := clientlib.CoinSelect( nil, availableVtxos, []clientlib.Receiver{{Amount: args.ServerInfo.Dust}}, args.ServerInfo.Dust, nil, ) diff --git a/pkg/client-lib/service.go b/pkg/client-lib/service.go index af7cb8566..54bf0f120 100644 --- a/pkg/client-lib/service.go +++ b/pkg/client-lib/service.go @@ -115,11 +115,13 @@ type ExplorerUtxo struct { func (u ExplorerUtxo) ToUtxo( delay arklib.RelativeLocktime, tapscripts []string, signingClosure script.Closure, ) Utxo { - utxoTime := u.Status.BlockTime - createdAt := time.Unix(utxoTime, 0) - if utxoTime == 0 { - createdAt = time.Time{} - utxoTime = time.Now().Unix() + 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) } return Utxo{ @@ -130,7 +132,7 @@ func (u ExplorerUtxo) ToUtxo( Amount: u.Amount, Script: u.Script, Delay: delay, - RedeemableAt: time.Unix(utxoTime, 0).Add(time.Duration(delay.Seconds()) * time.Second), + RedeemableAt: redeemableAt, CreatedAt: createdAt, Tapscripts: tapscripts, SigningClosure: signingClosure, diff --git a/pkg/client-lib/types.go b/pkg/client-lib/types.go index 2b6dda886..e10823881 100644 --- a/pkg/client-lib/types.go +++ b/pkg/client-lib/types.go @@ -192,7 +192,7 @@ func (v Vtxo) ToArkFeeInput() arkfee.OffchainInput { } func (v Vtxo) ParseClosure() ([]byte, *arklib.TaprootMerkleProof, error) { - pkScript, leafProof, err := parseClosure(v.Outpoint, v.SigningClosure, v.Tapscripts) + pkScript, leafProof, err := ParseClosure(v.Outpoint, v.SigningClosure, v.Tapscripts) if err != nil { return nil, nil, fmt.Errorf("vtxo %w", err) } @@ -269,7 +269,7 @@ func (u Utxo) ToArkFeeInput() arkfee.OnchainInput { } func (u Utxo) ParseClosure() ([]byte, *arklib.TaprootMerkleProof, error) { - pkScript, leafProof, err := parseClosure(u.Outpoint, u.SigningClosure, u.Tapscripts) + pkScript, leafProof, err := ParseClosure(u.Outpoint, u.SigningClosure, u.Tapscripts) if err != nil { return nil, nil, fmt.Errorf("utxo %w", err) } diff --git a/pkg/client-lib/utils.go b/pkg/client-lib/utils.go index b64ad22de..3a08d1d2a 100644 --- a/pkg/client-lib/utils.go +++ b/pkg/client-lib/utils.go @@ -2,13 +2,19 @@ package clientlib import ( "fmt" + "sort" 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/script" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" ) +// 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: @@ -28,6 +34,8 @@ func NetworkFromString(net string) arklib.Network { } } +// 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: @@ -47,7 +55,213 @@ func ToBitcoinNetwork(net arklib.Network) chaincfg.Params { } } -func parseClosure( +// 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) + } + if err != nil { + return nil, nil, 0, err + } + amount += uint64(fees.ToSatoshis()) + } + } + + // Sort vtxos by expiration (oldest last) + sort.SliceStable(vtxos, func(i, j int) bool { + return vtxos[i].ExpiresAt.After(vtxos[j].ExpiresAt) + }) + + sort.SliceStable(boardingUtxos, func(i, j int) bool { + return boardingUtxos[i].RedeemableAt.Before(boardingUtxos[j].RedeemableAt) + }) + + 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 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) + + filteredVtxos := make([]Vtxo, 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 (oldest last) + sort.SliceStable(vtxos, func(i, j int) bool { + return vtxos[i].ExpiresAt.After(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 +} + +// 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 false, nil, nil + } + + onchainScript, err := txscript.PayToAddrScript(btcAddr) + if err != nil { + return false, nil, err + } + return true, onchainScript, nil +} + +// 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 { diff --git a/pkg/client-lib/utils_test.go b/pkg/client-lib/utils_test.go index 943046756..2da27cee5 100644 --- a/pkg/client-lib/utils_test.go +++ b/pkg/client-lib/utils_test.go @@ -2,6 +2,7 @@ package clientlib_test import ( "testing" + "time" arklib "github.com/arkade-os/arkd/pkg/ark-lib" clientlib "github.com/arkade-os/arkd/pkg/client-lib" @@ -51,3 +52,185 @@ func TestToBitcoinNetwork(t *testing.T) { }) } } + +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 +} From 11a702f126c2d7353f76e9da2edd494b35bd74bc Mon Sep 17 00:00:00 2001 From: altafan <18440657+altafan@users.noreply.github.com> Date: Fri, 22 May 2026 01:23:14 +0200 Subject: [PATCH 9/9] Fixes --- pkg/client-lib/offchain-tx/args.go | 11 +++++++++++ pkg/client-wallet/asset.go | 6 +++--- pkg/client-wallet/send.go | 2 +- pkg/client-wallet/unroll.go | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/pkg/client-lib/offchain-tx/args.go b/pkg/client-lib/offchain-tx/args.go index d9a5023e9..12d705aa8 100644 --- a/pkg/client-lib/offchain-tx/args.go +++ b/pkg/client-lib/offchain-tx/args.go @@ -208,6 +208,17 @@ func (a *BaseArgs) validateBase() error { 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) diff --git a/pkg/client-wallet/asset.go b/pkg/client-wallet/asset.go index 913d78290..6ba3de74f 100644 --- a/pkg/client-wallet/asset.go +++ b/pkg/client-wallet/asset.go @@ -18,7 +18,7 @@ func (w *wallet) IssueAsset( return nil, err } - vtxos, err := w.getSpendableVtxos(ctx, nil) + vtxos, err := w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) if err != nil { return nil, err } @@ -82,7 +82,7 @@ func (w *wallet) ReissueAsset( return nil, fmt.Errorf("%s can't be reissued, no control asset", assetId) } - vtxos, err := w.getSpendableVtxos(ctx, nil) + vtxos, err := w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) if err != nil { return nil, err } @@ -124,7 +124,7 @@ func (w *wallet) BurnAsset( return nil, err } - vtxos, err := w.getSpendableVtxos(ctx, nil) + vtxos, err := w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) if err != nil { return nil, err } diff --git a/pkg/client-wallet/send.go b/pkg/client-wallet/send.go index 39c4e560f..126d2b76a 100644 --- a/pkg/client-wallet/send.go +++ b/pkg/client-wallet/send.go @@ -15,7 +15,7 @@ func (w *wallet) SendOffChain( return nil, err } - vtxos, err := w.getSpendableVtxos(ctx, nil) + vtxos, err := w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) if err != nil { return nil, err } diff --git a/pkg/client-wallet/unroll.go b/pkg/client-wallet/unroll.go index e128a02ee..9a6b428e8 100644 --- a/pkg/client-wallet/unroll.go +++ b/pkg/client-wallet/unroll.go @@ -44,7 +44,7 @@ func (w *wallet) Unroll(ctx context.Context, opts ...UnrollOption) ([]UnrollRes, vtxos := o.vtxos if len(vtxos) <= 0 { var err error - vtxos, err = w.getSpendableVtxos(ctx, nil) + vtxos, err = w.getSpendableVtxos(ctx, &getVtxosFilter{excludeRecoverableVtxos: true}) if err != nil { return nil, err }