Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
053c462
wip:add write capability
Krish-vemula Jun 10, 2026
12448ff
wip:format nits
Krish-vemula Jun 15, 2026
2c257d9
Fix Stellar WriteReport to use ReadContract and SubmitTransaction via…
Krish-vemula Jun 15, 2026
a9e8a59
wip:lint fix
Krish-vemula Jun 15, 2026
c2f92ae
errormessage update
Krish-vemula Jun 18, 2026
f7f210d
Merge main into feat/stellar-write-capability
Krish-vemula Jun 18, 2026
71b6840
bump cl-common
Krish-vemula Jun 18, 2026
20dabea
wip:add test cases
Krish-vemula Jun 19, 2026
470c182
added blcok_timestamp
Krish-vemula Jun 19, 2026
78cf56c
lint fix
Krish-vemula Jun 19, 2026
825bbf6
Added config & sig validations
Krish-vemula Jun 22, 2026
46240f0
Added poll backoff
Krish-vemula Jun 22, 2026
59f7916
Merge branch 'main' into feat/stellar-write-capability
Krish-vemula Jun 22, 2026
ab3e093
Extract Stellar forwarder client and TxHashRetriever for WriteReport …
Krish-vemula Jun 24, 2026
121b814
Bump chainlink-common to 206b9c7 for Stellar SimulateTransaction API
Krish-vemula Jun 25, 2026
3ace00b
lint
Krish-vemula Jun 25, 2026
936c7fa
TXM handles idempotency key
Krish-vemula Jun 28, 2026
2206085
bump cl-common
Krish-vemula Jun 28, 2026
31af473
refactor(stellar): extract CRE forwarder encode/decode into CREForwar…
Krish-vemula Jun 30, 2026
29dd42c
group forwarder query keys into TransmissionID
Krish-vemula Jun 30, 2026
d9d6e00
fix(stellar): sanitize nil byte slices in CRE forwarder EncodeReport
Krish-vemula Jun 30, 2026
f884290
refactor(stellar): strict decode get_transmission_info return struct …
Krish-vemula Jun 30, 2026
7655335
refactor(stellar): return billing metadata directly from WriteReport …
Krish-vemula Jun 30, 2026
62b63f0
refactor(stellar): align WriteReport reply building with EVM pattern
Krish-vemula Jun 30, 2026
3fd1c12
fix(stellar): prefer canonical tx hash on duplicate submit paths
Krish-vemula Jun 30, 2026
d3c8433
fix(stellar): return error on unknown post-submit transmission state
Krish-vemula Jun 30, 2026
3d68b01
fix(stellar): use canonical failed tx hash on post-submit revert paths
Krish-vemula Jun 30, 2026
b938719
lint fix
Krish-vemula Jun 30, 2026
d06455f
add test coverage
Krish-vemula Jun 30, 2026
1d455e3
bump common
ilija42 Jul 2, 2026
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
17 changes: 17 additions & 0 deletions chain_capabilities/stellar/.mockery.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
dir: "{{ .InterfaceDir }}"
mockname: "{{ .InterfaceName }}_mock"
outpkg: actions
inpackage: true
filename: "test_{{ .InterfaceName | snakecase }}_mock.go"
fail-on-missing: true
resolve-type-alias: false
packages:
github.com/smartcontractkit/chainlink-common/pkg/types:
config:
dir: actions
mockname: "StellarService_mock"
outpkg: actions
inpackage: true
filename: "test_stellar_service_mock.go"
interfaces:
StellarService:
1 change: 1 addition & 0 deletions chain_capabilities/stellar/.tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
golang 1.25.3
134 changes: 99 additions & 35 deletions chain_capabilities/stellar/actions/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,46 +11,87 @@ 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"
commoncfg "github.com/smartcontractkit/chainlink-common/pkg/config"
"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/services"
"github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings"
"github.com/smartcontractkit/chainlink-common/pkg/settings/limits"
"github.com/smartcontractkit/chainlink-common/pkg/types"
stellartypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/stellar"
"github.com/smartcontractkit/chainlink-framework/multinode"

capcommon "github.com/smartcontractkit/capabilities/chain_capabilities/common"
commonmon "github.com/smartcontractkit/capabilities/chain_capabilities/common/monitoring"
"github.com/smartcontractkit/capabilities/libs/chainconsensus"
ctypes "github.com/smartcontractkit/capabilities/libs/chainconsensus/types"

ts "github.com/smartcontractkit/capabilities/chain_capabilities/common/transmission_schedule"
"github.com/smartcontractkit/capabilities/chain_capabilities/stellar/metering"
"github.com/smartcontractkit/capabilities/chain_capabilities/stellar/monitoring"
"github.com/smartcontractkit/capabilities/libs/chainconsensus"
ctypes "github.com/smartcontractkit/capabilities/libs/chainconsensus/types"
)

