From f1a7179af983d257b80b127ad4ddd5c43c99b561 Mon Sep 17 00:00:00 2001 From: "Sergey B." Date: Fri, 15 May 2026 14:14:16 +0300 Subject: [PATCH 1/2] fix: ListBatches REST batch_key handling --- cmd/commands/assets.go | 14 ++-- itest/assets_test.go | 109 +++++++++++++++++++++++++++++++ itest/backup_test.go | 4 +- itest/test_list_on_test.go | 4 ++ rpcserver/rpcserver.go | 34 ++++++---- taprpc/mintrpc/mint.pb.gw.go | 36 +++++----- taprpc/mintrpc/mint.swagger.json | 16 ++--- taprpc/mintrpc/mint.yaml | 2 +- 8 files changed, 173 insertions(+), 46 deletions(-) diff --git a/cmd/commands/assets.go b/cmd/commands/assets.go index 84552a06b0..00253b15da 100644 --- a/cmd/commands/assets.go +++ b/cmd/commands/assets.go @@ -698,12 +698,16 @@ func listBatches(ctx *cli.Context) error { } } - resp, err := client.ListBatches(ctxc, &mintrpc.ListBatchRequest{ - Filter: &mintrpc.ListBatchRequest_BatchKey{ - BatchKey: batchKey, - }, + listReq := &mintrpc.ListBatchRequest{ Verbose: ctx.Bool("verbose"), - }) + } + if len(batchKey) > 0 { + listReq.Filter = &mintrpc.ListBatchRequest_BatchKey{ + BatchKey: batchKey, + } + } + + resp, err := client.ListBatches(ctxc, listReq) if err != nil { return fmt.Errorf("unable to list batches: %w", err) } diff --git a/itest/assets_test.go b/itest/assets_test.go index 6603fe64f2..0e1e137c86 100644 --- a/itest/assets_test.go +++ b/itest/assets_test.go @@ -4,9 +4,15 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" + "encoding/hex" + "io" "net/http" + "net/url" + "os" "slices" "strings" + "testing" "time" "github.com/btcsuite/btcd/btcec/v2" @@ -226,6 +232,109 @@ func testMintBatchResume(t *harnessTest) { ) } +// testMintListBatchesRESTBatchKeyEncoding tests that the mint batches REST +// endpoint rejects malformed path batch keys with a client error. +func testMintListBatchesRESTBatchKeyEncoding(t *harnessTest) { + ctx := context.Background() + + BuildMintingBatch(t.t, t.tapd, simpleAssets) + + batchResp, err := t.tapd.ListBatches(ctx, &mintrpc.ListBatchRequest{}) + require.NoError(t.t, err) + require.NotEmpty(t.t, batchResp.Batches) + + batchKey := batchResp.Batches[0].Batch.BatchKey + require.NotEmpty(t.t, batchKey) + macaroonBytes, err := os.ReadFile(t.tapd.macPath) + require.NoError(t.t, err) + + macaroonHex := hex.EncodeToString(macaroonBytes) + + urlPrefix := "https://" + t.tapd.restListenAddr + + "/v1/taproot-assets/assets/mint/batches" + + testCases := []struct { + name string + batchKey string + statusCode []int + }{ + { + name: "valid_hex", + batchKey: hex.EncodeToString(batchKey), + statusCode: []int{http.StatusOK}, + }, + { + name: "invalid_std_base64_padded", + batchKey: base64.StdEncoding.EncodeToString( + batchKey, + ), + statusCode: []int{ + http.StatusBadRequest, + http.StatusNotFound, + http.StatusInternalServerError, + }, + }, + { + name: "invalid_url_base64_padded", + batchKey: base64.URLEncoding.EncodeToString( + batchKey, + ), + statusCode: []int{ + http.StatusBadRequest, + http.StatusInternalServerError, + }, + }, + { + name: "invalid_url_base64_raw", + batchKey: base64.RawURLEncoding.EncodeToString( + batchKey, + ), + statusCode: []int{ + http.StatusBadRequest, + http.StatusInternalServerError, + }, + }, + } + + for _, testCase := range testCases { + t.t.Run(testCase.name, func(tt *testing.T) { + escapedBatchKey := url.PathEscape(testCase.batchKey) + reqURL := urlPrefix + "/" + escapedBatchKey + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + require.NoError(tt, err) + + req.Header.Set("Grpc-Metadata-macaroon", macaroonHex) + + resp, err := client.Do(req) + require.NoError(tt, err) + tt.Cleanup(func() { + _ = resp.Body.Close() + }) + + require.Contains( + tt, testCase.statusCode, resp.StatusCode, + ) + + body, err := io.ReadAll(resp.Body) + require.NoError(tt, err) + + if resp.StatusCode == http.StatusOK { + require.Contains( + tt, string(body), "\"batches\"", + ) + return + } + + if resp.StatusCode == http.StatusNotFound { + require.Contains(tt, string(body), "Not Found") + return + } + + require.Contains(tt, string(body), "invalid batch_key") + }) + } +} + // transferAssetProofs locates and exports the proof files for all given assets // from the source node and imports them into the destination node. func transferAssetProofs(t *harnessTest, src, dst *tapdHarness, diff --git a/itest/backup_test.go b/itest/backup_test.go index dbc9ab2e4a..2dae7839b1 100644 --- a/itest/backup_test.go +++ b/itest/backup_test.go @@ -121,7 +121,7 @@ func testBackupRestoreGenesis(t *harnessTest) { // imported (both anchor outpoints spent in step 7) func testBackupRestoreTransferred(t *harnessTest) { ctxb := context.Background() - ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout*4) + ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout) defer cancel() // === Stage 1: Mint 2 assets on Alice in separate batches === @@ -475,7 +475,7 @@ func assertAssetsMatch(t *harnessTest, expected []*taprpc.Asset, // 5. Verify asset counts and group key presence on both nodes func testBackupRestoreGrouped(t *harnessTest) { ctxb := context.Background() - ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout*4) + ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout) defer cancel() // Mint a grouped asset and an ungrouped asset together. diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index 80febc4b1d..09ec3a3069 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -17,6 +17,10 @@ var allTestCases = []*testCase{ name: "mint batch resume", test: testMintBatchResume, }, + { + name: "mint list batches REST batch key encoding", + test: testMintListBatchesRESTBatchKeyEncoding, + }, { name: "mint batch and transfer", test: testMintBatchAndTransfer, diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 6b195ab3a5..64b08fdd48 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -1032,27 +1032,37 @@ func (r *RPCServer) ListBatches(_ context.Context, err error ) - switch { - case len(req.GetBatchKey()) > 0 && len(req.GetBatchKeyStr()) > 0: - return nil, fmt.Errorf("cannot specify both batch_key and " + - "batch_key_string") + switch filter := req.GetFilter().(type) { + // No filter specified. + case nil: + + case *mintrpc.ListBatchRequest_BatchKey: + // Legacy clients (e.g. tapcli) set this oneof with nil or empty + // bytes to mean "list all"; treat that like an unset filter. + if len(filter.BatchKey) == 0 { + break + } - case len(req.GetBatchKey()) > 0: - batchKey, err = btcec.ParsePubKey(req.GetBatchKey()) + batchKey, err = btcec.ParsePubKey(filter.BatchKey) if err != nil { - return nil, fmt.Errorf("invalid batch key: %w", err) + return nil, fmt.Errorf("invalid batch_key: %w", err) } - case len(req.GetBatchKeyStr()) > 0: - batchKeyBytes, err := hex.DecodeString(req.GetBatchKeyStr()) + case *mintrpc.ListBatchRequest_BatchKeyStr: + // Same as BatchKey: empty means list all for backward + // compatibility with callers that always set the oneof arm. + if filter.BatchKeyStr == "" { + break + } + + batchKeyBytes, err := hex.DecodeString(filter.BatchKeyStr) if err != nil { - return nil, fmt.Errorf("invalid batch key string: %w", - err) + return nil, fmt.Errorf("invalid batch_key: %w", err) } batchKey, err = btcec.ParsePubKey(batchKeyBytes) if err != nil { - return nil, fmt.Errorf("invalid batch key: %w", err) + return nil, fmt.Errorf("invalid batch_key: %w", err) } } diff --git a/taprpc/mintrpc/mint.pb.gw.go b/taprpc/mintrpc/mint.pb.gw.go index 9cca70af22..ea9a8de2fb 100644 --- a/taprpc/mintrpc/mint.pb.gw.go +++ b/taprpc/mintrpc/mint.pb.gw.go @@ -162,7 +162,7 @@ func local_request_Mint_CancelBatch_0(ctx context.Context, marshaler runtime.Mar } var ( - filter_Mint_ListBatches_0 = &utilities.DoubleArray{Encoding: map[string]int{"batch_key": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} + filter_Mint_ListBatches_0 = &utilities.DoubleArray{Encoding: map[string]int{"batch_key_str": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} ) func request_Mint_ListBatches_0(ctx context.Context, marshaler runtime.Marshaler, client MintClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { @@ -176,19 +176,19 @@ func request_Mint_ListBatches_0(ctx context.Context, marshaler runtime.Marshaler _ = err ) - val, ok = pathParams["batch_key"] + val, ok = pathParams["batch_key_str"] if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "batch_key") + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "batch_key_str") } if protoReq.Filter == nil { - protoReq.Filter = &ListBatchRequest_BatchKey{} - } else if _, ok := protoReq.Filter.(*ListBatchRequest_BatchKey); !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "expect type: *ListBatchRequest_BatchKey, but: %t\n", protoReq.Filter) + protoReq.Filter = &ListBatchRequest_BatchKeyStr{} + } else if _, ok := protoReq.Filter.(*ListBatchRequest_BatchKeyStr); !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "expect type: *ListBatchRequest_BatchKeyStr, but: %t\n", protoReq.Filter) } - protoReq.Filter.(*ListBatchRequest_BatchKey).BatchKey, err = runtime.Bytes(val) + protoReq.Filter.(*ListBatchRequest_BatchKeyStr).BatchKeyStr, err = runtime.String(val) if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "batch_key", err) + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "batch_key_str", err) } if err := req.ParseForm(); err != nil { @@ -214,19 +214,19 @@ func local_request_Mint_ListBatches_0(ctx context.Context, marshaler runtime.Mar _ = err ) - val, ok = pathParams["batch_key"] + val, ok = pathParams["batch_key_str"] if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "batch_key") + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "batch_key_str") } if protoReq.Filter == nil { - protoReq.Filter = &ListBatchRequest_BatchKey{} - } else if _, ok := protoReq.Filter.(*ListBatchRequest_BatchKey); !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "expect type: *ListBatchRequest_BatchKey, but: %t\n", protoReq.Filter) + protoReq.Filter = &ListBatchRequest_BatchKeyStr{} + } else if _, ok := protoReq.Filter.(*ListBatchRequest_BatchKeyStr); !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "expect type: *ListBatchRequest_BatchKeyStr, but: %t\n", protoReq.Filter) } - protoReq.Filter.(*ListBatchRequest_BatchKey).BatchKey, err = runtime.Bytes(val) + protoReq.Filter.(*ListBatchRequest_BatchKeyStr).BatchKeyStr, err = runtime.String(val) if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "batch_key", err) + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "batch_key_str", err) } if err := req.ParseForm(); err != nil { @@ -401,7 +401,7 @@ func RegisterMintHandlerServer(ctx context.Context, mux *runtime.ServeMux, serve inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) var err error var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/mintrpc.Mint/ListBatches", runtime.WithHTTPPathPattern("/v1/taproot-assets/assets/mint/batches/{batch_key}")) + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/mintrpc.Mint/ListBatches", runtime.WithHTTPPathPattern("/v1/taproot-assets/assets/mint/batches/{batch_key_str}")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -582,7 +582,7 @@ func RegisterMintHandlerClient(ctx context.Context, mux *runtime.ServeMux, clien inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) var err error var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/mintrpc.Mint/ListBatches", runtime.WithHTTPPathPattern("/v1/taproot-assets/assets/mint/batches/{batch_key}")) + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/mintrpc.Mint/ListBatches", runtime.WithHTTPPathPattern("/v1/taproot-assets/assets/mint/batches/{batch_key_str}")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -634,7 +634,7 @@ var ( pattern_Mint_CancelBatch_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4}, []string{"v1", "taproot-assets", "assets", "mint", "cancel"}, "")) - pattern_Mint_ListBatches_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"v1", "taproot-assets", "assets", "mint", "batches", "batch_key"}, "")) + pattern_Mint_ListBatches_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"v1", "taproot-assets", "assets", "mint", "batches", "batch_key_str"}, "")) pattern_Mint_SubscribeMintEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v1", "taproot-assets", "events", "asset-mint"}, "")) ) diff --git a/taprpc/mintrpc/mint.swagger.json b/taprpc/mintrpc/mint.swagger.json index 6d76db5ed0..b10e668da8 100644 --- a/taprpc/mintrpc/mint.swagger.json +++ b/taprpc/mintrpc/mint.swagger.json @@ -49,7 +49,7 @@ ] } }, - "/v1/taproot-assets/assets/mint/batches/{batch_key}": { + "/v1/taproot-assets/assets/mint/batches/{batch_key_str}": { "get": { "summary": "tapcli: `assets mint batches`\nListBatches lists the set of batches submitted to the daemon, including\npending and cancelled batches.", "operationId": "Mint_ListBatches", @@ -69,19 +69,19 @@ }, "parameters": [ { - "name": "batch_key", - "description": "The optional batch key of the batch to list, specified as raw bytes\n(gRPC only).", + "name": "batch_key_str", + "description": "The optional batch key of the batch to list, specified as a hex\nencoded string (use this for REST).", "in": "path", "required": true, - "type": "string", - "format": "byte" + "type": "string" }, { - "name": "batch_key_str", - "description": "The optional batch key of the batch to list, specified as a hex\nencoded string (use this for REST).", + "name": "batch_key", + "description": "The optional batch key of the batch to list, specified as raw bytes\n(gRPC only).", "in": "query", "required": false, - "type": "string" + "type": "string", + "format": "byte" }, { "name": "verbose", diff --git a/taprpc/mintrpc/mint.yaml b/taprpc/mintrpc/mint.yaml index 0ec2db4a97..4c96b77896 100644 --- a/taprpc/mintrpc/mint.yaml +++ b/taprpc/mintrpc/mint.yaml @@ -24,7 +24,7 @@ http: body: "*" - selector: mintrpc.Mint.ListBatches - get: "/v1/taproot-assets/assets/mint/batches/{batch_key}" + get: "/v1/taproot-assets/assets/mint/batches/{batch_key_str}" - selector: mintrpc.Mint.SubscribeMintEvents post: "/v1/taproot-assets/events/asset-mint" From c7e8a6c76690a564aecdf4bd60434498c33270f6 Mon Sep 17 00:00:00 2001 From: "Sergey B." Date: Fri, 15 May 2026 14:16:55 +0300 Subject: [PATCH 2/2] docs: updating release notes --- docs/release-notes/release-notes-0.8.0.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/release-notes-0.8.0.md b/docs/release-notes/release-notes-0.8.0.md index f34ee0bc20..d46c3da6e9 100644 --- a/docs/release-notes/release-notes-0.8.0.md +++ b/docs/release-notes/release-notes-0.8.0.md @@ -111,6 +111,12 @@ fixes several bugs that could leave minting batches in an inconsistent state in the case of a funding, database write, or restart error. +* [PR#2137](https://github.com/lightninglabs/taproot-assets/pull/2137) + fixes `ListBatches` REST `batch_key` handling so malformed path values + no longer silently return empty results. The endpoint now uses + `batch_key_str` in the REST path and returns `InvalidArgument` for + malformed or empty batch keys. + # New Features ## Functional Enhancements