diff --git a/adapters.go b/adapters.go new file mode 100644 index 00000000..b712e314 --- /dev/null +++ b/adapters.go @@ -0,0 +1,236 @@ +package main + +import ( + "encoding/base64" + + "github.com/mr-tron/base58" +) + +func ptrToUint64(v uint64) *uint64 { + return &v +} + +// byteSliceAsIntegerSlice converts a byte slice to an integer slice. +func byteSliceAsIntegerSlice(b []byte) []uint64 { + var ret []uint64 + for i := 0; i < len(b); i++ { + ret = append(ret, uint64(b[i])) + } + return ret +} + +// adaptTransactionMetaToExpectedOutput adapts the transaction meta to the expected output +// as per what solana RPC server returns. +func adaptTransactionMetaToExpectedOutput(m map[string]any) map[string]any { + meta, ok := m["meta"].(map[string]any) + if !ok { + return m + } + { + if _, ok := meta["err"]; ok { + meta["err"], _ = parseTransactionError(meta["err"]) + } else { + meta["err"] = nil + } + } + { + if _, ok := meta["loadedAddresses"]; !ok { + meta["loadedAddresses"] = map[string]any{ + "readonly": []any{}, + "writable": []any{}, + } + } + { + // if has loadedReadonlyAddresses and is []string, then use that for loadedAddresses.readonly + if loadedReadonlyAddresses, ok := meta["loadedReadonlyAddresses"].([]any); ok { + // the address list is base64 encoded; decode and encode to base58 + for i, addr := range loadedReadonlyAddresses { + addrStr, ok := addr.(string) + if ok { + decoded, err := base64.StdEncoding.DecodeString(addrStr) + if err == nil { + loadedReadonlyAddresses[i] = base58.Encode(decoded) + } + } + } + meta["loadedAddresses"].(map[string]any)["readonly"] = loadedReadonlyAddresses + delete(meta, "loadedReadonlyAddresses") + } + // if has loadedWritableAddresses and is []string, then use that for loadedAddresses.writable + if loadedWritableAddresses, ok := meta["loadedWritableAddresses"].([]any); ok { + // the address list is base64 encoded; decode and encode to base58 + for i, addr := range loadedWritableAddresses { + addrStr, ok := addr.(string) + if ok { + decoded, err := base64.StdEncoding.DecodeString(addrStr) + if err == nil { + loadedWritableAddresses[i] = base58.Encode(decoded) + } + } + } + meta["loadedAddresses"].(map[string]any)["writable"] = loadedWritableAddresses + delete(meta, "loadedWritableAddresses") + } + // remove loadedReadonlyAddresses and loadedWritableAddresses + } + if preTokenBalances, ok := meta["preTokenBalances"]; !ok { + meta["preTokenBalances"] = []any{} + } else { + // in preTokenBalances.[].uiTokenAmount.decimals if not present, set to 0 + preTokenBalances, ok := preTokenBalances.([]any) + if ok { + for _, preTokenBalanceAny := range preTokenBalances { + preTokenBalance, ok := preTokenBalanceAny.(map[string]any) + if ok { + uiTokenAmountAny, ok := preTokenBalance["uiTokenAmount"] + if ok { + uiTokenAmount, ok := uiTokenAmountAny.(map[string]any) + if ok { + _, ok := uiTokenAmount["decimals"] + if !ok { + uiTokenAmount["decimals"] = 0 + } + _, ok = uiTokenAmount["uiAmount"] + if !ok { + uiTokenAmount["uiAmount"] = nil + } + } + } + } + } + } + } + if postTokenBalances, ok := meta["postTokenBalances"]; !ok { + meta["postTokenBalances"] = []any{} + } else { + // in postTokenBalances.[].uiTokenAmount.decimals if not present, set to 0 + postTokenBalances, ok := postTokenBalances.([]any) + if ok { + for _, postTokenBalanceAny := range postTokenBalances { + postTokenBalance, ok := postTokenBalanceAny.(map[string]any) + if ok { + uiTokenAmountAny, ok := postTokenBalance["uiTokenAmount"] + if ok { + uiTokenAmount, ok := uiTokenAmountAny.(map[string]any) + if ok { + _, ok := uiTokenAmount["decimals"] + if !ok { + uiTokenAmount["decimals"] = 0 + } + } + } + } + } + } + } + + delete(meta, "returnDataNone") + + if _, ok := meta["rewards"]; !ok { + meta["rewards"] = []any{} + } + if _, ok := meta["status"]; !ok { + eee, ok := meta["err"] + if ok { + if eee == nil { + meta["status"] = map[string]any{ + "Ok": nil, + } + } else { + meta["status"] = map[string]any{ + "Err": eee, + } + } + } + } + { + // TODO: is this correct? + // if doesn't have err, but has status and it is empty, then set status to Ok + if _, ok := meta["err"]; !ok || meta["err"] == nil { + if status, ok := meta["status"].(map[string]any); ok { + if len(status) == 0 { + meta["status"] = map[string]any{ + "Ok": nil, + } + } + } + } + } + } + { + if returnData, ok := meta["returnData"].(map[string]any); ok { + if data, ok := returnData["data"].(string); ok { + returnData["data"] = []any{data, "base64"} + } + + if programId, ok := returnData["programId"].(string); ok { + decoded, err := base64.StdEncoding.DecodeString(programId) + if err == nil { + returnData["programId"] = base58.Encode(decoded) + } + } + } + } + { + innerInstructionsAny, ok := meta["innerInstructions"] + if !ok { + meta["innerInstructions"] = []any{} + return m + } + innerInstructions, ok := innerInstructionsAny.([]any) + if !ok { + return m + } + for i, innerInstructionAny := range innerInstructions { + innerInstruction, ok := innerInstructionAny.(map[string]any) + if !ok { + continue + } + // If doesn't have `index`, then set it to 0 + if _, ok := innerInstruction["index"]; !ok { + innerInstruction["index"] = 0 + } + instructionsAny, ok := innerInstruction["instructions"] + if !ok { + continue + } + instructions, ok := instructionsAny.([]any) + if !ok { + continue + } + for _, instructionAny := range instructions { + instruction, ok := instructionAny.(map[string]any) + if !ok { + continue + } + { + if accounts, ok := instruction["accounts"]; ok { + // as string + accountsStr, ok := accounts.(string) + if ok { + decoded, err := base64.StdEncoding.DecodeString(accountsStr) + if err == nil { + instruction["accounts"] = byteSliceAsIntegerSlice(decoded) + } + } + } else { + instruction["accounts"] = []any{} + } + if data, ok := instruction["data"]; ok { + // as string + dataStr, ok := data.(string) + if ok { + decoded, err := base64.StdEncoding.DecodeString(dataStr) + if err == nil { + // TODO: the data in the `innerInstructions` is always base58 encoded (even if the transaction is base64 encoded) + instruction["data"] = base58.Encode(decoded) + } + } + } + } + } + meta["innerInstructions"].([]any)[i] = innerInstruction + } + } + return m +} diff --git a/cmd-rpc-server-car-getSignaturesForAddress.go b/cmd-rpc-server-car-getSignaturesForAddress.go index dce6f5c9..87f18deb 100644 --- a/cmd-rpc-server-car-getSignaturesForAddress.go +++ b/cmd-rpc-server-car-getSignaturesForAddress.go @@ -225,7 +225,7 @@ func (ser *deprecatedRPCServer) handleGetSignaturesForAddress(ctx context.Contex } // reply with the data - err = conn.ReplyNoMod( + err = conn.ReplyRaw( ctx, req.ID, response, diff --git a/cmd-rpc-server-car-getTransaction.go b/cmd-rpc-server-car-getTransaction.go index 29ec8340..36f65953 100644 --- a/cmd-rpc-server-car-getTransaction.go +++ b/cmd-rpc-server-car-getTransaction.go @@ -2,19 +2,13 @@ package main import ( "context" - "encoding/base64" "errors" - "github.com/mr-tron/base58" "github.com/rpcpool/yellowstone-faithful/compactindex36" "github.com/sourcegraph/jsonrpc2" "k8s.io/klog/v2" ) -func ptrToUint64(v uint64) *uint64 { - return &v -} - func (ser *deprecatedRPCServer) handleGetTransaction(ctx context.Context, conn *requestContext, req *jsonrpc2.Request) { params, err := parseGetTransactionRequest(req.Params) if err != nil { @@ -34,7 +28,7 @@ func (ser *deprecatedRPCServer) handleGetTransaction(ctx context.Context, conn * transactionNode, err := ser.GetTransaction(WithSubrapghPrefetch(ctx, true), sig) if err != nil { if errors.Is(err, compactindex36.ErrNotFound) { - conn.ReplyNoMod( + conn.ReplyRaw( ctx, req.ID, nil, // NOTE: solana just returns null here in case of transaction not found @@ -128,209 +122,3 @@ func (ser *deprecatedRPCServer) handleGetTransaction(ctx context.Context, conn * klog.Errorf("failed to reply: %v", err) } } - -// byteSliceAsIntegerSlice converts a byte slice to an integer slice. -func byteSliceAsIntegerSlice(b []byte) []uint64 { - var ret []uint64 - for i := 0; i < len(b); i++ { - ret = append(ret, uint64(b[i])) - } - return ret -} - -// adaptTransactionMetaToExpectedOutput adapts the transaction meta to the expected output -// as per what solana RPC server returns. -func adaptTransactionMetaToExpectedOutput(m map[string]any) map[string]any { - meta, ok := m["meta"].(map[string]any) - if !ok { - return m - } - { - if _, ok := meta["err"]; ok { - meta["err"], _ = parseTransactionError(meta["err"]) - } else { - meta["err"] = nil - } - } - { - if _, ok := meta["loadedAddresses"]; !ok { - meta["loadedAddresses"] = map[string]any{ - "readonly": []any{}, - "writable": []any{}, - } - } - { - // if has loadedReadonlyAddresses and is []string, then use that for loadedAddresses.readonly - if loadedReadonlyAddresses, ok := meta["loadedReadonlyAddresses"].([]any); ok { - // the address list is base64 encoded; decode and encode to base58 - for i, addr := range loadedReadonlyAddresses { - addrStr, ok := addr.(string) - if ok { - decoded, err := base64.StdEncoding.DecodeString(addrStr) - if err == nil { - loadedReadonlyAddresses[i] = base58.Encode(decoded) - } - } - } - meta["loadedAddresses"].(map[string]any)["readonly"] = loadedReadonlyAddresses - delete(meta, "loadedReadonlyAddresses") - } - // if has loadedWritableAddresses and is []string, then use that for loadedAddresses.writable - if loadedWritableAddresses, ok := meta["loadedWritableAddresses"].([]any); ok { - // the address list is base64 encoded; decode and encode to base58 - for i, addr := range loadedWritableAddresses { - addrStr, ok := addr.(string) - if ok { - decoded, err := base64.StdEncoding.DecodeString(addrStr) - if err == nil { - loadedWritableAddresses[i] = base58.Encode(decoded) - } - } - } - meta["loadedAddresses"].(map[string]any)["writable"] = loadedWritableAddresses - delete(meta, "loadedWritableAddresses") - } - // remove loadedReadonlyAddresses and loadedWritableAddresses - } - if preTokenBalances, ok := meta["preTokenBalances"]; !ok { - meta["preTokenBalances"] = []any{} - } else { - // in preTokenBalances.[].uiTokenAmount.decimals if not present, set to 0 - preTokenBalances, ok := preTokenBalances.([]any) - if ok { - for _, preTokenBalanceAny := range preTokenBalances { - preTokenBalance, ok := preTokenBalanceAny.(map[string]any) - if ok { - uiTokenAmountAny, ok := preTokenBalance["uiTokenAmount"] - if ok { - uiTokenAmount, ok := uiTokenAmountAny.(map[string]any) - if ok { - _, ok := uiTokenAmount["decimals"] - if !ok { - uiTokenAmount["decimals"] = 0 - } - } - } - } - } - } - } - if postTokenBalances, ok := meta["postTokenBalances"]; !ok { - meta["postTokenBalances"] = []any{} - } else { - // in postTokenBalances.[].uiTokenAmount.decimals if not present, set to 0 - postTokenBalances, ok := postTokenBalances.([]any) - if ok { - for _, postTokenBalanceAny := range postTokenBalances { - postTokenBalance, ok := postTokenBalanceAny.(map[string]any) - if ok { - uiTokenAmountAny, ok := postTokenBalance["uiTokenAmount"] - if ok { - uiTokenAmount, ok := uiTokenAmountAny.(map[string]any) - if ok { - _, ok := uiTokenAmount["decimals"] - if !ok { - uiTokenAmount["decimals"] = 0 - } - } - } - } - } - } - } - if _, ok := meta["returnDataNone"]; !ok { - // TODO: what is this? - meta["returnDataNone"] = nil - } - if _, ok := meta["rewards"]; !ok { - meta["rewards"] = []any{} - } - if _, ok := meta["status"]; !ok { - eee, ok := meta["err"] - if ok { - if eee == nil { - meta["status"] = map[string]any{ - "Ok": nil, - } - } else { - meta["status"] = map[string]any{ - "Err": eee, - } - } - } - } - { - // TODO: is this correct? - // if doesn't have err, but has status and it is empty, then set status to Ok - if _, ok := meta["err"]; !ok || meta["err"] == nil { - if status, ok := meta["status"].(map[string]any); ok { - if len(status) == 0 { - meta["status"] = map[string]any{ - "Ok": nil, - } - } - } - } - } - } - { - innerInstructionsAny, ok := meta["innerInstructions"] - if !ok { - meta["innerInstructions"] = []any{} - return m - } - innerInstructions, ok := innerInstructionsAny.([]any) - if !ok { - return m - } - for i, innerInstructionAny := range innerInstructions { - innerInstruction, ok := innerInstructionAny.(map[string]any) - if !ok { - continue - } - // If doesn't have `index`, then set it to 0 - if _, ok := innerInstruction["index"]; !ok { - innerInstruction["index"] = 0 - } - instructionsAny, ok := innerInstruction["instructions"] - if !ok { - continue - } - instructions, ok := instructionsAny.([]any) - if !ok { - continue - } - for _, instructionAny := range instructions { - instruction, ok := instructionAny.(map[string]any) - if !ok { - continue - } - { - if accounts, ok := instruction["accounts"]; ok { - // as string - accountsStr, ok := accounts.(string) - if ok { - decoded, err := base64.StdEncoding.DecodeString(accountsStr) - if err == nil { - instruction["accounts"] = byteSliceAsIntegerSlice(decoded) - } - } - } - if data, ok := instruction["data"]; ok { - // as string - dataStr, ok := data.(string) - if ok { - decoded, err := base64.StdEncoding.DecodeString(dataStr) - if err == nil { - // TODO: the data in the `innerInstructions` is always base58 encoded (even if the transaction is base64 encoded) - instruction["data"] = base58.Encode(decoded) - } - } - } - } - } - meta["innerInstructions"].([]any)[i] = innerInstruction - } - } - return m -} diff --git a/cmd-rpc.go b/cmd-rpc.go index dd6eca6d..117e692a 100644 --- a/cmd-rpc.go +++ b/cmd-rpc.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "sort" "github.com/davecgh/go-spew/spew" @@ -23,6 +24,7 @@ func newCmd_rpc() *cli.Command { var excludePatterns cli.StringSlice var watch bool var pathForProxyForUnknownRpcMethods string + var epochSearchConcurrency int return &cli.Command{ Name: "rpc", Description: "Provide multiple epoch config files, and start a Solana JSON RPC that exposes getTransaction, getBlock, and (optionally) getSignaturesForAddress", @@ -79,6 +81,12 @@ func newCmd_rpc() *cli.Command { Value: "", Destination: &pathForProxyForUnknownRpcMethods, }, + &cli.IntFlag{ + Name: "epoch-search-concurrency", + Usage: "How many epochs to search in parallel when searching for a signature", + Value: runtime.NumCPU(), + Destination: &epochSearchConcurrency, + }, ), Action: func(c *cli.Context) error { src := c.Args().Slice() @@ -122,8 +130,9 @@ func newCmd_rpc() *cli.Command { } multi := NewMultiEpoch(&Options{ - GsfaOnlySignatures: gsfaOnlySignatures, - PathToSigToEpoch: sigToEpochIndexDir, + GsfaOnlySignatures: gsfaOnlySignatures, + PathToSigToEpoch: sigToEpochIndexDir, + EpochSearchConcurrency: epochSearchConcurrency, }) for _, epoch := range epochs { diff --git a/first.go b/first.go new file mode 100644 index 00000000..0b6e5381 --- /dev/null +++ b/first.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "errors" + "sync" + + "golang.org/x/sync/errgroup" +) + +// FirstResponse is a helper to get the first non-null result or error from a set of goroutines. +type FirstResponse struct { + result chan any + wg *errgroup.Group + waitWg chan struct{} + resultOnce sync.Once + ctx context.Context +} + +func NewFirstResponse(ctx context.Context, concurrency int) *FirstResponse { + fr := &FirstResponse{ + result: make(chan any), + waitWg: make(chan struct{}), + } + fr.wg, ctx = errgroup.WithContext(ctx) + if concurrency > 0 { + fr.wg.SetLimit(concurrency) + } + fr.ctx = ctx + return fr +} + +// Spawn spawns a goroutine that executes the given function. +func (w *FirstResponse) Spawn(f func() (any, error)) { + w.wg.Go(func() error { + result, err := f() + if err != nil { + w.send(err) + return errGotFirstResult // stop the errgroup + } else { + if result != nil { + w.send(result) + return errGotFirstResult // stop the errgroup + } + } + return nil + }) +} + +var errGotFirstResult = errors.New("got first result") + +// send sends the result to the channel, but only once. +// If the result is already sent, it does nothing. +// The result can be something, or an error. +func (w *FirstResponse) send(result any) { + w.resultOnce.Do(func() { + w.result <- result + close(w.result) + }) +} + +// Wait waits for all goroutines to finish, and returns the first non-null result or error. +func (w *FirstResponse) Wait() any { + go func() { + w.wg.Wait() + w.waitWg <- struct{}{} + }() + + select { + case result := <-w.result: + return result + case <-w.waitWg: + return nil + } +} diff --git a/multiepoch-getSignaturesForAddress.go b/multiepoch-getSignaturesForAddress.go index 95f3b6c6..d4126a15 100644 --- a/multiepoch-getSignaturesForAddress.go +++ b/multiepoch-getSignaturesForAddress.go @@ -207,7 +207,7 @@ func (multi *MultiEpoch) handleGetSignaturesForAddress(ctx context.Context, conn } // reply with the data - err = conn.ReplyNoMod( + err = conn.ReplyRaw( ctx, req.ID, response, diff --git a/multiepoch-getTransaction.go b/multiepoch-getTransaction.go index 3296fe2e..4e9f1b95 100644 --- a/multiepoch-getTransaction.go +++ b/multiepoch-getTransaction.go @@ -4,19 +4,76 @@ import ( "context" "errors" "fmt" + "sort" + "time" + "github.com/gagliardetto/solana-go" "github.com/rpcpool/yellowstone-faithful/compactindex36" - sigtoepoch "github.com/rpcpool/yellowstone-faithful/sig-to-epoch" "github.com/sourcegraph/jsonrpc2" + "k8s.io/klog/v2" ) -func (ser *MultiEpoch) handleGetTransaction(ctx context.Context, conn *requestContext, req *jsonrpc2.Request) (*jsonrpc2.Error, error) { - if ser.sigToEpoch == nil && ser.CountEpochs() > 1 { - // The sig-to-epoch index is required when there's more than one epoch. +func (multi *MultiEpoch) findEpochNumberFromSignature(ctx context.Context, sig solana.Signature) (uint64, error) { + // FLOW: + // - if one epoch, just return that epoch + // - if multiple epochs, use sigToEpoch to find the epoch number + // - if sigToEpoch is not available, linear search through all epochs + + if epochs := multi.GetEpochNumbers(); len(epochs) == 1 { + return epochs[0], nil + } + + if multi.sigToEpoch != nil { + epochNumber, err := multi.sigToEpoch.Get(ctx, sig) + if err != nil { + return 0, fmt.Errorf("failed to get epoch for signature %s: %v", sig, err) + } + return uint64(epochNumber), nil + } + + // Linear search: + numbers := multi.GetEpochNumbers() + // sort from highest to lowest: + sort.Slice(numbers, func(i, j int) bool { + return numbers[i] > numbers[j] + }) + // Search all epochs in parallel: + wg := NewFirstResponse(ctx, multi.options.EpochSearchConcurrency) + for i := range numbers { + epochNumber := numbers[i] + wg.Spawn(func() (any, error) { + epoch, err := multi.GetEpoch(epochNumber) + if err != nil { + return nil, fmt.Errorf("failed to get epoch %d: %v", epochNumber, err) + } + if _, err := epoch.FindCidFromSignature(ctx, sig); err == nil { + return epochNumber, nil + } + // Not found in this epoch. + return nil, nil + }) + } + switch result := wg.Wait().(type) { + case nil: + // All epochs were searched, but the signature was not found. + return 0, ErrNotFound + case error: + // An error occurred while searching one of the epochs. + return 0, result + case uint64: + // The signature was found in one of the epochs. + return result, nil + default: + return 0, fmt.Errorf("unexpected result: (%T) %v", result, result) + } +} + +func (multi *MultiEpoch) handleGetTransaction(ctx context.Context, conn *requestContext, req *jsonrpc2.Request) (*jsonrpc2.Error, error) { + if multi.CountEpochs() == 0 { return &jsonrpc2.Error{ Code: jsonrpc2.CodeInternalError, - Message: "getTransaction method is not enabled", - }, fmt.Errorf("the sig-to-epoch index was not provided") + Message: "no epochs available", + }, fmt.Errorf("no epochs available") } params, err := parseGetTransactionRequest(req.Params) @@ -29,35 +86,28 @@ func (ser *MultiEpoch) handleGetTransaction(ctx context.Context, conn *requestCo sig := params.Signature - var epochHandler *Epoch - if ser.sigToEpoch != nil { - epochNumber, err := ser.sigToEpoch.Get(ctx, sig) - if err != nil { - if sigtoepoch.IsNotFound(err) { - return nil, fmt.Errorf("not found epoch for signature %s", sig) - } - return &jsonrpc2.Error{ - Code: jsonrpc2.CodeInternalError, - Message: "Internal error", - }, fmt.Errorf("failed to get epoch for signature %s: %v", sig, err) - } - - epochHandler, err = ser.GetEpoch(uint64(epochNumber)) - if err != nil { + startedEpochLookupAt := time.Now() + epochNumber, err := multi.findEpochNumberFromSignature(ctx, sig) + if err != nil { + if errors.Is(err, ErrNotFound) { return &jsonrpc2.Error{ Code: CodeNotFound, Message: fmt.Sprintf("Epoch %d is not available from this RPC", epochNumber), - }, fmt.Errorf("failed to get handler for epoch %d: %w", epochNumber, err) - } - } else { - // When there's only one epoch, we can just use the first available epoch: - epochHandler, err = ser.GetFirstAvailableEpoch() - if err != nil { - return &jsonrpc2.Error{ - Code: CodeNotFound, - Message: fmt.Sprintf("Epoch %d is not available from this RPC", 0), - }, fmt.Errorf("failed to get handler for epoch %d: %w", 0, err) + }, fmt.Errorf("failed to find epoch number from signature %s: %v", sig, err) } + return &jsonrpc2.Error{ + Code: jsonrpc2.CodeInternalError, + Message: "Internal error", + }, fmt.Errorf("failed to get epoch for signature %s: %v", sig, err) + } + klog.Infof("Found signature %s in epoch %d in %s", sig, epochNumber, time.Since(startedEpochLookupAt)) + + epochHandler, err := multi.GetEpoch(uint64(epochNumber)) + if err != nil { + return &jsonrpc2.Error{ + Code: CodeNotFound, + Message: fmt.Sprintf("Epoch %d is not available from this RPC", epochNumber), + }, fmt.Errorf("failed to get handler for epoch %d: %w", epochNumber, err) } transactionNode, err := epochHandler.GetTransaction(WithSubrapghPrefetch(ctx, true), sig) diff --git a/multiepoch.go b/multiepoch.go index 56414f43..2bed13cc 100644 --- a/multiepoch.go +++ b/multiepoch.go @@ -20,8 +20,9 @@ import ( ) type Options struct { - GsfaOnlySignatures bool - PathToSigToEpoch string // path to the sig-to-epoch index directory + GsfaOnlySignatures bool + PathToSigToEpoch string // path to the sig-to-epoch index directory + EpochSearchConcurrency int } type MultiEpoch struct { @@ -149,6 +150,14 @@ func (m *MultiEpoch) GetFirstAvailableEpoch() (*Epoch, error) { return nil, fmt.Errorf("no epochs available") } +func (m *MultiEpoch) GetFirstAvailableEpochNumber() (uint64, error) { + numbers := m.GetEpochNumbers() + if len(numbers) > 0 { + return numbers[0], nil + } + return 0, fmt.Errorf("no epochs available") +} + type ListenerConfig struct { ProxyConfig *ProxyConfig } @@ -353,7 +362,7 @@ func newMultiEpochHandler(handler *MultiEpoch, lsConf *ListenerConfig) func(ctx versionInfo[k] = v } - err := rqCtx.ReplyNoMod( + err := rqCtx.ReplyRaw( c, rpcRequest.ID, versionInfo, diff --git a/request-response.go b/request-response.go index 7c687ec3..fc1d4f08 100644 --- a/request-response.go +++ b/request-response.go @@ -76,7 +76,9 @@ func toLowerCamelCase(v string) string { return strings.ToLower(pascal[:1]) + pascal[1:] } -// Reply(ctx context.Context, id ID, result interface{}) error { +// Reply sends a response to the client with the given result. +// The result fields keys are converted to camelCase. +// If remapCallback is not nil, it is called with the result map[string]interface{}. func (c *requestContext) Reply( ctx context.Context, id jsonrpc2.ID, @@ -106,7 +108,8 @@ func (c *requestContext) Reply( return err } -func (c *requestContext) ReplyNoMod( +// ReplyRaw sends a raw response without any processing (no camelCase conversion, etc). +func (c *requestContext) ReplyRaw( ctx context.Context, id jsonrpc2.ID, result interface{},