// Stellar implements the CRE capability actions for the Stellar chain
// Stellar implements the CRE capability actions for the Stellar chain.
type Stellar struct {
types.StellarService
handler chainconsensus.RequestHandler
lggr logger.SugaredLogger
messageBuilder *monitoring.MessageBuilder
beholderProcessor beholder.ProtoProcessor
chainSelector uint64
handler chainconsensus.RequestHandler
lggr logger.SugaredLogger
messageBuilder *monitoring.MessageBuilder
beholderProcessor beholder.ProtoProcessor
chainSelector uint64
forwarderClient CREForwarderClient
forwarderLookbackLedgers int64
reportSizeLimit limits.BoundLimiter[commoncfg.Size]
transmissionScheduler ts.TransmissionScheduler
}

// NewStellar builds the Stellar capability actions.
func NewStellar(
Comment thread
Krish-vemula marked this conversation as resolved.
service types.StellarService,
forwarderAddress string,
forwarderLookbackLedgers int64,
lggr logger.Logger,
limitsFactory limits.Factory,
transmissionScheduler ts.TransmissionScheduler,
chainSelector uint64,
handler chainconsensus.RequestHandler,
messageBuilder *monitoring.MessageBuilder,
beholderProcessor beholder.ProtoProcessor,
) (*Stellar, error) {
return &Stellar{
StellarService: service,
handler: handler,
lggr: logger.Sugared(lggr),
messageBuilder: messageBuilder,
beholderProcessor: beholderProcessor,
chainSelector: chainSelector,
}, nil
if service == nil {
return nil, fmt.Errorf("stellar service is required")
}

st := &Stellar{
StellarService: service,
handler: handler,
lggr: logger.Sugared(lggr),
messageBuilder: messageBuilder,
beholderProcessor: beholderProcessor,
chainSelector: chainSelector,
forwarderClient: newForwarderClient(service, lggr, forwarderAddress, forwarderLookbackLedgers),
forwarderLookbackLedgers: forwarderLookbackLedgers,
transmissionScheduler: transmissionScheduler,
}
return st, st.initLimiters(limitsFactory)
}

func (s *Stellar) initLimiters(limitsFactory limits.Factory) (err error) {
s.reportSizeLimit, err = limits.MakeUpperBoundLimiter(limitsFactory, cresettings.Default.PerWorkflow.ChainWrite.ReportSizeLimit)
return err
}

func (s *Stellar) Close() error {
return services.CloseAll(s.reportSizeLimit)
}

func (s *Stellar) GetLatestLedger(ctx context.Context, _ capabilities.RequestMetadata, _ *stellarcap.GetLatestLedgerRequest) (*capabilities.ResponseAndMetadata[*stellarcap.GetLatestLedgerResponse], caperrors.Error) {
resp, err := s.StellarService.GetLatestLedger(ctx)
if err != nil {
return nil, GetError(err, false)
}
protoResp, err := stellarcap.ConvertGetLatestLedgerResponseToProto(resp)
if err != nil {
return nil, GetError(err, false)
}
return &capabilities.ResponseAndMetadata[*stellarcap.GetLatestLedgerResponse]{Response: protoResp}, nil
}

