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: 2 additions & 1 deletion internal/serve/graphql/resolvers/mutations.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

161 changes: 161 additions & 0 deletions internal/serve/graphql/resolvers/mutations_resolvers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package resolvers

import (
"context"
"encoding/json"
"errors"
"testing"

Expand Down Expand Up @@ -1079,4 +1080,164 @@ func TestMutationResolver_BuildTransaction(t *testing.T) {

mockTransactionService.AssertExpectations(t)
})

t.Run("invalid soroban simulation results empty error", func(t *testing.T) {
mockAccountService := &mockAccountService{}
mockTransactionService := &mockTransactionService{}

resolver := &mutationResolver{
&Resolver{
accountService: mockAccountService,
transactionService: mockTransactionService,
models: &data.Models{},
},
}

sourceAccount := keypair.MustRandom()
tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{
SourceAccount: &txnbuild.SimpleAccount{AccountID: sourceAccount.Address()},
IncrementSequenceNum: true,
Operations: []txnbuild.Operation{
&txnbuild.Payment{
Destination: keypair.MustRandom().Address(),
Asset: txnbuild.NativeAsset{},
Amount: "10",
},
},
BaseFee: txnbuild.MinBaseFee,
Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(30)},
})
require.NoError(t, err)

txXDR, err := tx.Base64()
require.NoError(t, err)

input := graphql.BuildTransactionInput{
TransactionXdr: txXDR,
}

mockTransactionService.On("BuildAndSignTransactionWithChannelAccount", ctx, mock.AnythingOfType("*txnbuild.GenericTransaction"), (*entities.RPCSimulateTransactionResult)(nil)).Return((*txnbuild.Transaction)(nil), services.ErrInvalidSorobanSimulationResultsEmpty)

result, err := resolver.BuildTransaction(ctx, input)

require.Error(t, err)
assert.Nil(t, result)
assert.ErrorContains(t, err, "invalid Soroban transaction: simulation results cannot be empty for InvokeHostFunction")

var gqlErr *gqlerror.Error
if errors.As(err, &gqlErr) {
assert.Equal(t, "INVALID_SOROBAN_TRANSACTION", gqlErr.Extensions["code"])
}

mockTransactionService.AssertExpectations(t)
})
}

func Test_convertSimulationResult(t *testing.T) {
t.Run("results_properly_converted", func(t *testing.T) {
// Use a pre-built valid JSON result string with base64 XDR values.
// The auth entry is a SourceAccount credential with a contract function invocation.
// The xdr value is a ScVal boolean (true).
resultStr := buildValidSimulationResultJSON(t)

input := &graphql.SimulationResultInput{
Results: []string{resultStr},
}

result, err := convertSimulationResult(input)

require.NoError(t, err)
require.Len(t, result.Results, 1)
require.Len(t, result.Results[0].Auth, 1)
assert.Equal(t, xdr.SorobanCredentialsTypeSorobanCredentialsSourceAccount, result.Results[0].Auth[0].Credentials.Type)
assert.Equal(t, xdr.ScValTypeScvBool, result.Results[0].XDR.Type)
assert.True(t, *result.Results[0].XDR.B)
})

t.Run("nil_results_returns_nil", func(t *testing.T) {
input := &graphql.SimulationResultInput{
Results: nil,
}

result, err := convertSimulationResult(input)

require.NoError(t, err)
assert.Nil(t, result.Results)
})

t.Run("empty_results_returns_nil", func(t *testing.T) {
input := &graphql.SimulationResultInput{
Results: []string{},
}

result, err := convertSimulationResult(input)

require.NoError(t, err)
assert.Nil(t, result.Results)
})

t.Run("invalid_json_returns_error", func(t *testing.T) {
input := &graphql.SimulationResultInput{
Results: []string{"not-json"},
}

_, err := convertSimulationResult(input)

require.Error(t, err)
assert.Contains(t, err.Error(), "unmarshalling simulation result at index 0")
})

t.Run("invalid_base64_in_auth_returns_error", func(t *testing.T) {
input := &graphql.SimulationResultInput{
Results: []string{`{"auth":["bad-base64"],"xdr":"bad-base64"}`},
}

_, err := convertSimulationResult(input)

require.Error(t, err)
assert.Contains(t, err.Error(), "unmarshalling simulation result at index 0")
})
}

