diff --git a/internal/serve/graphql/resolvers/mutations.resolvers.go b/internal/serve/graphql/resolvers/mutations.resolvers.go index c2e8a2851..d5f5b2b44 100644 --- a/internal/serve/graphql/resolvers/mutations.resolvers.go +++ b/internal/serve/graphql/resolvers/mutations.resolvers.go @@ -132,7 +132,8 @@ func (r *mutationResolver) BuildTransaction(ctx context.Context, input graphql1. case errors.Is(err, services.ErrInvalidSorobanOperationCount), errors.Is(err, services.ErrInvalidSorobanSimulationEmpty), errors.Is(err, services.ErrInvalidSorobanSimulationFailed), - errors.Is(err, services.ErrInvalidSorobanOperationType): + errors.Is(err, services.ErrInvalidSorobanOperationType), + errors.Is(err, services.ErrInvalidSorobanSimulationResultsEmpty): return nil, &gqlerror.Error{ Message: err.Error(), Extensions: map[string]any{ diff --git a/internal/serve/graphql/resolvers/mutations_resolvers_test.go b/internal/serve/graphql/resolvers/mutations_resolvers_test.go index 924aa3afe..3fceef442 100644 --- a/internal/serve/graphql/resolvers/mutations_resolvers_test.go +++ b/internal/serve/graphql/resolvers/mutations_resolvers_test.go @@ -2,6 +2,7 @@ package resolvers import ( "context" + "encoding/json" "errors" "testing" @@ -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 } diff --git a/internal/serve/graphql/resolvers/resolver.go b/internal/serve/graphql/resolvers/resolver.go index 15885e60f..fb0e3584a 100644 --- a/internal/serve/graphql/resolvers/resolver.go +++ b/internal/serve/graphql/resolvers/resolver.go @@ -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 } diff --git a/internal/services/transaction_service.go b/internal/services/transaction_service.go index f087dd4a9..372640771 100644 --- a/internal/services/transaction_service.go +++ b/internal/services/transaction_service.go @@ -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 { @@ -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 { diff --git a/internal/services/transaction_service_test.go b/internal/services/transaction_service_test.go index dc3567ac5..dc3f9ea1d 100644 --- a/internal/services/transaction_service_test.go +++ b/internal/services/transaction_service_test.go @@ -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,