// ReadContract performs a consensus read of a read-only Soroban contract call.
Expand All @@ -59,7 +100,7 @@ func (s *Stellar) ReadContract(
metadata capabilities.RequestMetadata,
input *stellarcap.ReadContractRequest,
) (*capabilities.ResponseAndMetadata[*stellarcap.ReadContractResponse], caperrors.Error) {
request, err := stellarcap.ConvertReadContractRequestFromProto(input)
request, err := convertReadContractRequestFromProto(input)
if err != nil {
return nil, caperrors.NewPublicUserError(fmt.Errorf("invalid request: %w", err), caperrors.InvalidArgument)
}
Expand All @@ -81,13 +122,13 @@ func (s *Stellar) ReadContract(
metadata.ReferenceID,
metering.GetResponseMetadata(metering.ReadContract),
func(ctx context.Context) (*stellarcap.ReadContractResponse, uint64, error) {
response, err := s.StellarService.ReadContract(ctx, request)
response, err := s.SimulateTransaction(ctx, request)
if err != nil {
return nil, 0, err
}

return &stellarcap.ReadContractResponse{
Result: response.Result,
Result: response.ReturnValueXDR,
LedgerSequence: response.LedgerSequence,
Error: response.Error,
}, uint64(response.LedgerSequence), nil
Expand All @@ -109,29 +150,52 @@ func (s *Stellar) ReadContract(
return responseAndMetadata, nil
}

func (s *Stellar) GetLatestLedger(
_ context.Context,
_ capabilities.RequestMetadata,
_ *stellarcap.GetLatestLedgerRequest,
) (*capabilities.ResponseAndMetadata[*stellarcap.GetLatestLedgerResponse], caperrors.Error) {
return nil, caperrors.NewPublicSystemError(errors.New("unimplemented"), caperrors.Unknown)
func convertReadContractRequestFromProto(p *stellarcap.ReadContractRequest) (stellartypes.SimulateTransactionRequest, error) {
if p == nil {
return stellartypes.SimulateTransactionRequest{}, fmt.Errorf("readContractRequest is nil")
}
if p.GetContractId() == "" {
return stellartypes.SimulateTransactionRequest{}, fmt.Errorf("contractID is required")
}
if p.GetFunction() == "" {
return stellartypes.SimulateTransactionRequest{}, fmt.Errorf("function is required")
}

pArgs := p.GetArgs()
args := make([]stellartypes.ScVal, len(pArgs))
for i, psv := range pArgs {
sv, err := stellarcap.ProtoToScVal(psv)
if err != nil {
return stellartypes.SimulateTransactionRequest{}, fmt.Errorf("args[%d]: %w", i, err)
}
args[i] = sv
}
return stellartypes.SimulateTransactionRequest{
ContractID: p.GetContractId(),
Function: p.GetFunction(),
Args: args,
SourceAccount: p.GetSourceAccount(),
}, nil
}

func (s *Stellar) isUserErrorWriteReport(err error) bool {
return strings.HasPrefix(err.Error(), capcommon.UserError)
}

func (s *Stellar) WriteReport(
_ context.Context,
_ capabilities.RequestMetadata,
_ *stellarcap.WriteReportRequest,
) (*capabilities.ResponseAndMetadata[*stellarcap.WriteReportReply], caperrors.Error) {
return nil, caperrors.NewPublicSystemError(errors.New("unimplemented"), caperrors.Unknown)
func (s *Stellar) Info() (capabilities.CapabilityInfo, error) {
return capabilities.CapabilityInfo{}, nil
}

func isUserError(err error) bool {
return !errors.Is(err, context.DeadlineExceeded) && !isStellarNodeInfraError(err)
}

// isStellarNodeInfraError reports whether err is a node-availability failure. It checks both
// error identity and the message substring because errors reach this function through LOOP gRPC ,
// which preserve only the gRPC status code and message — Go error identity (errors.Is) does not survive that round trip.
// error identity and the message substring because errors reach this function through LOOP gRPC,
// which preserves only the gRPC status code and message.
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
130 changes: 122 additions & 8 deletions chain_capabilities/stellar/actions/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
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/logger"
"github.com/smartcontractkit/chainlink-common/pkg/settings/limits"
"github.com/smartcontractkit/chainlink-common/pkg/types"
stellartypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/stellar"
"github.com/smartcontractkit/chainlink-common/pkg/types/mocks"
Expand All @@ -24,6 +25,7 @@ import (

ctypes "github.com/smartcontractkit/capabilities/libs/chainconsensus/types"

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

Expand Down Expand Up @@ -65,6 +67,92 @@ func validReadContractRequest() *stellarcap.ReadContractRequest {
}
}

func TestNewStellar(t *testing.T) {
t.Parallel()

t.Run("nil service", func(t *testing.T) {
t.Parallel()
lggr := logger.Test(t)
_, err := NewStellar(
nil,
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
100,
lggr,
limits.Factory{Logger: lggr},
ts.TransmissionScheduler{},
1,
testConsensusHandler{handle: runVolatileHashableHandle},
monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""),
nopBeholderProcessor{},
)
require.Error(t, err)
require.Contains(t, err.Error(), "stellar service is required")
})

t.Run("success", func(t *testing.T) {
t.Parallel()
lggr := logger.Test(t)
svc := mocks.NewStellarService(t)
st, err := NewStellar(
svc,
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
100,
lggr,
limits.Factory{Logger: lggr},
ts.TransmissionScheduler{},
1,
testConsensusHandler{handle: runVolatileHashableHandle},
monitoring.NewMessageBuilder(types.ChainInfo{}, capabilities.CapabilityInfo{}, ""),
nopBeholderProcessor{},
)
require.NoError(t, err)
require.NotNil(t, st)
require.NoError(t, st.Close())
})
}

func TestGetLatestLedger(t *testing.T) {
t.Parallel()

t.Run("happy path", func(t *testing.T) {
t.Parallel()
helper := newMockedStellar(t)
helper.stellarService.EXPECT().
GetLatestLedger(mock.Anything).
Return(stellartypes.GetLatestLedgerResponse{
Sequence: 123,
LedgerCloseTime: 1_700_000_000,
}, nil).
Once()

resp, err := helper.stellar.GetLatestLedger(t.Context(), capabilities.RequestMetadata{}, &stellarcap.GetLatestLedgerRequest{})
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, uint32(123), resp.Response.GetSequence())
require.Equal(t, int64(1_700_000_000), resp.Response.GetLedgerCloseTime())
})

t.Run("service error", func(t *testing.T) {
t.Parallel()
helper := newMockedStellar(t)
helper.stellarService.EXPECT().
GetLatestLedger(mock.Anything).
Return(stellartypes.GetLatestLedgerResponse{}, errors.New("node unavailable")).
Once()

_, err := helper.stellar.GetLatestLedger(t.Context(), capabilities.RequestMetadata{}, &stellarcap.GetLatestLedgerRequest{})
require.Error(t, err)
})
}

func TestStellar_Info(t *testing.T) {
t.Parallel()
helper := newMockedStellar(t)
info, err := helper.stellar.Info()
require.NoError(t, err)
require.Equal(t, capabilities.CapabilityInfo{}, info)
}

func TestReadContract(t *testing.T) {
t.Parallel()

Expand All @@ -90,9 +178,9 @@ func TestReadContract(t *testing.T) {
const ledgerSeq uint32 = 52_000
const result = "AAAAAwAAAAA=" // base64 XDR
helper.stellarService.EXPECT().
ReadContract(mock.Anything, mock.Anything).
Return(stellartypes.ReadContractResponse{
Result: result,
SimulateTransaction(mock.Anything, mock.Anything).
Return(stellartypes.SimulateTransactionResponse{
ReturnValueXDR: result,
LedgerSequence: ledgerSeq,
}, nil).
Once()
Expand All @@ -114,8 +202,8 @@ func TestReadContract(t *testing.T) {
// Plain errors (e.g. invalid input surfaced by the relayer) default to user errors.
expectedErr := errors.New("failed to decode contract id")
helper.stellarService.EXPECT().
ReadContract(mock.Anything, mock.Anything).
Return(stellartypes.ReadContractResponse{}, expectedErr).
SimulateTransaction(mock.Anything, mock.Anything).
Return(stellartypes.SimulateTransactionResponse{}, expectedErr).
Once()

_, err := helper.stellar.ReadContract(t.Context(), capabilities.RequestMetadata{
Expand All @@ -135,10 +223,10 @@ func TestReadContract(t *testing.T) {

// Errors tagged by the relayer with multinode.ErrNodeError must survive the
// observation-error serialization round trip and stay classified as infra/system.
expectedErr := fmt.Errorf("failed to read contract: %w", multinode.ErrNodeError)
expectedErr := fmt.Errorf("failed to simulate transaction: %w", multinode.ErrNodeError)
helper.stellarService.EXPECT().
ReadContract(mock.Anything, mock.Anything).
Return(stellartypes.ReadContractResponse{}, expectedErr).
SimulateTransaction(mock.Anything, mock.Anything).
Return(stellartypes.SimulateTransactionResponse{}, expectedErr).
Once()

_, err := helper.stellar.ReadContract(t.Context(), capabilities.RequestMetadata{
Expand Down Expand Up @@ -175,6 +263,32 @@ func TestReadContract(t *testing.T) {
})
}

func TestConvertReadContractRequestFromProto(t *testing.T) {
t.Parallel()

t.Run("nil request", func(t *testing.T) {
t.Parallel()
_, err := convertReadContractRequestFromProto(nil)
require.Error(t, err)
require.Contains(t, err.Error(), "nil")
})

t.Run("missing function", func(t *testing.T) {
t.Parallel()
_, err := convertReadContractRequestFromProto(&stellarcap.ReadContractRequest{
ContractId: testForwarderAddress,
})
require.Error(t, err)
require.Contains(t, err.Error(), "function is required")
})
}

func TestIsStellarNodeInfraError_MessageSubstring(t *testing.T) {
t.Parallel()
require.True(t, isStellarNodeInfraError(errors.New("wrapped: "+multinode.ErrNodeError.Error())))
require.False(t, isStellarNodeInfraError(errors.New("user input invalid")))
}

// testConsensusHandler simulates the consensus handler so a single node's observation is treated
// as the agreed value (mode of one observation), exercising the volatile / F+1 / tiebreak path.
type testConsensusHandler struct {
Expand Down
Loading
Loading