// buildValidSimulationResultJSON builds a valid JSON string representing an RPCSimulateHostFunctionResult.
// This mirrors what the Soroban RPC server returns and what a client forwards to the buildTransaction mutation.
func buildValidSimulationResultJSON(t *testing.T) string {
t.Helper()

// Build a valid SourceAccount auth entry with a contract function invocation
signerAccountID, err := xdr.AddressToAccountId(keypair.MustRandom().Address())
require.NoError(t, err)

authEntry := xdr.SorobanAuthorizationEntry{
Credentials: xdr.SorobanCredentials{
Type: xdr.SorobanCredentialsTypeSorobanCredentialsSourceAccount,
},
RootInvocation: xdr.SorobanAuthorizedInvocation{
Function: xdr.SorobanAuthorizedFunction{
Type: xdr.SorobanAuthorizedFunctionTypeSorobanAuthorizedFunctionTypeContractFn,
ContractFn: &xdr.InvokeContractArgs{
ContractAddress: xdr.ScAddress{
Type: xdr.ScAddressTypeScAddressTypeAccount,
AccountId: &signerAccountID,
},
FunctionName: "test",
},
},
},
}
scVal := xdr.ScVal{Type: xdr.ScValTypeScvBool, B: boolPtr(true)}
// Use entities.RPCSimulateHostFunctionResult so we rely on its custom JSON
// marshalling logic rather than duplicating the wire-format encoding here.
simResult := entities.RPCSimulateHostFunctionResult{
Auth: []xdr.SorobanAuthorizationEntry{authEntry},
XDR: scVal,
}
resultJSON, err := json.Marshal(simResult)
require.NoError(t, err)
return string(resultJSON)
}

// boolPtr returns a pointer to a bool value.
func boolPtr(b bool) *bool {
return &b
}
11 changes: 11 additions & 0 deletions internal/serve/graphql/resolvers/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,5 +235,16 @@ func convertSimulationResult(simulationResultInput *graphql1.SimulationResultInp
simulationResult.TransactionData = txData
}

// Handle Results if provided — each result is a JSON-encoded string
// containing base64-encoded XDR auth entries and return value.
if len(simulationResultInput.Results) > 0 {
simulationResult.Results = make([]entities.RPCSimulateHostFunctionResult, len(simulationResultInput.Results))
for i, resultStr := range simulationResultInput.Results {
if err := json.Unmarshal([]byte(resultStr), &simulationResult.Results[i]); err != nil {
return entities.RPCSimulateTransactionResult{}, fmt.Errorf("unmarshalling simulation result at index %d: %w", i, err)
}
}
}

return simulationResult, nil
}
20 changes: 13 additions & 7 deletions internal/services/transaction_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ const (
)

var (
ErrInvalidTimeout = errors.New("invalid timeout: timeout cannot be greater than maximum allowed seconds")
ErrInvalidOperationChannelAccount = errors.New("invalid operation: operation source account cannot be the channel account")
ErrInvalidOperationMissingSource = errors.New("invalid operation: operation source account cannot be empty for non-Soroban operations")
ErrInvalidSorobanOperationCount = errors.New("invalid Soroban transaction: must have exactly one operation")
ErrInvalidSorobanSimulationEmpty = errors.New("invalid Soroban transaction: simulation response cannot be empty")
ErrInvalidSorobanSimulationFailed = errors.New("invalid Soroban transaction: simulation failed")
ErrInvalidSorobanOperationType = errors.New("invalid Soroban transaction: operation type not supported")
ErrInvalidTimeout = errors.New("invalid timeout: timeout cannot be greater than maximum allowed seconds")
ErrInvalidOperationChannelAccount = errors.New("invalid operation: operation source account cannot be the channel account")
ErrInvalidOperationMissingSource = errors.New("invalid operation: operation source account cannot be empty for non-Soroban operations")
ErrInvalidSorobanOperationCount = errors.New("invalid Soroban transaction: must have exactly one operation")
ErrInvalidSorobanSimulationEmpty = errors.New("invalid Soroban transaction: simulation response cannot be empty")
ErrInvalidSorobanSimulationFailed = errors.New("invalid Soroban transaction: simulation failed")
ErrInvalidSorobanOperationType = errors.New("invalid Soroban transaction: operation type not supported")
ErrInvalidSorobanSimulationResultsEmpty = errors.New("invalid Soroban transaction: simulation results cannot be empty for InvokeHostFunction")
)

