Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions chain_capabilities/stellar/actions/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,3 @@ func isUserError(err error) bool {
func isStellarNodeInfraError(err error) bool {
return errors.Is(err, multinode.ErrNodeError) || strings.Contains(err.Error(), multinode.ErrNodeError.Error())
}

var GetError = capcommon.GetError
var NewUserError = caperrors.NewPublicUserError
12 changes: 12 additions & 0 deletions chain_capabilities/stellar/actions/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/smartcontractkit/chainlink-common/pkg/capabilities"
caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors"
stellarcap "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/stellar"
"github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/stellar/scval"
"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/settings/limits"
"github.com/smartcontractkit/chainlink-common/pkg/types"
Expand Down Expand Up @@ -271,6 +272,17 @@ func TestConvertReadContractRequestFromProto(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "function is required")
})

t.Run("invalid arg conversion", func(t *testing.T) {
t.Parallel()
_, err := convertReadContractRequestFromProto(&stellarcap.ReadContractRequest{
ContractId: testForwarderAddress,
Function: "balance",
Args: []*scval.ScVal{nil},
})
require.Error(t, err)
require.Contains(t, err.Error(), "args[0]")
})
}

func TestIsStellarNodeInfraError_MessageSubstring(t *testing.T) {
Expand Down
73 changes: 67 additions & 6 deletions chain_capabilities/stellar/actions/cre_forwarder_codec.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package actions

import (
"bytes"
"fmt"
"sort"

"github.com/stellar/go-stellar-sdk/strkey"
"github.com/stellar/go-stellar-sdk/xdr"
Expand All @@ -12,6 +14,12 @@ import (
capcommon "github.com/smartcontractkit/capabilities/chain_capabilities/common"
)

// ed25519OCRSigLen is the length of an OCR report signature for the ed25519
// (FamilyStellar) signer scheme: a 32-byte public key followed by a 64-byte
// signature. See the Aptos Move forwarder's signature_from_bytes for the same
// layout.
const ed25519OCRSigLen = 96

// CREForwarderCodec encodes and decodes Stellar CRE forwarder contract calls.
type CREForwarderCodec interface {
EncodeReport(transmitter, receiver string, report *sdk.ReportResponse) ([]stellartypes.ScVal, error)
Expand All @@ -30,7 +38,7 @@ func NewCREForwarderCodec() CREForwarderCodec {
//
// Arg order matches the on-chain Rust signature:
//
// report(transmitter: Address, receiver: Address, raw_report: Bytes, report_context: Bytes, signatures: Vec<BytesN<65>>)
// report(transmitter: Address, receiver: Address, raw_report: Bytes, report_context: Bytes, signatures: Vec<Ed25519Signature>)
func (c *creForwarderCodecImpl) EncodeReport(transmitter, receiver string, report *sdk.ReportResponse) ([]stellartypes.ScVal, error) {
transmitterVal, err := accountAddressToScVal(transmitter)
if err != nil {
Expand All @@ -54,15 +62,36 @@ func (c *creForwarderCodecImpl) EncodeReport(transmitter, receiver string, repor
rawReportVal := stellartypes.ScVal{Type: stellartypes.ScValTypeBytes, Bytes: rawReport}
reportContextVal := stellartypes.ScVal{Type: stellartypes.ScValTypeBytes, Bytes: reportContext}

// Each OCR signature is a 32-byte ed25519 public key || 64-byte signature.
// Encode each as a soroban Ed25519Signature struct and emit them in
// strictly-ascending public-key order, which the forwarder's verify_signatures
// requires (it rejects out-of-order or duplicate signers: InvalidSignerOrder).
sigs := report.GetSigs()
sigVals := make([]*stellartypes.ScVal, len(sigs))
type encodedSig struct {
pubKey []byte
val *stellartypes.ScVal
}
encoded := make([]encodedSig, 0, len(sigs))
for i, sig := range sigs {
sigBytes := sig.GetSignature()
if sigBytes == nil {
sigBytes = []byte{}
if len(sigBytes) != ed25519OCRSigLen {
return nil, fmt.Errorf("signature %d: expected %d bytes (32-byte pubkey || 64-byte signature), got %d", i, ed25519OCRSigLen, len(sigBytes))
}
s := stellartypes.ScVal{Type: stellartypes.ScValTypeBytes, Bytes: sigBytes}
sigVals[i] = &s
pubKey := sigBytes[:32]
signature := sigBytes[32:]
val, err := encodeEd25519Signature(pubKey, signature)
if err != nil {
return nil, fmt.Errorf("signature %d: %w", i, err)
}
encoded = append(encoded, encodedSig{pubKey: pubKey, val: val})
}
sort.Slice(encoded, func(i, j int) bool {
return bytes.Compare(encoded[i].pubKey, encoded[j].pubKey) < 0
})

sigVals := make([]*stellartypes.ScVal, len(encoded))
for i := range encoded {
sigVals[i] = encoded[i].val
}
sigsVal := stellartypes.ScVal{
Type: stellartypes.ScValTypeVec,
Expand All @@ -72,6 +101,38 @@ func (c *creForwarderCodecImpl) EncodeReport(transmitter, receiver string, repor
return []stellartypes.ScVal{transmitterVal, receiverVal, rawReportVal, reportContextVal, sigsVal}, nil
}

// encodeEd25519Signature builds the ScVal for a soroban Ed25519Signature:
//
// #[contracttype] struct Ed25519Signature { public_key: BytesN<32>, signature: BytesN<64> }
//
// Soroban serializes a #[contracttype] struct as an ScMap keyed by field-name
// symbols in sorted order — "public_key" sorts before "signature".
func encodeEd25519Signature(pubKey, signature []byte) (*stellartypes.ScVal, error) {
if len(pubKey) != 32 {
return nil, fmt.Errorf("ed25519 public key must be 32 bytes, got %d", len(pubKey))
}
if len(signature) != 64 {
return nil, fmt.Errorf("ed25519 signature must be 64 bytes, got %d", len(signature))
}
pubKeySym := "public_key"
signatureSym := "signature"
return &stellartypes.ScVal{
Type: stellartypes.ScValTypeMap,
Map: &stellartypes.ScMap{
Entries: []stellartypes.ScMapEntry{
{
Key: &stellartypes.ScVal{Type: stellartypes.ScValTypeSymbol, Symbol: &pubKeySym},
Val: &stellartypes.ScVal{Type: stellartypes.ScValTypeBytes, Bytes: pubKey},
},
{
Key: &stellartypes.ScVal{Type: stellartypes.ScValTypeSymbol, Symbol: &signatureSym},
Val: &stellartypes.ScVal{Type: stellartypes.ScValTypeBytes, Bytes: signature},
},
},
},
}, nil
}

func (c *creForwarderCodecImpl) EncodeQueryTransmissionInputs(transmissionID TransmissionID) ([]stellartypes.ScVal, error) {
receiverVal, err := contractAddressToScVal(transmissionID.Receiver)
if err != nil {
Expand Down
56 changes: 53 additions & 3 deletions chain_capabilities/stellar/actions/cre_forwarder_codec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,17 @@ func TestEncodeReport_SanitizesNilSlices(t *testing.T) {
t.Parallel()
codec := NewCREForwarderCodec()

// Valid 96-byte ed25519 sig so encoding reaches raw_report/report_context.
sig := make([]byte, ed25519OCRSigLen)
sig[0] = 0xAB
report := &workflowpb.ReportResponse{
Sigs: []*workflowpb.AttributedSignature{{Signature: nil}},
Sigs: []*workflowpb.AttributedSignature{{Signature: sig}},
}
args, err := codec.EncodeReport(testNodeAddress, testReceiverAddress, report)
require.NoError(t, err)
require.Len(t, args, 5)

// nil raw_report / report_context become empty Bytes.
require.Equal(t, stellartypes.ScValTypeBytes, args[2].Type)
require.NotNil(t, args[2].Bytes)
require.Empty(t, args[2].Bytes)
Expand All @@ -148,11 +152,57 @@ func TestEncodeReport_SanitizesNilSlices(t *testing.T) {
require.NotNil(t, args[3].Bytes)
require.Empty(t, args[3].Bytes)

// signatures is a Vec<Ed25519Signature>: each an ScMap { public_key, signature }
// with field-name keys in sorted order (public_key < signature).
require.Equal(t, stellartypes.ScValTypeVec, args[4].Type)
require.NotNil(t, args[4].Vec)
require.Len(t, args[4].Vec.Values, 1)
require.NotNil(t, args[4].Vec.Values[0].Bytes)
require.Empty(t, args[4].Vec.Values[0].Bytes)
sigStruct := args[4].Vec.Values[0]
require.Equal(t, stellartypes.ScValTypeMap, sigStruct.Type)
require.NotNil(t, sigStruct.Map)
require.Len(t, sigStruct.Map.Entries, 2)
require.Equal(t, "public_key", *sigStruct.Map.Entries[0].Key.Symbol)
require.Len(t, sigStruct.Map.Entries[0].Val.Bytes, 32)
require.Equal(t, "signature", *sigStruct.Map.Entries[1].Key.Symbol)
require.Len(t, sigStruct.Map.Entries[1].Val.Bytes, 64)
}

func TestEncodeReport_RejectsInvalidSignatureLength(t *testing.T) {
t.Parallel()
codec := NewCREForwarderCodec()

report := &workflowpb.ReportResponse{
Sigs: []*workflowpb.AttributedSignature{{Signature: make([]byte, 65)}}, // secp256k1 length, not ed25519
}
_, err := codec.EncodeReport(testNodeAddress, testReceiverAddress, report)
require.Error(t, err)
require.Contains(t, err.Error(), "expected 96 bytes")
}

// TestEncodeReport_SortsSignaturesByPublicKey verifies the forwarder's
// strictly-ascending-by-public-key requirement: signatures are emitted sorted by
// pubkey regardless of input order.
func TestEncodeReport_SortsSignaturesByPublicKey(t *testing.T) {
t.Parallel()
codec := NewCREForwarderCodec()

sigHigh := make([]byte, ed25519OCRSigLen)
sigHigh[0] = 0x02
sigLow := make([]byte, ed25519OCRSigLen)
sigLow[0] = 0x01

report := &workflowpb.ReportResponse{
Sigs: []*workflowpb.AttributedSignature{{Signature: sigHigh}, {Signature: sigLow}}, // out of order
}
args, err := codec.EncodeReport(testNodeAddress, testReceiverAddress, report)
require.NoError(t, err)

vec := args[4].Vec.Values
require.Len(t, vec, 2)
pk0 := vec[0].Map.Entries[0].Val.Bytes // public_key of first entry
pk1 := vec[1].Map.Entries[0].Val.Bytes
require.Equal(t, byte(0x01), pk0[0])
require.Equal(t, byte(0x02), pk1[0])
}

func TestEncodeQueryTransmissionInputs(t *testing.T) {
Expand Down
74 changes: 66 additions & 8 deletions chain_capabilities/stellar/actions/tx_hash_retriever.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,67 @@ import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"

"github.com/smartcontractkit/chainlink-common/pkg/beholder"
"github.com/smartcontractkit/chainlink-common/pkg/logger"

capcommon "github.com/smartcontractkit/capabilities/chain_capabilities/common"
"github.com/smartcontractkit/capabilities/chain_capabilities/stellar/monitoring"
)

const (
reportProcessedTopicPrefix = "forwarder_ReportProcessed"
defaultForwarderLookbackLedgers = int64(100)
failedToRetrieveTxHashErrorMsg = "failed to retrieve tx hash for report"

txHashLookupTypeSuccessful = "SuccessfulTransmission"
txHashLookupTypeFailed = "FailedTransmission"
txHashRetrievalPhase = "EventPoll"
)

var ErrUnexpectedSuccessfulTransmission = errors.New("unexpected successful transmission")

type TxHashRetriever struct {
forwarderClient CREForwarderClient
transmissionID TransmissionID
lggr logger.SugaredLogger
forwarderClient CREForwarderClient
transmissionID TransmissionID
lggr logger.SugaredLogger
beholderProcessor beholder.ProtoProcessor
messageBuilder *monitoring.MessageBuilder
telemetryContext monitoring.TelemetryContext
}

type TxHashRetrieverOption func(*TxHashRetriever)

func WithTxHashRetrieverMonitoring(
beholderProcessor beholder.ProtoProcessor,
messageBuilder *monitoring.MessageBuilder,
telemetryContext monitoring.TelemetryContext,
) TxHashRetrieverOption {
return func(r *TxHashRetriever) {
r.beholderProcessor = beholderProcessor
r.messageBuilder = messageBuilder
r.telemetryContext = telemetryContext
}
}

func NewTxHashRetriever(
forwarderClient CREForwarderClient,
lggr logger.SugaredLogger,
transmissionID TransmissionID,
opts ...TxHashRetrieverOption,
) TxHashRetriever {
return TxHashRetriever{
r := TxHashRetriever{
forwarderClient: forwarderClient,
transmissionID: transmissionID,
lggr: lggr,
}
for _, opt := range opts {
opt(&r)
}
return r
}

type eventDetails struct {
Expand Down Expand Up @@ -65,7 +95,7 @@ func (l eventDetailsList) String() string {
}

func (r *TxHashRetriever) GetSuccessfulTransmissionHash(ctx context.Context) (string, error) {
details, err := r.fetchAndParseEvents(ctx)
details, err := r.fetchAndParseEvents(ctx, txHashLookupTypeSuccessful)
if err != nil {
return "", err
}
Expand All @@ -75,6 +105,7 @@ func (r *TxHashRetriever) GetSuccessfulTransmissionHash(ctx context.Context) (st
}
}
r.lggr.Errorw("No successful transmission found", "txCount", len(details), "transactions", details.String())
r.emitTxHashRetrievalPhase(ctx, txHashLookupTypeSuccessful, "NotFound", time.Now(), "")
return "", fmt.Errorf("no successful transmission found. Found %d transactions (all failed): %s",
len(details), details)
}
Expand All @@ -85,16 +116,21 @@ func (r *TxHashRetriever) GetFailedTransmissionHash(ctx context.Context) (string
}

func (r *TxHashRetriever) GetFailedTransmissionHashWithCount(ctx context.Context) (string, int, error) {
details, err := r.fetchAndParseEvents(ctx)
details, err := r.fetchAndParseEvents(ctx, txHashLookupTypeFailed)
if err != nil {
return "", 0, err
}
for _, d := range details {
if d.isSuccess {
r.emitTxHashRetrievalPhase(ctx, txHashLookupTypeFailed, "UnexpectedSuccess", time.Now(), d.txHash)
return "", len(details), fmt.Errorf("%w, successful tx hash: %s",
ErrUnexpectedSuccessfulTransmission, d.txHash)
}
}
if len(details) == 0 {
r.emitTxHashRetrievalPhase(ctx, txHashLookupTypeFailed, "NotFound", time.Now(), "")
return "", 0, fmt.Errorf("no failed transmission found")
}

earliestIdx := 0
for i, d := range details {
Expand All @@ -113,7 +149,8 @@ func (r *TxHashRetriever) GetFailedTransmissionHashWithCount(ctx context.Context
return details[earliestIdx].txHash, len(details), nil
}

func (r *TxHashRetriever) fetchAndParseEvents(ctx context.Context) (eventDetailsList, error) {
func (r *TxHashRetriever) fetchAndParseEvents(ctx context.Context, lookupType string) (eventDetailsList, error) {
phaseStart := time.Now()
events, err := capcommon.WithPollingRetry(ctx, r.lggr, func(ctx context.Context) ([]ReportProcessedEvent, error) {
events, fetchErr := r.forwarderClient.GetReportProcessedEvents(ctx, r.transmissionID)
if fetchErr != nil {
Expand All @@ -125,10 +162,31 @@ func (r *TxHashRetriever) fetchAndParseEvents(ctx context.Context) (eventDetails
return events, nil
})
if err != nil {
r.emitTxHashRetrievalPhase(ctx, lookupType, "FetchError", phaseStart, "")
return nil, fmt.Errorf("%s: %w", failedToRetrieveTxHashErrorMsg, err)
}

return buildEventDetails(events), nil
details := buildEventDetails(events)
selectedHash := ""
if len(details) > 0 {
selectedHash = details[0].txHash
}
r.emitTxHashRetrievalPhase(ctx, lookupType, "Found", phaseStart, selectedHash)
return details, nil
}

func (r *TxHashRetriever) emitTxHashRetrievalPhase(ctx context.Context, lookupType, result string, phaseStart time.Time, txHash string) {
if r.beholderProcessor == nil || r.messageBuilder == nil {
return
}
monitoring.EmitInitiated(ctx, r.lggr, r.beholderProcessor, r.messageBuilder.BuildWriteReportTxHashRetrievalPhase(
r.telemetryContext,
txHashRetrievalPhase,
result,
int64(math.Max(float64(time.Since(phaseStart).Milliseconds()), 0)),
txHash,
lookupType,
))
}

func buildEventDetails(events []ReportProcessedEvent) eventDetailsList {
Expand Down
Loading
Loading