diff --git a/go.mod b/go.mod index c957daaa..5629d111 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 - github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7 + github.com/smartcontractkit/chainlink-common v0.10.1-0.20260310151336-c98a9c147ac0 github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260226130359-963f935e0396 github.com/stretchr/testify v1.11.1 github.com/valyala/fastjson v1.6.10 diff --git a/go.sum b/go.sum index 90be5047..1f8ee598 100644 --- a/go.sum +++ b/go.sum @@ -325,8 +325,8 @@ github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= github.com/smartcontractkit/chain-selectors v1.0.89 h1:L9oWZGqQXWyTPnC6ODXgu3b0DFyLmJ9eHv+uJrE9IZY= github.com/smartcontractkit/chain-selectors v1.0.89/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= -github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7 h1:h5cmgzKpKn5N5ItpEDFhRcrtqs36nu9r/dciJub1hos= -github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7/go.mod h1:HXgSKzmZ/bhSx8nHU7hHW6dR+BHSXkdcpFv2T8qJcS8= +github.com/smartcontractkit/chainlink-common v0.10.1-0.20260310151336-c98a9c147ac0 h1:eui+u6ge2RYW01F/DeXWrc5UOqc+8+lyPoi9TIAmMgo= +github.com/smartcontractkit/chainlink-common v0.10.1-0.20260310151336-c98a9c147ac0/go.mod h1:0ghbAr7tRO0tT5ZqBXhOyzgUO37tNNe33Yn0hskauVM= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10/go.mod h1:oiDa54M0FwxevWwyAX773lwdWvFYYlYHHQV1LQ5HpWY= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260226130359-963f935e0396 h1:03tbcwjyIEjvHba1IWOj1sfThwebm2XNzyFHSuZtlWc= diff --git a/integration-tests/go.mod b/integration-tests/go.mod index a1af9d8b..10f20b94 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -14,7 +14,7 @@ require ( github.com/rs/zerolog v1.34.0 github.com/smartcontractkit/chain-selectors v1.0.97 github.com/smartcontractkit/chainlink-aptos v0.0.0-20260217195306-9fec97c5dfbd - github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7 + github.com/smartcontractkit/chainlink-common v0.10.1-0.20260310151336-c98a9c147ac0 github.com/smartcontractkit/chainlink-deployments-framework v0.83.0 github.com/smartcontractkit/chainlink-testing-framework/framework v0.14.9 github.com/smartcontractkit/chainlink/core/scripts v0.0.0-20260217182614-af80ea0f7d31 diff --git a/integration-tests/go.sum b/integration-tests/go.sum index 34f40f6e..905a77e6 100644 --- a/integration-tests/go.sum +++ b/integration-tests/go.sum @@ -1322,8 +1322,8 @@ github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260129103204-4c84 github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260129103204-4c8453dd8139/go.mod h1:gUbichNQBqk+fBF2aV40ZkzFmAJ8SygH6DEPd3cJkQE= github.com/smartcontractkit/chainlink-ccv v0.0.0-20260210123725-95a6e7788856 h1:e/L0oKHTwXjqIf66vw8NWYvXecCq5tybGwHm7YK9wuo= github.com/smartcontractkit/chainlink-ccv v0.0.0-20260210123725-95a6e7788856/go.mod h1:wLN3X89JoiWr951CX+lALwmDirEm1KzP0n+gGFblwxw= -github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7 h1:h5cmgzKpKn5N5ItpEDFhRcrtqs36nu9r/dciJub1hos= -github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7/go.mod h1:HXgSKzmZ/bhSx8nHU7hHW6dR+BHSXkdcpFv2T8qJcS8= +github.com/smartcontractkit/chainlink-common v0.10.1-0.20260310151336-c98a9c147ac0 h1:eui+u6ge2RYW01F/DeXWrc5UOqc+8+lyPoi9TIAmMgo= +github.com/smartcontractkit/chainlink-common v0.10.1-0.20260310151336-c98a9c147ac0/go.mod h1:0ghbAr7tRO0tT5ZqBXhOyzgUO37tNNe33Yn0hskauVM= github.com/smartcontractkit/chainlink-common/keystore v1.0.2-0.20260217160002-b56cb5356cc7 h1:Pxnkt4XntTv945tw/kGMqOmyfn40YtJciKEcKqljvrA= github.com/smartcontractkit/chainlink-common/keystore v1.0.2-0.20260217160002-b56cb5356cc7/go.mod h1:rSkIHdomyak3YnUtXLenl6poIq8q0V3UZPiiyYqPdGA= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20251211140724-319861e514c4 h1:NOUsjsMzNecbjiPWUQGlRSRAutEvCFrqqyETDJeh5q4= diff --git a/relayer/aptos_service.go b/relayer/aptos_service.go new file mode 100644 index 00000000..d30fe990 --- /dev/null +++ b/relayer/aptos_service.go @@ -0,0 +1,381 @@ +package relayer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "time" + + aptos_sdk "github.com/aptos-labs/aptos-go-sdk" + "github.com/aptos-labs/aptos-go-sdk/bcs" + "github.com/google/uuid" + + "github.com/smartcontractkit/chainlink-aptos/relayer/chain" + "github.com/smartcontractkit/chainlink-aptos/relayer/utils" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + commontypes "github.com/smartcontractkit/chainlink-common/pkg/types" + commonaptos "github.com/smartcontractkit/chainlink-common/pkg/types/chains/aptos" + "github.com/smartcontractkit/chainlink-common/pkg/utils/retry" +) + +type aptosService struct { + commontypes.UnimplementedAptosService + chain chain.Chain + logger logger.Logger +} + +func (s *aptosService) AccountAPTBalance(ctx context.Context, req commonaptos.AccountAPTBalanceRequest) (*commonaptos.AccountAPTBalanceReply, error) { + client, err := s.chain.GetClient() + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + sdkAddr := aptos_sdk.AccountAddress(req.Address[:]) + reply, err := client.AccountAPTBalance(sdkAddr) + if err != nil { + return nil, fmt.Errorf("failed to get account APT balance: %w", err) + } + return &commonaptos.AccountAPTBalanceReply{Value: reply}, nil +} + +func (s *aptosService) View(ctx context.Context, req commonaptos.ViewRequest) (*commonaptos.ViewReply, error) { + if req.Payload == nil { + s.logger.Errorw("TestingAptosWriteCap: View - payload is nil") + return nil, fmt.Errorf("view payload is required") + } + s.logger.Infow("TestingAptosWriteCap: View - request details", + "moduleAddress", fmt.Sprintf("0x%x", req.Payload.Module.Address), + "moduleName", req.Payload.Module.Name, + "function", req.Payload.Function, + "numArgTypes", len(req.Payload.ArgTypes), + "numArgs", len(req.Payload.Args), + "args", req.Payload.Args, + ) + + client, err := s.chain.GetClient() + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: View - failed to get client", "error", err) + return nil, fmt.Errorf("failed to get client: %w", err) + } + + sdkPayload := &aptos_sdk.ViewPayload{ + Module: aptos_sdk.ModuleId{ + Address: aptos_sdk.AccountAddress(req.Payload.Module.Address), + Name: req.Payload.Module.Name, + }, + Function: req.Payload.Function, + ArgTypes: convertTypeTagsToSDK(req.Payload.ArgTypes), + Args: req.Payload.Args, + } + + result, err := client.View(sdkPayload) + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: View - view function call failed", "error", err) + return nil, fmt.Errorf("failed to call view function: %w", err) + } + + data, err := json.Marshal(result) + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: View - failed to marshal view result", "error", err) + return nil, fmt.Errorf("failed to marshal view result: %w", err) + } + + s.logger.Infow("TestingAptosWriteCap: View - success", "responseLen", len(data), "responseData", string(data)) + return &commonaptos.ViewReply{Data: data}, nil +} + +func (s *aptosService) TransactionByHash(ctx context.Context, req commonaptos.TransactionByHashRequest) (*commonaptos.TransactionByHashReply, error) { + client, err := s.chain.GetClient() + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + + tx, err := client.TransactionByHash(req.Hash) + if err != nil { + return nil, fmt.Errorf("failed to get transaction by hash: %w", err) + } + + data, err := json.Marshal(tx.Inner) + if err != nil { + return nil, fmt.Errorf("failed to marshal transaction data: %w", err) + } + + return &commonaptos.TransactionByHashReply{ + Transaction: &commonaptos.Transaction{ + Type: commonaptos.TransactionVariant(tx.Type), + Hash: string(tx.Hash()), + Version: tx.Version(), + Success: tx.Success(), + Data: data, + }, + }, nil +} + +func (s *aptosService) AccountTransactions(ctx context.Context, req commonaptos.AccountTransactionsRequest) (*commonaptos.AccountTransactionsReply, error) { + s.logger.Infow("TestingAptosWriteCap: AccountTransactions called", + "address", fmt.Sprintf("0x%x", req.Address), + "hasStart", req.Start != nil, + "hasLimit", req.Limit != nil, + ) + + client, err := s.chain.GetClient() + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: AccountTransactions - failed to get client", "error", err) + return nil, fmt.Errorf("failed to get client: %w", err) + } + + sdkAddr := aptos_sdk.AccountAddress(req.Address[:]) + txns, err := client.AccountTransactions(sdkAddr, req.Start, req.Limit) + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: AccountTransactions - failed to get transactions", "address", sdkAddr.String(), "error", err) + return nil, fmt.Errorf("failed to get account transactions: %w", err) + } + + s.logger.Infow("TestingAptosWriteCap: AccountTransactions - fetched", "address", sdkAddr.String(), "count", len(txns)) + + result := make([]*commonaptos.Transaction, 0, len(txns)) + for _, tx := range txns { + data, err := json.Marshal(tx.Inner) + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: AccountTransactions - failed to marshal tx", "hash", string(tx.Hash()), "error", err) + return nil, fmt.Errorf("failed to marshal transaction data: %w", err) + } + v := tx.Version() + s := tx.Success() + result = append(result, &commonaptos.Transaction{ + Type: commonaptos.TransactionVariant(tx.Type), + Hash: string(tx.Hash()), + Version: &v, + Success: &s, + Data: data, + }) + } + + s.logger.Infow("TestingAptosWriteCap: AccountTransactions - returning", "address", sdkAddr.String(), "txCount", len(result)) + return &commonaptos.AccountTransactionsReply{Transactions: result}, nil +} + +func (s *aptosService) SubmitTransaction(ctx context.Context, req commonaptos.SubmitTransactionRequest) (*commonaptos.SubmitTransactionReply, error) { + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction called", + "encodedPayloadLen", len(req.EncodedPayload), + "hasGasConfig", req.GasConfig != nil, + "moduleAddress", fmt.Sprintf("%x", req.ReceiverModuleID.Address), + "moduleName", req.ReceiverModuleID.Name, + ) + + // Deserialize the BCS-encoded TransactionPayload (containing an EntryFunction) + var txPayload aptos_sdk.TransactionPayload + if err := bcs.Deserialize(&txPayload, req.EncodedPayload); err != nil { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - failed to deserialize payload", "error", err) + return nil, fmt.Errorf("failed to deserialize transaction payload: %w", err) + } + + entryFn, ok := txPayload.Payload.(*aptos_sdk.EntryFunction) + if !ok { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - unexpected payload type", "type", fmt.Sprintf("%T", txPayload.Payload)) + return nil, fmt.Errorf("expected EntryFunction payload, got %T", txPayload.Payload) + } + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - deserialized entry function", + "module", entryFn.Module.Address.String()+"::"+entryFn.Module.Name, + "function", entryFn.Function, + ) + + gasLimit := big.NewInt(int64(req.GasConfig.MaxGasAmount)) + accounts, err := s.chain.KeyStore().Accounts(ctx) + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - failed to get accounts", "error", err) + return nil, fmt.Errorf("failed to get accounts: %w", err) + } + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - accounts retrieved", "numAccounts", len(accounts)) + + // Find account with highest balance + publicKey, err := s.getAccountWithHighestBalance(ctx, accounts) + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - failed to get account with highest balance", "error", err) + return nil, fmt.Errorf("failed to determine account for SubmitTransaction: %w", err) + } + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - selected account", "publicKey", publicKey, "gasLimit", gasLimit.String()) + + txID := uuid.New().String() + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - enqueueing to TxManager", "txID", txID) + enqueueErr := s.chain.TxManager().EnqueueCRE( + txID, + &commontypes.TxMeta{ + GasLimit: gasLimit, + }, + publicKey, + entryFn, + true, // simulateTx + ) + if enqueueErr != nil { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - EnqueueCRE failed", "txID", txID, "error", enqueueErr) + return nil, fmt.Errorf("failed to enqueue transaction: %w", enqueueErr) + } + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - enqueued successfully", "txID", txID) + + // TODO: dont use txmgr config, create and use workflow/cre config + maximumWaitTime := time.Duration(s.chain.Config().TransactionManager.TxExpirationSecs) * time.Second + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - polling for status", "txID", txID, "maximumWaitTime", maximumWaitTime) + + retryCtx, cancel := context.WithTimeout(ctx, maximumWaitTime) + defer cancel() + txStatus, err := retry.Do(retryCtx, s.logger, func(ctx context.Context) (commonaptos.TransactionStatus, error) { + txStatus, txStatusErr := s.chain.TxManager().GetStatus(txID) + if txStatusErr != nil { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - GetStatus error", "txID", txID, "error", txStatusErr) + return commonaptos.TxFatal, txStatusErr + } + s.logger.Debugw("TestingAptosWriteCap: SubmitTransaction - GetStatus poll", "txID", txID, "status", txStatus) + switch txStatus { + case commontypes.Fatal, commontypes.Failed: + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - terminal failure from TxManager", "txID", txID, "status", txStatus) + return commonaptos.TxFatal, nil + case commontypes.Finalized: + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - finalized, checking result", "txID", txID) + txResult, resultErr := s.chain.TxManager().GetTransactionResult(txID) + if resultErr != nil { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - GetTransactionResult failed for finalized tx", "txID", txID, "error", resultErr) + return commonaptos.TxSuccess, nil + } + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - finalized result", "txID", txID, "vmStatus", txResult.VmStatus, "txHash", txResult.TxHash) + if txResult.VmStatus != "" && txResult.VmStatus != "Executed successfully" { + s.logger.Warnw("TestingAptosWriteCap: SubmitTransaction - finalized but VM reverted", "txID", txID, "vmStatus", txResult.VmStatus) + return commonaptos.TxFatal, nil + } + return commonaptos.TxSuccess, nil + case commontypes.Unconfirmed: + s.logger.Debugw("TestingAptosWriteCap: SubmitTransaction - still unconfirmed (broadcast but not yet confirmed on-chain)", "txID", txID) + return commonaptos.TxFatal, fmt.Errorf("tx still unconfirmed (broadcast, awaiting on-chain confirmation) for tx with ID %s", txID) + case commontypes.Pending, commontypes.Unknown: + s.logger.Debugw("TestingAptosWriteCap: SubmitTransaction - still pending/unknown, will retry", "txID", txID, "status", txStatus) + return commonaptos.TxFatal, fmt.Errorf("tx still in state pending or unknown, tx status is %d for tx with ID %s", txStatus, txID) + default: + s.logger.Warnw("TestingAptosWriteCap: SubmitTransaction - unexpected status", "txID", txID, "status", txStatus) + return commonaptos.TxFatal, fmt.Errorf("unexpected transaction status %d for tx with ID %s", txStatus, txID) + } + }) + + if err != nil { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - failed getting transaction status", "txID", txID, "error", err) + return nil, fmt.Errorf("failed getting transaction status: %w", err) + } + + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - final status", "txID", txID, "txStatus", txStatus) + + txResult, resultErr := s.chain.TxManager().GetTransactionResult(txID) + if resultErr != nil { + s.logger.Errorw("TestingAptosWriteCap: SubmitTransaction - failed to get transaction result", "txID", txID, "error", resultErr) + return nil, fmt.Errorf("failed getting transaction result: %w", resultErr) + } + + s.logger.Infow("TestingAptosWriteCap: SubmitTransaction - returning result", "txID", txID, "txStatus", txStatus, "txHash", txResult.TxHash, "vmStatus", txResult.VmStatus) + return &commonaptos.SubmitTransactionReply{ + TxStatus: txStatus, + TxHash: txResult.TxHash, + TxIdempotencyKey: txID, + }, nil +} + +// getAccountWithHighestBalance returns the public key of the account with the highest APT balance. +func (s *aptosService) getAccountWithHighestBalance(ctx context.Context, accounts []string) (string, error) { + if len(accounts) == 0 { + return "", errors.New("no accounts provided") + } + if len(accounts) == 1 { + s.logger.Debugw("only one enabled account for chain", "account", accounts[0]) + return accounts[0], nil + } + + client, err := s.chain.GetClient() + if err != nil { + return "", fmt.Errorf("failed to get client: %w", err) + } + + var highestBalance uint64 + var selectedAccount string + var foundAny bool + + for _, account := range accounts { + addr, err := utils.HexPublicKeyToAddress(account) + if err != nil { + s.logger.Warnw("failed to convert public key to address, skipping", "account", account, "error", err) + continue + } + + balance, err := client.AccountAPTBalance(addr) + if err != nil { + s.logger.Warnw("failed to get balance for account, skipping", "account", account, "address", addr.String(), "error", err) + continue + } + + if !foundAny || balance > highestBalance { + highestBalance = balance + selectedAccount = account + foundAny = true + } + } + + if !foundAny { + // Fallback to first account if all balance queries failed + return accounts[0], nil + } + + s.logger.Debugw("selected account with highest balance for chain", + "account", selectedAccount, + "balance", highestBalance, + "totalAccounts", len(accounts)) + + return selectedAccount, nil +} + +// convertTypeTagsToSDK converts common TypeTags to SDK TypeTags. +func convertTypeTagsToSDK(tags []commonaptos.TypeTag) []aptos_sdk.TypeTag { + out := make([]aptos_sdk.TypeTag, len(tags)) + for i, tag := range tags { + out[i] = aptos_sdk.TypeTag{Value: convertTypeTagImplToSDK(tag.Value)} + } + return out +} + +func convertTypeTagImplToSDK(impl commonaptos.TypeTagImpl) aptos_sdk.TypeTagImpl { + switch v := impl.(type) { + case commonaptos.BoolTag: + return &aptos_sdk.BoolTag{} + case commonaptos.U8Tag: + return &aptos_sdk.U8Tag{} + case commonaptos.U16Tag: + return &aptos_sdk.U16Tag{} + case commonaptos.U32Tag: + return &aptos_sdk.U32Tag{} + case commonaptos.U64Tag: + return &aptos_sdk.U64Tag{} + case commonaptos.U128Tag: + return &aptos_sdk.U128Tag{} + case commonaptos.U256Tag: + return &aptos_sdk.U256Tag{} + case commonaptos.AddressTag: + return &aptos_sdk.AddressTag{} + case commonaptos.SignerTag: + return &aptos_sdk.SignerTag{} + case commonaptos.VectorTag: + return &aptos_sdk.VectorTag{ + TypeParam: aptos_sdk.TypeTag{Value: convertTypeTagImplToSDK(v.ElementType.Value)}, + } + case commonaptos.StructTag: + typeParams := make([]aptos_sdk.TypeTag, len(v.TypeParams)) + for i, tp := range v.TypeParams { + typeParams[i] = aptos_sdk.TypeTag{Value: convertTypeTagImplToSDK(tp.Value)} + } + return &aptos_sdk.StructTag{ + Address: aptos_sdk.AccountAddress(v.Address), + Module: v.Module, + Name: v.Name, + TypeParams: typeParams, + } + case commonaptos.GenericTag: + return &aptos_sdk.GenericTag{Num: uint64(v.Index)} + default: + return nil + } +} diff --git a/relayer/chain/chain.go b/relayer/chain/chain.go index 2b2a8797..256015cf 100644 --- a/relayer/chain/chain.go +++ b/relayer/chain/chain.go @@ -39,6 +39,7 @@ type Chain interface { TxManager() *txm.AptosTxm LogPoller() *logpoller.AptosLogPoller GetClient() (aptos.AptosRpcClient, error) + KeyStore() loop.Keystore } type ChainOpts struct { @@ -73,10 +74,11 @@ var _ Chain = (*chain)(nil) type chain struct { starter commonutils.StartStopOnce - id string - cfg *config.TOMLConfig - lggr logger.Logger - ds sqlutil.DataSource + id string + cfg *config.TOMLConfig + lggr logger.Logger + ds sqlutil.DataSource + keyStore loop.Keystore // Sub-services txm *txm.AptosTxm @@ -113,10 +115,11 @@ func newChain(cfg *config.TOMLConfig, loopKs loop.Keystore, lggr logger.Logger, } ch := &chain{ - id: cfg.ChainID, - cfg: cfg, - lggr: logger.Named(lggr, "Chain"), - ds: ds, + id: cfg.ChainID, + cfg: cfg, + lggr: logger.Named(lggr, "Chain"), + ds: ds, + keyStore: loopKs, } ch.txm, err = txm.New(lggr, loopKs, *cfg.TransactionManager, ch.GetClient, cfg.ChainID) @@ -169,6 +172,10 @@ func (c *chain) ChainID() string { return c.id } +func (c *chain) KeyStore() loop.Keystore { + return c.keyStore +} + // GetClient returns a client, randomly selecting one from available and valid nodes func (c *chain) GetClient() (aptos.AptosRpcClient, error) { var node *config.Node diff --git a/relayer/relay.go b/relayer/relay.go index 15436c0f..7ea28808 100644 --- a/relayer/relay.go +++ b/relayer/relay.go @@ -21,6 +21,7 @@ import ( write_target "github.com/smartcontractkit/chainlink-aptos/relayer/write_target/aptos" ) +var _ types.AptosService = (*relayer)(nil) var _ loop.Relayer = (*relayer)(nil) type relayer struct { @@ -29,11 +30,13 @@ type relayer struct { starter utils.StartStopOnce stopCh services.StopChan + aptosService } func NewRelayer(lggr logger.Logger, chain chain.Chain, capRegistry core.CapabilitiesRegistry) (*relayer, error) { ctx := context.TODO() + // TODO: Deprecate this after CRE migration is complete if chain.Config().Workflow != nil { capability, err := write_target.NewAptosWriteTarget(ctx, chain, lggr) if err != nil { @@ -50,6 +53,10 @@ func NewRelayer(lggr logger.Logger, chain chain.Chain, capRegistry core.Capabili chain: chain, lggr: lggr, stopCh: make(chan struct{}), + aptosService: aptosService{ + chain: chain, + logger: lggr, + }, }, nil } @@ -57,6 +64,10 @@ func (r *relayer) Name() string { return r.lggr.Name() } +func (r *relayer) Replay(ctx context.Context, fromBlock string, args map[string]any) error { + return errors.ErrUnsupported +} + // Start starts the relayer respecting the given context. func (r *relayer) Start(ctx context.Context) error { return r.starter.StartOnce("AptosRelayer", func() error { @@ -153,8 +164,8 @@ func (r *relayer) Solana() (types.SolanaService, error) { return nil, errors.New("SolanaService is not supported for aptos") } -func (r *relayer) Replay(ctx context.Context, fromBlock string, args map[string]any) error { - return errors.ErrUnsupported +func (r *relayer) Aptos() (types.AptosService, error) { + return r, nil } // ChainService interface diff --git a/relayer/txm/txm.go b/relayer/txm/txm.go index 7ad778d0..2a7337d7 100644 --- a/relayer/txm/txm.go +++ b/relayer/txm/txm.go @@ -230,6 +230,115 @@ func (a *AptosTxm) Enqueue(transactionID string, txMetadata *commontypes.TxMeta, return nil } +// EnqueueCRE is like Enqueue but accepts a deserialized EntryFunction directly, +// skipping the string-based function parsing and BCS serialisation of parameters. +// The EntryFunction already contains the module, function name, type tags, and +// pre-encoded BCS args. +func (a *AptosTxm) EnqueueCRE(transactionID string, txMetadata *commontypes.TxMeta, publicKey string, entryFunction *aptos.EntryFunction, simulateTx bool) error { + a.baseLogger.Infow("TestingAptosWriteCap: EnqueueCRE called", + "transactionID", transactionID, + "publicKey", publicKey, + "hasEntryFunction", entryFunction != nil, + "simulateTx", simulateTx, + "hasMetadata", txMetadata != nil, + ) + + if entryFunction == nil { + a.baseLogger.Errorw("TestingAptosWriteCap: EnqueueCRE - entry function is nil") + return errors.New("entry function is required") + } + + a.baseLogger.Infow("TestingAptosWriteCap: EnqueueCRE - entry function details", + "module", entryFunction.Module.Address.String()+"::"+entryFunction.Module.Name, + "function", entryFunction.Function, + "numArgTypes", len(entryFunction.ArgTypes), + "numArgs", len(entryFunction.Args), + ) + + if transactionID == "" { + transactionID = uuid.New().String() + a.baseLogger.Infow("TestingAptosWriteCap: EnqueueCRE - generated txID", "transactionID", transactionID) + } else { + a.transactionsLock.Lock() + _, transactionExists := a.transactions[transactionID] + a.transactionsLock.Unlock() + if transactionExists { + a.baseLogger.Errorw("TestingAptosWriteCap: EnqueueCRE - transaction already exists", "transactionID", transactionID) + return errors.New("transaction already exists") + } + } + + ctxLogger := GetContexedTxLogger(a.baseLogger, transactionID, txMetadata) + + ed25519PublicKey, err := utils.HexPublicKeyToEd25519PublicKey(publicKey) + if err != nil { + a.baseLogger.Errorw("TestingAptosWriteCap: EnqueueCRE - failed to convert public key", "publicKey", publicKey, "error", err) + return fmt.Errorf("failed to convert public key: %+w", err) + } + + acc := utils.Ed25519PublicKeyToAddress(ed25519PublicKey) + fromAddress := acc.String() + a.baseLogger.Infow("TestingAptosWriteCap: EnqueueCRE - resolved from address", "fromAddress", fromAddress) + + fromAccountAddress := &aptos.AccountAddress{} + err = fromAccountAddress.ParseStringRelaxed(fromAddress) + if err != nil { + a.baseLogger.Errorw("TestingAptosWriteCap: EnqueueCRE - failed to parse from address", "fromAddress", fromAddress, "error", err) + return fmt.Errorf("failed to parse from address: %+w", err) + } + + currentTimestamp := getTimestampSecs() + tx := &AptosTx{ + ID: transactionID, + Metadata: txMetadata, + Timestamp: currentTimestamp, + FromAddress: *fromAccountAddress, + PublicKey: ed25519PublicKey, + ContractAddress: entryFunction.Module.Address, + ModuleName: entryFunction.Module.Name, + FunctionName: entryFunction.Function, + TypeTags: entryFunction.ArgTypes, + BcsValues: entryFunction.Args, + Status: commontypes.Pending, + Simulate: simulateTx, + } + + a.transactionsLock.Lock() + if (currentTimestamp - a.transactionsLastPruneTime) > a.config.PruneIntervalSecs { + for txID, tx := range a.transactions { + if tx.Status != commontypes.Finalized && tx.Status != commontypes.Failed && tx.Status != commontypes.Fatal { + continue + } + if (currentTimestamp - tx.Timestamp) < a.config.PruneTxExpirationSecs { + continue + } + ctxLogger.Debugw("Pruning transaction", "status", tx.Status) + delete(a.transactions, txID) + } + a.transactionsLastPruneTime = currentTimestamp + } + a.transactions[transactionID] = tx + a.transactionsLock.Unlock() + + select { + case a.broadcastChan <- transactionID: + ctxLogger.Infow("TestingAptosWriteCap: EnqueueCRE - tx enqueued to broadcast channel", "fromAddr", fromAddress, "transactionID", transactionID) + default: + // if the channel is full, we drop the transaction. + // we do this instead of setting the tx in `a.transactions` post-broadcast to avoid a race + // with the broadcastLoop, which expects to find the tx in `a.transactions` upon reception of + // the id. + a.transactionsLock.Lock() + delete(a.transactions, transactionID) + a.transactionsLock.Unlock() + + a.baseLogger.Errorw("TestingAptosWriteCap: EnqueueCRE - broadcast channel full, tx dropped", "transactionID", transactionID) + return fmt.Errorf("failed to enqueue tx: %+v", tx) + } + + return nil +} + func (a *AptosTxm) GetStatus(transactionID string) (commontypes.TransactionStatus, error) { if transactionID == "" { return commontypes.Unknown, errors.New("nil tx id") @@ -654,6 +763,10 @@ func (a *AptosTxm) confirmLoop() { } } +// checkUnconfirmed polls committed/pending txs and moves them to terminal states. +// Possible terminal states from this method: +// - Finalized: tx committed on-chain (successful OR reverted with non-OOG VmStatus — see TODO below) +// - Failed: OOG revert after max retries, expired tx after max retries, or TxStore errors func (a *AptosTxm) checkUnconfirmed(ctx context.Context) { client, err := a.getClient() if err != nil { @@ -669,6 +782,8 @@ func (a *AptosTxm) checkUnconfirmed(ctx context.Context) { for _, unconfirmedTx := range unconfirmedTxs { ctxLogger := GetContexedTxLogger(a.baseLogger, unconfirmedTx.Tx.ID, unconfirmedTx.Tx.Metadata) hash := unconfirmedTx.Hash + // NOTE: TransactionByHash errors (network failure, RPC error, not just "not found") + // are all treated as "tx still unconfirmed" and fall through to the expiry check below. chainTx, err := client.TransactionByHash(hash) if err == nil && chainTx.Type != aptosapi.TransactionVariantPending { @@ -702,16 +817,28 @@ func (a *AptosTxm) checkUnconfirmed(ctx context.Context) { // https://github.com/aptos-labs/aptos-core/blob/77ff4bf413f54c41206bd5573e1891fa3a0dccf6/api/types/src/convert.rs#L1062 // Example transaction: https://api.testnet.aptoslabs.com/v1/transactions/by_hash/0x7a106db811c8d5dfd71ac98f374ca36e4f630ce5412b99c8f0e871e7feda37ea a.incrementTransactionAttempt(unconfirmedTx.Tx) + // NOTE: The continue here correctly skips the Finalized update below. + // If maybeRetry succeeds, status stays Unconfirmed and the tx re-enters the broadcast loop. + // If it fails (max attempts), status is set to Failed. if !a.maybeRetry(ctx, unconfirmedTx, RetryReasonOutOfGas) { a.updateTransactionStatus(unconfirmedTx.Tx, commontypes.Failed) } continue } + // TODO: Non-OOG reverts (e.g. MOVE_ABORT, EXECUTION_FAILURE) fall through + // to the Finalized update below. The caller (aptos_service.go) treats + // Finalized as TxSuccess, which is incorrect for reverted txs. Should either: + // - set status to Failed for non-OOG reverts, or + // - introduce a distinct status (e.g. Reverted) that callers can distinguish } } else { + // NOTE: Type assertion failed — VmStatus is never set on the AptosTx. + // Falls through to Finalized below; callers won't know why. ctxLogger.Errorw("failed to read confirmed user tx", "hash", hash, "chainTxInner", chainTx.Inner) } } else { + // NOTE: Committed tx is not TransactionVariantUser (e.g. some future variant). + // VmStatus won't be set. Still marked Finalized below. ctxLogger.Errorw("unexpected confirmed tx type", "hash", hash, "chainTx", chainTx, "chainTx.Type", chainTx.Type) } @@ -733,7 +860,9 @@ func (a *AptosTxm) checkUnconfirmed(ctx context.Context) { continue } - // Confirm the transaction, mark as failed to reuse the nonce. + // NOTE: Passing failed=true marks this nonce as reusable in the TxStore. + // If the subsequent maybeRetry succeeds, the tx will be re-broadcast + // with a potentially reused nonce. err = txStore.Confirm(unconfirmedTx.Nonce, hash, true) if err != nil { ctxLogger.Errorw("couldn't confirm expired tx", "error", err) diff --git a/relayer/txm/txm_local_test.go b/relayer/txm/txm_local_test.go index f3cfd16c..08062840 100644 --- a/relayer/txm/txm_local_test.go +++ b/relayer/txm/txm_local_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/aptos-labs/aptos-go-sdk" + "github.com/aptos-labs/aptos-go-sdk/bcs" "github.com/google/uuid" "github.com/stretchr/testify/require" "golang.org/x/crypto/sha3" @@ -152,6 +153,92 @@ func runTxmTest(t *testing.T, logger logger.Logger, config Config, rpcURL string logger.Debugw("Counter value after test", "value", counterValue) require.Equal(t, expectedValue, counterValue) + + // submit all txs at once and wait for all afterwards + // helps testing reties and failure recoveries + var txIDsCRE []string + + accountBytes, err := bcs.Serialize(&accountAddress) + require.NoError(t, err) + + threeBytes, err := bcs.SerializeU64(3) + require.NoError(t, err) + fourBytes, err := bcs.SerializeU64(4) + require.NoError(t, err) + + for i := 0; i < iterations; i++ { + incrementId := uuid.New().String() + err := txm.EnqueueCRE( + incrementId, + getSampleTxMetadata(), + publicKeyHex, + &aptos.EntryFunction{ + Module: aptos.ModuleId{ + Address: accountAddress, + Name: "counter", + }, + Function: "increment", + ArgTypes: []aptos.TypeTag{}, + Args: [][]byte{ + accountBytes, + }, + }, + true, + ) + require.NoError(t, err) + expectedValue += 1 + txIDsCRE = append(txIDsCRE, incrementId) + + incrementMultId := uuid.New().String() + err = txm.EnqueueCRE( + incrementMultId, + getSampleTxMetadata(), + publicKeyHex, + &aptos.EntryFunction{ + Module: aptos.ModuleId{ + Address: accountAddress, + Name: "counter", + }, + Function: "increment_mult", + ArgTypes: []aptos.TypeTag{}, + Args: [][]byte{ + accountBytes, + threeBytes, + fourBytes, + }, + }, + true, + ) + require.NoError(t, err) + expectedValue += 3 * 4 + txIDsCRE = append(txIDsCRE, incrementMultId) + } + + for _, txId := range txIDsCRE { + waitForTxmId(t, txm, txId, time.Minute*2) + } + + counterValueCRE := testutils.ReadCounterValue(t, client, accountAddress) + logger.Debugw("Counter value after test", "value", counterValueCRE) + + require.Equal(t, expectedValue, counterValueCRE) + + // Test GetTransactionResult for finalized transactions + for _, txId := range txIDsCRE { + result, err := txm.GetTransactionResult(txId) + require.NoError(t, err) + require.Equal(t, commontypes.Finalized, result.Status) + require.NotEmpty(t, result.TxHash, "TxHash should be set for finalized transaction") + } + + // Test GetTransactionResult with invalid transaction ID + _, err = txm.GetTransactionResult("") + require.Error(t, err) + require.Contains(t, err.Error(), "nil tx id") + + _, err = txm.GetTransactionResult("non-existent-tx-id") + require.Error(t, err) + require.Contains(t, err.Error(), "no such tx") } func deployTestModule(t *testing.T, txm *AptosTxm, fromAddress aptos.AccountAddress, publicKeyHex string) {