type TransactionService interface {
Expand Down Expand Up @@ -221,6 +222,11 @@ func (t *transactionService) adjustParamsForSoroban(_ context.Context, channelAc
return txnbuild.TransactionParams{}, fmt.Errorf("%w: %s", ErrInvalidSorobanSimulationFailed, simulationResponse.Error)
}

// InvokeHostFunction requires non-empty simulation results so auth entries can be verified.
if _, ok := operations[0].(*txnbuild.InvokeHostFunction); ok && len(simulationResponse.Results) == 0 {
return txnbuild.TransactionParams{}, ErrInvalidSorobanSimulationResultsEmpty
}

// Check if the channel account public key is used as a source account for any SourceAccount auth entry.
err := sorobanauth.CheckForForbiddenSigners(simulationResponse.Results, operations[0].GetSourceAccount(), channelAccountPublicKey)
if err != nil {
Expand Down
80 changes: 80 additions & 0 deletions internal/services/transaction_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,86 @@ func Test_transactionService_adjustParamsForSoroban(t *testing.T) {
newRestoreFootprintOp.Ext, err = xdr.NewTransactionExt(1, sorobanTxData)
require.NoError(t, err)

return txnbuild.TransactionParams{
Operations: []txnbuild.Operation{newRestoreFootprintOp},
BaseFee: initialBuildTxParams.BaseFee,
SourceAccount: &txnbuild.SimpleAccount{
AccountID: txSourceAccount,
Sequence: 1,
},
Preconditions: txnbuild.Preconditions{
TimeBounds: txnbuild.NewTimeout(300),
},
}
},
},
{
name: "🚨reject_InvokeHostFunction_with_empty_results",
baseFee: txnbuild.MinBaseFee,
incomingOps: []txnbuild.Operation{
buildInvokeContractOp(t),
},
simulationResponse: entities.RPCSimulateTransactionResult{
TransactionData: sorobanTxData,
Results: []entities.RPCSimulateHostFunctionResult{},
},
wantErrContains: ErrInvalidSorobanSimulationResultsEmpty.Error(),
},
{
name: "🚨reject_InvokeHostFunction_with_nil_results",
baseFee: txnbuild.MinBaseFee,
incomingOps: []txnbuild.Operation{
buildInvokeContractOp(t),
},
simulationResponse: entities.RPCSimulateTransactionResult{
TransactionData: sorobanTxData,
Results: nil,
},
wantErrContains: ErrInvalidSorobanSimulationResultsEmpty.Error(),
},
{
name: "🟢allow_ExtendFootprintTtl_with_empty_results",
baseFee: txnbuild.MinBaseFee,
incomingOps: []txnbuild.Operation{
&txnbuild.ExtendFootprintTtl{ExtendTo: 1840580937},
},
simulationResponse: entities.RPCSimulateTransactionResult{
TransactionData: sorobanTxData,
},
wantBuildTxParamsFn: func(t *testing.T, initialBuildTxParams txnbuild.TransactionParams) txnbuild.TransactionParams {
newExtendFootprintTTLOp := &txnbuild.ExtendFootprintTtl{ExtendTo: 1840580937}
var err error
newExtendFootprintTTLOp.Ext, err = xdr.NewTransactionExt(1, sorobanTxData)
require.NoError(t, err)

return txnbuild.TransactionParams{
Operations: []txnbuild.Operation{newExtendFootprintTTLOp},
BaseFee: initialBuildTxParams.BaseFee,
SourceAccount: &txnbuild.SimpleAccount{
AccountID: txSourceAccount,
Sequence: 1,
},
Preconditions: txnbuild.Preconditions{
TimeBounds: txnbuild.NewTimeout(300),
},
}
},
},
{
name: "🟢allow_RestoreFootprint_with_empty_results",
baseFee: txnbuild.MinBaseFee,
incomingOps: []txnbuild.Operation{
&txnbuild.RestoreFootprint{},
},
simulationResponse: entities.RPCSimulateTransactionResult{
TransactionData: sorobanTxData,
},
wantBuildTxParamsFn: func(t *testing.T, initialBuildTxParams txnbuild.TransactionParams) txnbuild.TransactionParams {
newRestoreFootprintOp := &txnbuild.RestoreFootprint{}
var err error
newRestoreFootprintOp.Ext, err = xdr.NewTransactionExt(1, sorobanTxData)
require.NoError(t, err)

return txnbuild.TransactionParams{
Operations: []txnbuild.Operation{newRestoreFootprintOp},
BaseFee: initialBuildTxParams.BaseFee,
Expand Down
Loading