diff --git a/.github/workflows/test_mock_and_examples.yml b/.github/workflows/test_mock_and_examples.yml index 22f369aa..02a8a241 100644 --- a/.github/workflows/test_mock_and_examples.yml +++ b/.github/workflows/test_mock_and_examples.yml @@ -29,6 +29,7 @@ jobs: cd ../readEvents && go build cd ../simpleCall && go build cd ../simpleDeclare && go build + cd ../paymaster && go build cd ../typedData && go build cd ../websocket && go build diff --git a/.github/workflows/test_paymaster.yml b/.github/workflows/test_paymaster.yml new file mode 100644 index 00000000..d74c76d1 --- /dev/null +++ b/.github/workflows/test_paymaster.yml @@ -0,0 +1,30 @@ +name: paymaster integration tests + +permissions: + contents: read + +on: + push: + branches: + - main + +jobs: + run: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout branch + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: go.mod + + - name: Run paymaster tests + run: cd paymaster && go test -timeout 300s -v -env integration . + env: + STARKNET_PRIVATE_KEY: ${{ secrets.TESTNET_ACCOUNT_PRIVATE_KEY }} + STARKNET_PUBLIC_KEY: ${{ secrets.TESTNET_ACCOUNT_PUBLIC_KEY }} + STARKNET_ACCOUNT_ADDRESS: ${{ secrets.TESTNET_ACCOUNT_ADDRESS }} + AVNU_API_KEY: ${{ secrets.AVNU_API_KEY }} \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml index 59e5610d..b974c40f 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -49,6 +49,9 @@ linters: min-occurrences: 3 gocritic: enable-all: true + settings: + hugeParam: + sizeThreshold: 256 gocyclo: min-complexity: 15 govet: @@ -109,7 +112,12 @@ linters: - gosec - lll - mnd + - funlen # it's ok for the examples to have big main() functions path: examples/ + - linters: + - gocritic #commentedOutCode: "curve.SignFelts..." is there just as an explanation + - unused # it's part of the example to have two unused functions + path: examples/paymaster/ - linters: - exhaustruct - noctx diff --git a/README.md b/README.md index ea204ed4..9bf809a0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ operations on the wallets. The package has excellent documentation for a smooth - [invoke transaction example](./examples/invoke) to add a new invoke transaction on testnet. - [declare transaction example](./examples/simpleDeclare) to add a new contract on testnet. - [deploy contract UDC example](./examples/deployContractUDC) to deploy an ERC20 token using [UDC (Universal Deployer Contract)](https://docs.openzeppelin.com/contracts-cairo/1.0.0/udc) on testnet. +- [paymaster example](./examples/paymaster) to learn how to interact with a paymaster and send transactions with it. - [typed data example](./examples/typedData) to sign and verify a typed data. - [websocket example](./examples/websocket) to learn how to subscribe to WebSocket methods. diff --git a/examples/.env.template b/examples/.env.template index e477140c..73c08ef1 100644 --- a/examples/.env.template +++ b/examples/.env.template @@ -6,4 +6,7 @@ #ACCOUNT_ADDRESS=0xyour_account_address #PUBLIC_KEY=0xyour_starknet_public_key #PRIVATE_KEY=0xyour_private_key -#ACCOUNT_CAIRO_VERSION=2 \ No newline at end of file +#ACCOUNT_CAIRO_VERSION=2 + +# ----- use this variable for specific cases in the paymaster example +#AVNU_API_KEY=your-api-key \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index a5434e21..3409474f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,3 +47,5 @@ To run an example: R: See [typedData](./typedData/main.go). 1. How to use WebSocket methods? How to subscribe, unsubscribe, handle errors, and read values from them? R: See [websocket](./websocket/main.go). +1. How to interact with a paymaster? How to send transactions with it? + R: See [paymaster](./paymaster/main.go). diff --git a/examples/internal/setup.go b/examples/internal/setup.go index 9f3a2102..680edeab 100644 --- a/examples/internal/setup.go +++ b/examples/internal/setup.go @@ -62,6 +62,11 @@ func GetAccountCairoVersion() account.CairoVersion { } } +// Validates whether the AVNU_API_KEY variable has been set in the '.env' file and returns it; panics otherwise. +func GetAVNUApiKey() string { + return getEnv("AVNU_API_KEY") +} + // Loads an env variable by name and returns it; panics otherwise. func getEnv(envName string) string { env := os.Getenv(envName) diff --git a/examples/paymaster/README.md b/examples/paymaster/README.md new file mode 100644 index 00000000..c635ab37 --- /dev/null +++ b/examples/paymaster/README.md @@ -0,0 +1,22 @@ +This example demonstrates how to send transactions on Starknet with a paymaster using the Starkent.go SNIP-29 implementation, allowing you to pay fees with tokens other than STRK. +It has three files: main.go, deploy.go, and deploy_and_invoke.go. + +The main.go file shows how to send an invoke transaction using a paymaster with the "default" fee mode, where you pay fees using supported tokens (like STRK). It demonstrates the complete 3-step process: building the transaction via the paymaster, signing it with your account, and executing the transaction. + +The deploy.go file demonstrates how to deploy a new account using a paymaster with the "sponsored" fee mode, where an entity covers the transaction fees. +The deploy_and_invoke.go file shows how to deploy an account and invoke a function in the same transaction using a paymaster, combining both deployment and execution in a single request. +Both of these `deploy...`examples require a valid paymaster API key. + +All examples demonstrate integration with the AVNU paymaster service and require SNIP-9 compatible accounts. + +Steps: +1. Rename the ".env.template" file located at the root of the "examples" folder to ".env" +2. Uncomment, and assign your Sepolia testnet endpoint to the `RPC_PROVIDER_URL` variable in the ".env" file +3. Uncomment, and assign your SNIP-9 compatible account address to the `ACCOUNT_ADDRESS` variable in the ".env" file (make sure to have some STRK tokens in it) +4. Uncomment, and assign your starknet public key to the `PUBLIC_KEY` variable in the ".env" file +5. Uncomment, and assign your private key to the `PRIVATE_KEY` variable in the ".env" file +6. Make sure you are in the "paymaster" directory +7. Execute `go run .` to run the basic paymaster invoke example +8. To run the deploy examples (requires API key), uncomment the function calls at the end of main.go and execute again. Also, uncomment, and assign your paymaster API key to the `AVNU_API_KEY` variable in the ".env" file + +The transaction hashes, tracking IDs, and execution status will be returned at the end of each example. \ No newline at end of file diff --git a/examples/paymaster/deploy.go b/examples/paymaster/deploy.go new file mode 100644 index 00000000..464c1cfe --- /dev/null +++ b/examples/paymaster/deploy.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "fmt" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/account" + "github.com/NethermindEth/starknet.go/client" + setup "github.com/NethermindEth/starknet.go/examples/internal" + "github.com/NethermindEth/starknet.go/internal/utils" + pm "github.com/NethermindEth/starknet.go/paymaster" +) + +// OpenZeppelin account class hash that supports outside executions +const OZAccountClassHash = "0x05b4b537eaa2399e3aa99c4e2e0208ebd6c71bc1467938cd52c798c601e43564" + +// An example of how to deploy a contract with a paymaster. +func deployWithPaymaster() { + fmt.Println("Starting paymaster example - deploying an account") + + // Load variables from '.env' file + AVNUApiKey := setup.GetAVNUApiKey() + + // Since all accounts in Starknet are smart contracts, we need to deploy them first before we can use them. + // And to do so, we need to calculate the address of the new account and fund it with + // enough STRK tokens before deploying it. This tokens will be used to pay the fees for the `deploy` txn. + // + // Deploy an account with a paymaster using the `default` fee mode doesn't make much sense, as we will + // need to send some tokens for the account anyway. So, we will use the `sponsored` fee mode now, + // which will allow the paymaster to fully cover the fees for the `deploy` txn. This mode requires + // an API key from an entity. You can only run this example with it. + + // Let's initialise the paymaster client, but now, we will also pass our API key to the client. + // In the AVNU paymaster, the API key is a http header called `x-paymaster-api-key`. + // In the current Starknet.go client, you can set a custom http header using the `client.WithHeader` option. + paymaster, err := pm.New( + context.Background(), + AVNUPaymasterURL, + client.WithHeader("x-paymaster-api-key", AVNUApiKey), + ) + if err != nil { + panic(fmt.Sprintf("Error connecting to the paymaster provider with the API key: %s", err)) + } + + fmt.Println("Established connection with the paymaster provider") + fmt.Print("Step 1: Build the deploy transaction\n\n") + + // First, let's get all the data we need for deploy an account. + _, pubKey, privK := account.GetRandomKeys() // Get random keys for the account + fmt.Println("Public key:", pubKey) + fmt.Println("Private key:", privK) + classHash, _ := utils.HexToFelt( + OZAccountClassHash, + ) // It needs to be an SNIP-9 compatible account + constructorCalldata := []*felt.Felt{ + pubKey, + } // The OZ account constructor requires the public key + salt, _ := utils.HexToFelt("0xdeadbeef") // Just a random salt + // Precompute the address of the new account based on the salt, class hash and constructor calldata + precAddress := account.PrecomputeAccountAddress(salt, classHash, constructorCalldata) + + fmt.Println("Precomputed address:", precAddress) + + // Now we can create the deploy data for the transaction. + deployData := &pm.AccountDeploymentData{ + Address: precAddress, // The precomputed address of the new account + ClassHash: classHash, + Salt: salt, + Calldata: constructorCalldata, + SignatureData: []*felt.Felt{}, // Optional. For the OZ account, we don't need to add anything in the signature data. + Version: pm.Cairo1, + } + + // With the deploy data, we can build the transaction by calling the `paymaster_buildTransaction` method. + // REMEMBER: this will only work if you have a valid API key configured. + // + // A full explanation about the paymaster_buildTransaction method can be found in the `main.go` file of this same example. + builtTxn, err := paymaster.BuildTransaction(context.Background(), &pm.BuildTransactionRequest{ + Transaction: pm.UserTransaction{ + Type: pm.UserTxnDeploy, // we are building an `deploy` transaction + Deployment: deployData, + }, + Parameters: pm.UserParameters{ + Version: pm.UserParamV1, + FeeMode: pm.FeeMode{ + Mode: pm.FeeModeSponsored, // We then set the fee mode to `sponsored` + Tip: &pm.TipPriority{ + Priority: pm.TipPriorityNormal, + }, + }, + }, + }) + if err != nil { + panic(fmt.Sprintf("Error building the deploy transaction: %s", err)) + } + fmt.Println("Transaction successfully built by the paymaster") + PrettyPrint(builtTxn) + + // Since we are deploying an account, we don't need to sign the transaction, just execute it. + + fmt.Println("Step 2: Send the signed transaction") + + // With our built deploy transaction, we can send it to the paymaster by calling the `paymaster_executeTransaction` method. + response, err := paymaster.ExecuteTransaction( + context.Background(), + &pm.ExecuteTransactionRequest{ + Transaction: pm.ExecutableUserTransaction{ + Type: pm.UserTxnDeploy, + Deployment: builtTxn.Deployment, // The deployment data is the same. We can use our `deployData` variable, or + // the `builtTxn.Deployment` value. + }, + Parameters: pm.UserParameters{ + Version: pm.UserParamV1, + + // Using the same fee options as in the `paymaster_buildTransaction` method. + FeeMode: pm.FeeMode{ + Mode: pm.FeeModeSponsored, + Tip: &pm.TipPriority{ + Priority: pm.TipPriorityNormal, + }, + }, + }, + }, + ) + if err != nil { + panic(fmt.Sprintf("Error executing the deploy transaction with the paymaster: %s", err)) + } + + fmt.Println("Deploy transaction successfully executed by the paymaster") + fmt.Println("Tracking ID:", response.TrackingID) + fmt.Println("Transaction Hash:", response.TransactionHash) +} diff --git a/examples/paymaster/deploy_and_invoke.go b/examples/paymaster/deploy_and_invoke.go new file mode 100644 index 00000000..4821a4b6 --- /dev/null +++ b/examples/paymaster/deploy_and_invoke.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "fmt" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/account" + "github.com/NethermindEth/starknet.go/client" + "github.com/NethermindEth/starknet.go/curve" + setup "github.com/NethermindEth/starknet.go/examples/internal" + "github.com/NethermindEth/starknet.go/internal/utils" + pm "github.com/NethermindEth/starknet.go/paymaster" +) + +// An example of how to deploy an account and invoke a function in the same request using a paymaster. +func deployAndInvokeWithPaymaster() { + fmt.Println("Starting paymaster example - deploy_and_invoke") + + // Load variables from '.env' file + AVNUApiKey := setup.GetAVNUApiKey() + + // Since all accounts in Starknet are smart contracts, we need to deploy them first before we can use them. + // And to do so, we need to calculate the address of the new account and fund it with + // enough STRK tokens before deploying it. This tokens will be used to pay the fees for the `deploy` txn. + // + // Deploy an account with a paymaster using the `default` fee mode doesn't make much sense, as we will + // need to send some tokens for the account anyway. So, we will use the `sponsored` fee mode now, + // which will allow the paymaster to fully cover the fees for the `deploy` txn. This mode requires + // an API key from an entity. You can only run this example with it. + + // Let's initialise the paymaster client, but now, we will also pass our API key to the client. + // In the AVNU paymaster, the API key is a http header called `x-paymaster-api-key`. + // In the current Starknet.go client, you can set a custom http header using the `client.WithHeader` option. + paymaster, err := pm.New( + context.Background(), + AVNUPaymasterURL, + client.WithHeader("x-paymaster-api-key", AVNUApiKey), + ) + if err != nil { + panic(fmt.Sprintf("Error connecting to the paymaster provider with the API key: %s", err)) + } + + fmt.Println("Established connection with the paymaster provider") + fmt.Print("Step 1: Build the deploy_and_invoke transaction\n\n") + + // First, let's get all the data we need for deploy an account. + _, pubKey, privK := account.GetRandomKeys() // Get random keys for the account + fmt.Println("Public key:", pubKey) + fmt.Println("Private key:", privK) + classHash, _ := utils.HexToFelt( + OZAccountClassHash, + ) // It needs to be an SNIP-9 compatible account + constructorCalldata := []*felt.Felt{ + pubKey, + } // The OZ account constructor requires the public key + salt, _ := utils.HexToFelt("0xdeadbeef") // Just a random salt + // Precompute the address of the new account based on the salt, class hash and constructor calldata + precAddress := account.PrecomputeAccountAddress(salt, classHash, constructorCalldata) + + fmt.Println("Precomputed address:", precAddress) + + // Now we can create the deploy data for the transaction. + deployData := &pm.AccountDeploymentData{ + Address: precAddress, // The precomputed address of the new account + ClassHash: classHash, + Salt: salt, + Calldata: constructorCalldata, + SignatureData: []*felt.Felt{}, // Optional. For the OZ account, we don't need to add anything in the signature data. + Version: pm.Cairo1, + } + + // The next step is to define what we want to execute. + // The `deploy_and_invoke` transaction type requires both the deploy and invoke data in order to + // deploy the account and invoke a function within the same request. + + // Here, we will execute a `mint` function in the `RAND_ERC20_CONTRACT_ADDRESS` contract, with the amount of `0xffffffff`. + amount, _ := utils.HexToU256Felt("0xffffffff") + invokeData := &pm.UserInvoke{ + UserAddress: precAddress, // The `user_address` is the address of the account that will be deployed. + Calls: []pm.Call{ + { // These fields were explained in the `main.go` file of this same example. + To: RandERC20ContractAddress, + Selector: utils.GetSelectorFromNameFelt("mint"), + Calldata: amount, + }, + }, + } + + // With the deploy and invoke data, we can build the transaction by calling the `paymaster_buildTransaction` method. + // REMEMBER: this will only work if you have a valid API key configured. + // + // A full explanation about the paymaster_buildTransaction method can be found in the `main.go` file of this same example. + builtTxn, err := paymaster.BuildTransaction(context.Background(), &pm.BuildTransactionRequest{ + Transaction: pm.UserTransaction{ + Type: pm.UserTxnDeployAndInvoke, // we are building an `deploy_and_invoke` transaction + + // Both the deploy and invoke data are required. + Deployment: deployData, + Invoke: invokeData, + }, + Parameters: pm.UserParameters{ + Version: pm.UserParamV1, + FeeMode: pm.FeeMode{ + Mode: pm.FeeModeSponsored, // We then set the fee mode to `sponsored` + Tip: &pm.TipPriority{ + Priority: pm.TipPriorityNormal, + }, + }, + }, + }) + if err != nil { + panic(fmt.Sprintf("Error building the deploy_and_invoke transaction: %s", err)) + } + fmt.Println("Transaction successfully built by the paymaster") + PrettyPrint(builtTxn) + + fmt.Println("Step 2: Sign the transaction") + + // Now that we have the built transaction, we need to sign it. + // Differently from the `deploy` transaction, where we just deploy a new account, in the `deploy_and_invoke` + // we both deploy the account and invoke a function using it. This function request needs to be signed by the account. + + // The signing process consists of signing the SNIP-12 typed data contained in the built transaction. + + // Firstly, get the message hash of the typed data using our precomputed account address as input. + messageHash, err := builtTxn.TypedData.GetMessageHash(precAddress.String()) + if err != nil { + panic(fmt.Sprintf("Error getting the message hash of the typed data: %s", err)) + } + fmt.Println("Message hash of the typed data:", messageHash) + + // Now, we sign the message hash using our account. + r, s, err := curve.SignFelts( + messageHash, + privK, + ) // You can also use the `curve` package to sign the message hash. + if err != nil { + panic(fmt.Sprintf("Error signing the transaction: %s", err)) + } + signature := []*felt.Felt{r, s} + + fmt.Println("Transaction successfully signed") + PrettyPrint(signature) + + fmt.Println("Step 3: Send the signed transaction") + + // With our built deploy_and_invoke transaction, we can send it to the paymaster by calling the `paymaster_executeTransaction` method. + response, err := paymaster.ExecuteTransaction( + context.Background(), + &pm.ExecuteTransactionRequest{ + Transaction: pm.ExecutableUserTransaction{ + Type: pm.UserTxnDeployAndInvoke, + + Deployment: builtTxn.Deployment, // The deployment data is the same. We can use our `deployData` variable, or + // the `builtTxn.Deployment` value. + Invoke: &pm.ExecutableUserInvoke{ + UserAddress: precAddress, // The `user_address` is the address of the account that will be deployed. + TypedData: builtTxn.TypedData, // The typed data returned by the `paymaster_buildTransaction` method. + Signature: signature, // The signature of the message hash made in the previous step. + }, + }, + Parameters: pm.UserParameters{ + Version: pm.UserParamV1, + + // Using the same fee options as in the `paymaster_buildTransaction` method. + FeeMode: pm.FeeMode{ + Mode: pm.FeeModeSponsored, + Tip: &pm.TipPriority{ + Priority: pm.TipPriorityNormal, + }, + }, + }, + }, + ) + if err != nil { + panic( + fmt.Sprintf( + "Error executing the deploy_and_invoke transaction with the paymaster: %s", + err, + ), + ) + } + + fmt.Println("Deploy_and_invoke transaction successfully executed by the paymaster") + fmt.Println("Tracking ID:", response.TrackingID) + fmt.Println("Transaction Hash:", response.TransactionHash) +} diff --git a/examples/paymaster/main.go b/examples/paymaster/main.go new file mode 100644 index 00000000..7e6d1cc1 --- /dev/null +++ b/examples/paymaster/main.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/NethermindEth/starknet.go/account" + setup "github.com/NethermindEth/starknet.go/examples/internal" + "github.com/NethermindEth/starknet.go/internal/utils" + pm "github.com/NethermindEth/starknet.go/paymaster" + "github.com/NethermindEth/starknet.go/rpc" +) + +var ( + AVNUPaymasterURL = "https://sepolia.paymaster.avnu.fi" + + // A simple ERC20 contract with a public mint function + RandERC20ContractAddress, _ = utils.HexToFelt( + "0x0669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54", + ) +) + +// This example shows how to build and execute an invoke transaction with a paymaster. +// With a paymaster, you can send txns to the Starknet network using other tokens than STRK. +// Read more about it in the SNIP-29: https://github.com/starknet-io/SNIPs/blob/dfd91b275ea65413f8c8aedb26677a8afff70f37/SNIPS/snip-29.md +// +// NOTE: IN ORDER TO RUN THIS EXAMPLE, YOU NEED TO HAVE AN ACCOUNT THAT IMPLEMENTS THE SNIP-9 STANDARD. +// OTHERWISE, THE PAYMASTER WILL RETURN AN ERROR. +// Ref: https://github.com/starknet-io/SNIPs/blob/ea46a8777d8c8d53a43f45b7beb1abcc301a1a69/SNIPS/snip-9.md +func main() { + fmt.Println("Starting paymaster example") + + // ************* Set up things ************* + // + // Load variables from '.env' file + accountAddress := setup.GetAccountAddress() + accountCairoVersion := setup.GetAccountCairoVersion() + privateKey := setup.GetPrivateKey() + publicKey := setup.GetPublicKey() + rpcProviderURL := setup.GetRPCProviderURL() + + // Connect to a RPC provider to instantiate the account + client, err := rpc.NewProvider(context.Background(), rpcProviderURL) + if err != nil { + panic(fmt.Sprintf("Error dialling the RPC provider: %s", err)) + } + + // Instantiate the account to sign the transaction (we can also use the `curve` pkg for that, we'll see later) + acc := NewAccount(client, accountAddress, privateKey, publicKey, accountCairoVersion) + + // ************* done ************* + + // Initialise connection to the paymaster provider - AVNU Sepolia in this case + paymaster, err := pm.New(context.Background(), AVNUPaymasterURL) + if err != nil { + panic(fmt.Sprintf("Error connecting to the paymaster provider: %s", err)) + } + + fmt.Println("Established connection with the paymaster provider") + + // Check if the paymaster provider is available by calling the `paymaster_isAvailable` method + available, err := paymaster.IsAvailable(context.Background()) + if err != nil { + panic(fmt.Sprintf("Error checking if the paymaster provider is available: %s", err)) + } + fmt.Println("Is paymaster provider available?: ", available) + + // Get the supported tokens by calling the `paymaster_getSupportedTokens` method + tokens, err := paymaster.GetSupportedTokens(context.Background()) + if err != nil { + panic(fmt.Sprintf("Error getting the supported tokens: %s", err)) + } + fmt.Println("\nSupported tokens:") + PrettyPrint(tokens) + + // Now that we know the paymaster is available and we have the supported tokens list, + // we can build and execute a transaction with the paymaster, paying the fees with any of + // the supported tokens. + // For the sake of simplicity, we will use the STRK token itself. + + // Sending an invoke transaction with a paymaster involves 3 steps: + // 1. Build the transaction by calling the `paymaster_buildTransaction` method + // 2. Sign the transaction built by the paymaster + // 3. Send the signed transaction by calling the `paymaster_executeTransaction` method + + fmt.Println("Step 1: Build the transaction") + + // Here we are declaring the invoke data for the transaction. + // It's a call to the `mint` function in the `RAND_ERC20_CONTRACT_ADDRESS` contract, with the amount of `0xffffffff`. + amount, _ := utils.HexToU256Felt("0xffffffff") + + invokeData := &pm.UserInvoke{ + UserAddress: acc.Address, + Calls: []pm.Call{ + { + To: RandERC20ContractAddress, + Selector: utils.GetSelectorFromNameFelt("mint"), + Calldata: amount, + }, + // we could add more calls to the transaction if we want. They would be executed in the + // same paymaster transaction. + }, + } + + STRKContractAddress, _ := utils.HexToFelt( + "0x04718f5a0Fc34cC1AF16A1cdee98fFB20C31f5cD61D6Ab07201858f4287c938D", + ) + + // Now that we have the invoke data, we will build the transaction by calling the `paymaster_buildTransaction` method. + builtTxn, err := paymaster.BuildTransaction(context.Background(), &pm.BuildTransactionRequest{ + Transaction: pm.UserTransaction{ + Type: pm.UserTxnInvoke, // we are building an `invoke` transaction + Invoke: invokeData, + }, + Parameters: pm.UserParameters{ + Version: pm.UserParamV1, // Leave as is. This is the only version supported by the paymaster for now. + + // Here we specify the fee mode we want to use for the transaction. + // We won't spend any value here; this step will just return a fee estimate based on our options. + FeeMode: pm.FeeMode{ + // There are 2 fee modes supported by the paymaster: `sponsored` and `default`. + // - `sponsored` fee mode is when an entity will cover your transaction fees. You need an API + // key from an entity to use this mode. + // - `default` fee mode is when you cover the fees yourself for the transaction using one of the supported tokens. + Mode: pm.FeeModeDefault, + GasToken: STRKContractAddress, // For the `default` fee mode, use the `gas_token` field + // to specify which token to use for the fees. + + // There's also the `tip` field to specify the tip for the transaction. + // - `tip` field is used to specify a tip priority. + // - `custom` field is used to specify a custom tip value. + Tip: &pm.TipPriority{ + // Custom: 0, // You can use the `custom` field to specify a custom tip value. + + // Or, you can use the `priority` field to specify a tip priority mode. + // There are 3 tip priority modes supported by the paymaster: `slow`, `normal` and `fast`. + Priority: pm.TipPriorityNormal, + + // If you don't specify a tip priority or a custom tip value (`Tip: nil`), + // the paymaster will use the `normal` tip priority by default. + }, + }, + }, + }) + if err != nil { + panic(fmt.Sprintf("Error building the transaction: %s", err)) + } + fmt.Println("Transaction successfully built by the paymaster") + + // NOTE: Now that we have the built transaction, is up to you to check the fee estimate and + // decide if you want to proceed with the transaction. + // The fee estimate is contained in the `fee` JSON field of the built transaction, and looks like this: + // `{ + // "gas_token_price_in_strk": "0xde0b6b3a7640000", + // "estimated_fee_in_strk": "0xd0867e191fcc0", + // "estimated_fee_in_gas_token": "0x83de54ac3228a", + // "suggested_max_fee_in_strk": "0x4e326f496bec80", + // "suggested_max_fee_in_gas_token": "0xab48f32cd750" + // }` + PrettyPrint(builtTxn) + + fmt.Println("Step 2: Sign the transaction") + + // Now that we have the built transaction, we need to sign it. + // The signing process consists of signing the SNIP-12 typed data contained in the built transaction. + + // Firstly, get the message hash of the typed data using our account address as input. + messageHash, err := builtTxn.TypedData.GetMessageHash(acc.Address.String()) + if err != nil { + panic(fmt.Sprintf("Error getting the message hash of the typed data: %s", err)) + } + fmt.Println("Message hash of the typed data:", messageHash) + + // Now, we sign the message hash using our account. + signature, err := acc.Sign(context.Background(), messageHash) + // r, s, err := curve.SignFelts(messageHash, privateKeyFelt) // You can also use the `curve` package to sign the message hash. + if err != nil { + panic(fmt.Sprintf("Error signing the transaction: %s", err)) + } + fmt.Println("Transaction successfully signed") + PrettyPrint(signature) + + fmt.Println("Step 3: Send the signed transaction") + + // Now that we have the signature, we can send our signed transaction to the paymaster by calling the `paymaster_executeTransaction` method. + // NOTE: this is the final step, the transaction will be executed and the fees will be paid by us in the specified gas token. + response, err := paymaster.ExecuteTransaction( + context.Background(), + &pm.ExecuteTransactionRequest{ + Transaction: pm.ExecutableUserTransaction{ + Type: pm.UserTxnInvoke, + Invoke: &pm.ExecutableUserInvoke{ + UserAddress: acc.Address, // Our account address + TypedData: builtTxn.TypedData, // The typed data returned by the `paymaster_buildTransaction` method + Signature: signature, // The signature of the message hash made in the previous step + }, + }, + Parameters: pm.UserParameters{ + Version: pm.UserParamV1, + + // Using the same fee options as in the `paymaster_buildTransaction` method. A different fee mode here + // will result in a different fee cost than the one we got in the build step. + FeeMode: pm.FeeMode{ + Mode: pm.FeeModeDefault, + GasToken: STRKContractAddress, + }, + }, + }, + ) + if err != nil { + panic(fmt.Sprintf("Error executing the txn with the paymaster: %s", err)) + } + + fmt.Println("Transaction successfully executed by the paymaster") + fmt.Println("Tracking ID:", response.TrackingID) + fmt.Println("Transaction Hash:", response.TransactionHash) + + // There are more two files in this example: + // - deploy.go: an example of how to deploy an account with a paymaster + // - deploy_and_invoke.go: an example of how to send a `deploy_and_invoke` transaction with a paymaster, + // which is a transaction that deploys an account and then invokes a function in it in the same transaction. + // + // For these examples, you need to have a valid paymaster API key from an entity. Just uncomment the function call + // you want to run. + // + // Don't you have the API key? Well, you can't run the examples, but feel free to take a look at the code! + + // deployWithPaymaster() + // deployAndInvokeWithPaymaster() +} + +// PrettyPrint marshals the data with indentation and prints it. +func PrettyPrint(data interface{}) { + prettyJSON, err := json.MarshalIndent(data, "", " ") + if err != nil { + panic(err) + } + fmt.Println(string(prettyJSON)) + fmt.Println("--------------------------------") +} + +// Just a helper function to instantiate the account for us. +func NewAccount( + client *rpc.Provider, + accountAddress, privateKey, publicKey string, + accountCairoVersion account.CairoVersion, +) *account.Account { + // Initialise the account memkeyStore (set public and private keys) + ks := account.NewMemKeystore() + privKeyBI, ok := new(big.Int).SetString(privateKey, 0) + if !ok { + panic("Failed to convert privKey to bigInt") + } + ks.Put(publicKey, privKeyBI) + + // Here we are converting the account address to felt + accountAddressInFelt, err := utils.HexToFelt(accountAddress) + if err != nil { + fmt.Println("Failed to transform the account address, did you give the hex address?") + panic(err) + } + // Initialise the account + accnt, err := account.NewAccount( + client, + accountAddressInFelt, + publicKey, + ks, + accountCairoVersion, + ) + if err != nil { + panic(err) + } + + return accnt +} diff --git a/internal/tests/tests.go b/internal/tests/tests.go index 8686a1d5..a8373733 100644 --- a/internal/tests/tests.go +++ b/internal/tests/tests.go @@ -19,7 +19,9 @@ type TestEnv string //nolint:staticcheck // Only used in tests const ( - MockEnv TestEnv = "mock" + MockEnv TestEnv = "mock" + // Used to run account and rpc tests on the Integration network. + // Also, used to run paymaster tests with the Avnu Sepolia paymaster. IntegrationEnv TestEnv = "integration" TestnetEnv TestEnv = "testnet" MainnetEnv TestEnv = "mainnet" diff --git a/paymaster/build_txn.go b/paymaster/build_txn.go new file mode 100644 index 00000000..a420bfe8 --- /dev/null +++ b/paymaster/build_txn.go @@ -0,0 +1,395 @@ +package paymaster + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/client/rpcerr" + "github.com/NethermindEth/starknet.go/typedata" +) + +// BuildTransaction receives the transaction the user wants to execute. Returns the typed +// data along with the estimated gas cost and the maximum gas cost suggested to ensure execution +// +// Parameters: +// - ctx: The context.Context object for controlling the function call +// - request: The BuildTransactionRequest containing the transaction and parameters +// +// Returns: +// - *BuildTransactionResponse: The response containing typed data and fee estimate +// - error: An error if the request fails +func (p *Paymaster) BuildTransaction( + ctx context.Context, + request *BuildTransactionRequest, +) (BuildTransactionResponse, error) { + var response BuildTransactionResponse + if err := p.c.CallContextWithSliceArgs( + ctx, &response, "paymaster_buildTransaction", request, + ); err != nil { + return response, rpcerr.UnwrapToRPCErr( + err, + ErrInvalidAddress, + ErrClassHashNotSupported, + ErrInvalidDeploymentData, + ErrTokenNotSupported, + ErrInvalidTimeBounds, + ErrUnknownError, + ErrTransactionExecutionError, + ) + } + + return response, nil +} + +// BuildTransactionRequest is the request to build a transaction for +// the paymaster (transaction + parameters). +type BuildTransactionRequest struct { + // The transaction to be executed by the paymaster + Transaction UserTransaction `json:"transaction"` + // Execution parameters to be used when executing the transaction + Parameters UserParameters `json:"parameters"` +} + +// UserTransaction represents a user transaction (deploy, invoke, +// or deploy_and_invoke). +type UserTransaction struct { + // The type of the transaction to be executed by the paymaster + Type UserTxnType `json:"type"` + // The deployment data for the transaction, used for `deploy` and + // `deploy_and_invoke` transaction types. + // Should be `nil` for `invoke` transaction types. + Deployment *AccountDeploymentData `json:"deployment,omitempty"` + // The invoke data for the transaction, used for `invoke` and + // `deploy_and_invoke` transaction types. + // Should be `nil` for `deploy` transaction types. + Invoke *UserInvoke `json:"invoke,omitempty"` +} + +// An enum representing the type of the transaction to be executed +// by the paymaster +type UserTxnType int + +const ( + // Represents a deploy transaction ("deploy" tag) + UserTxnDeploy UserTxnType = iota + 1 + // Represents an invoke transaction ("invoke" tag) + UserTxnInvoke + // Represents a deploy and invoke transaction ("deploy_and_invoke" tag) + UserTxnDeployAndInvoke +) + +// String returns the string representation of the UserTxnType. +func (u UserTxnType) String() string { + return []string{"deploy", "invoke", "deploy_and_invoke"}[u-1] +} + +// MarshalJSON marshals the UserTxnType to JSON. +func (u UserTxnType) MarshalJSON() ([]byte, error) { + return strconv.AppendQuote(nil, u.String()), nil +} + +// UnmarshalJSON unmarshals the JSON data into a UserTxnType. +func (u *UserTxnType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + + switch s { + case "deploy": + *u = UserTxnDeploy + case "invoke": + *u = UserTxnInvoke + case "deploy_and_invoke": + *u = UserTxnDeployAndInvoke + default: + return fmt.Errorf("invalid user transaction type: %s", s) + } + + return nil +} + +// Data required to deploy an account at an address. +type AccountDeploymentData struct { + // The expected address to be deployed, used to double check + Address *felt.Felt `json:"address"` + // The hash of the deployed contract's class + ClassHash *felt.Felt `json:"class_hash"` + // The salt used for the contract address calculation + Salt *felt.Felt `json:"salt"` + // The parameters passed to the constructor + Calldata []*felt.Felt `json:"calldata"` + // Optional array of felts to be added to the signature + SignatureData []*felt.Felt `json:"sigdata,omitempty"` + // The Cairo version of the account contract. Cairo 0 is not supported. + Version CairoVersion `json:"version"` +} + +// An enum representing the Cairo version of the account contract +// to be deployed. Cairo 0 is not supported. +type CairoVersion int + +const ( + // Represents the Cairo 1 version + Cairo1 CairoVersion = 1 +) + +// Calls to be executed by the paymaster and the user account address that will be called +type UserInvoke struct { + // The address of the user account + UserAddress *felt.Felt `json:"user_address"` + // The sequence of calls that the user wishes to perform + Calls []Call `json:"calls"` +} + +// The object that defines an invocation of a function in a contract +type Call struct { + // The address of the contract to invoke + To *felt.Felt `json:"to"` + // The selector of the function to invoke + Selector *felt.Felt `json:"selector"` + // The parameters passed to the function + Calldata []*felt.Felt `json:"calldata"` +} + +// Execution parameters to be used when executing the transaction through the paymaster +type UserParameters struct { + // Version of the execution parameters which is not tied to the 'execute from outside' version. + Version UserParamVersion `json:"version"` + // Fee mode to use for the execution + FeeMode FeeMode `json:"fee_mode"` + // Optional. Time constraint on the execution + TimeBounds *TimeBounds `json:"time_bounds"` + + // Note: even being optional, the `time_bounds` field wasn't tagged with `omitempty` + // to facilitate the tests. The reason is that the `time_bounds` returned by the paymaster + // is always filled with `null` in the response if not provided, and if we tag it + // with `omitempty`, the JSON field would be omitted in the request, resulting in + // different JSONs when comparing the request and response. +} + +// An enum representing the version of the execution parameters +type UserParamVersion int + +const ( + // Represents the v1 of the execution parameters ("0x1") + UserParamV1 UserParamVersion = iota + 1 +) + +// String returns the string representation of the UserTxnType. +func (u UserParamVersion) String() string { + return []string{"0x1"}[u-1] +} + +// MarshalJSON marshals the UserParamVersion to JSON. +func (u UserParamVersion) MarshalJSON() ([]byte, error) { + return strconv.AppendQuote(nil, u.String()), nil +} + +// UnmarshalJSON unmarshals the JSON data into a UserParamVersion. +func (u *UserParamVersion) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + + switch s { + case "0x1": + *u = UserParamV1 + default: + return fmt.Errorf("invalid user parameter version: %s", s) + } + + return nil +} + +// An enum representing the fee mode to use for the transaction +type FeeModeType int + +const ( + // Specify that the transaction should be sponsored. This argument does not + // guaranteed sponsorship and will depend on the paymaster provider. + // Represents the "sponsored" string value. + FeeModeSponsored FeeModeType = iota + 1 + // Default fee mode where the transaction is paid by the user in the given gas token. + // Represents the "default" string value. + FeeModeDefault +) + +// String returns the string representation of the FeeModeType. +func (fee FeeModeType) String() string { + return []string{"sponsored", "default"}[fee-1] +} + +// MarshalJSON marshals the FeeModeType to JSON. +func (fee FeeModeType) MarshalJSON() ([]byte, error) { + return strconv.AppendQuote(nil, fee.String()), nil +} + +// UnmarshalJSON unmarshals the JSON data into a FeeModeType. +func (fee *FeeModeType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + + switch s { + case "sponsored": + *fee = FeeModeSponsored + case "default": + *fee = FeeModeDefault + default: + return fmt.Errorf("invalid fee mode: %s", s) + } + + return nil +} + +// Specify how the transaction should be paid. Either by the user +// specifying a gas token or through sponsorship +type FeeMode struct { + // The fee mode type to use for the transaction + Mode FeeModeType `json:"mode"` + // The gas token to use for the transaction. Should be omitted for `sponsored` fee mode + GasToken *felt.Felt `json:"gas_token,omitempty"` + // Relative tip priority or a custom tip value. If not provided/is `nil`, + // the paymaster will use the `normal` tip priority by default. + Tip *TipPriority `json:"tip,omitempty"` +} + +// Relative tip priority or a custom tip value. +// +// The user must specify either the priority or the custom tip value. +// If both fields are omitted (or TipPriority is `nil`), the paymaster will +// use the `normal` tip priority by default. +type TipPriority struct { + // The relative tip priority + Priority TipPriorityEnum `json:"-"` + // A custom tip value + Custom *uint64 `json:"custom,omitempty"` +} + +// MarshalJSON marshals the TipPriority to JSON. +func (t *TipPriority) MarshalJSON() ([]byte, error) { + if t.Priority != 0 { + raw, err := t.Priority.MarshalJSON() + if err != nil { + return nil, err + } + + return raw, nil + } + + if t.Custom == nil { + return nil, nil + } + + // Marshal the `custom` field + type alias TipPriority + + return json.Marshal(alias(*t)) +} + +// UnmarshalJSON unmarshals the JSON data into a TipPriority. +func (t *TipPriority) UnmarshalJSON(b []byte) error { + var tip TipPriorityEnum + var err error + if err = tip.UnmarshalJSON(b); err == nil { + t.Priority = tip + + return nil + } + + type Alias TipPriority + var alias Alias + + if err2 := json.Unmarshal(b, &alias); err2 != nil { + return fmt.Errorf("failed to unmarshal tip priority: %w :%w", err2, err) + } + + t.Custom = alias.Custom + + return nil +} + +// An enum representing the desired tip priority for the +// paymster transaction. +type TipPriorityEnum int + +const ( + // Slow tip priority (represents the "slow" string value) + TipPrioritySlow TipPriorityEnum = iota + 1 + // Normal tip priority (represents the "normal" string value) + TipPriorityNormal + // Fast tip priority (represents the "fast" string value) + TipPriorityFast +) + +// String returns the string representation of the TipPriorityEnum. +func (tip TipPriorityEnum) String() string { + return []string{"slow", "normal", "fast"}[tip-1] +} + +// MarshalJSON marshals the TipPriorityEnum to JSON. +func (tip TipPriorityEnum) MarshalJSON() ([]byte, error) { + return strconv.AppendQuote(nil, tip.String()), nil +} + +// UnmarshalJSON unmarshals the JSON data into a TipPriorityEnum. +func (tip *TipPriorityEnum) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + + switch s { + case "slow": + *tip = TipPrioritySlow + case "normal": + *tip = TipPriorityNormal + case "fast": + *tip = TipPriorityFast + default: + return fmt.Errorf("invalid tip priority: %s", s) + } + + return nil +} + +// Object containing timestamps corresponding to `Execute After` and `Execute Before` +type TimeBounds struct { + // A lower bound after which an outside call is valid in UNIX timestamp format + ExecuteAfter string `json:"execute_after"` + // An upper bound before which an outside call is valid in UNIX timestamp format + ExecuteBefore string `json:"execute_before"` +} + +// FeeEstimate is a detailed fee estimation (in STRK and gas token, with suggested max). +type FeeEstimate struct { + GasTokenPriceInStrk *felt.Felt `json:"gas_token_price_in_strk"` + EstimatedFeeInStrk *felt.Felt `json:"estimated_fee_in_strk"` + EstimatedFeeInGasToken *felt.Felt `json:"estimated_fee_in_gas_token"` + SuggestedMaxFeeInStrk *felt.Felt `json:"suggested_max_fee_in_strk"` + SuggestedMaxFeeInGasToken *felt.Felt `json:"suggested_max_fee_in_gas_token"` +} + +// BuildTransactionResponse is the response from the `paymaster_buildTransaction` method. +// It contains the transaction data required for the paymaster to execute, along with +// an estimation of the fee. +type BuildTransactionResponse struct { + // The type of the transaction + Type UserTxnType `json:"type"` + // The deployment data for `deploy` and `deploy_and_invoke` transaction types. + // It's `nil` for `invoke` transaction types. + Deployment *AccountDeploymentData `json:"deployment,omitempty"` + // Execution parameters to be used when executing the transaction + Parameters *UserParameters `json:"parameters"` + // The typed data for for `invoke` and `deploy_and_invoke` transaction types. + // It's `nil` for `deploy` transaction types. + TypedData *typedata.TypedData `json:"typed_data,omitempty"` + // The fee estimation for the transaction + Fee *FeeEstimate `json:"fee"` +} diff --git a/paymaster/build_txn_test.go b/paymaster/build_txn_test.go new file mode 100644 index 00000000..6654b906 --- /dev/null +++ b/paymaster/build_txn_test.go @@ -0,0 +1,596 @@ +package paymaster + +import ( + "encoding/json" + "testing" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/internal/tests" + internalUtils "github.com/NethermindEth/starknet.go/internal/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +var STRKContractAddress, _ = internalUtils.HexToFelt( + "0x04718f5a0Fc34cC1AF16A1cdee98fFB20C31f5cD61D6Ab07201858f4287c938D", +) + +// Test the UserTxnType type +// +//nolint:dupl // The enum tests are similar, but with different enum values +func TestUserTxnType(t *testing.T) { + tests.RunTestOn(t, tests.MockEnv) + t.Parallel() + + type testCase struct { + Input string + Expected UserTxnType + ErrorExpected bool + } + + testCases := []testCase{ + { + Input: `"deploy"`, + Expected: UserTxnDeploy, + ErrorExpected: false, + }, + { + Input: `"invoke"`, + Expected: UserTxnInvoke, + ErrorExpected: false, + }, + { + Input: `"deploy_and_invoke"`, + Expected: UserTxnDeployAndInvoke, + ErrorExpected: false, + }, + { + Input: `"unknown"`, + ErrorExpected: true, + }, + } + + for _, test := range testCases { + t.Run(test.Input, func(t *testing.T) { + t.Parallel() + CompareEnumsHelper(t, test.Input, test.Expected, test.ErrorExpected) + }) + } +} + +// Test the FeeModeType type +func TestFeeModeType(t *testing.T) { + tests.RunTestOn(t, tests.MockEnv) + t.Parallel() + + type testCase struct { + Input string + Expected FeeModeType + ErrorExpected bool + } + + testCases := []testCase{ + { + Input: `"default"`, + Expected: FeeModeDefault, + ErrorExpected: false, + }, + { + Input: `"sponsored"`, + Expected: FeeModeSponsored, + ErrorExpected: false, + }, + { + Input: `"unknown"`, + ErrorExpected: true, + }, + } + + for _, test := range testCases { + t.Run(test.Input, func(t *testing.T) { + t.Parallel() + CompareEnumsHelper(t, test.Input, test.Expected, test.ErrorExpected) + }) + } +} + +// Test the UserParamVersion type +func TestUserParamVersion(t *testing.T) { + tests.RunTestOn(t, tests.MockEnv) + t.Parallel() + + type testCase struct { + Input string + Expected UserParamVersion + ErrorExpected bool + } + + testCases := []testCase{ + { + Input: `"0x1"`, + Expected: UserParamV1, + ErrorExpected: false, + }, + { + Input: `"0x2"`, + ErrorExpected: true, + }, + } + + for _, test := range testCases { + t.Run(test.Input, func(t *testing.T) { + t.Parallel() + CompareEnumsHelper(t, test.Input, test.Expected, test.ErrorExpected) + }) + } +} + +// Test the TipPriority type +func TestTipPriority(t *testing.T) { + tests.RunTestOn(t, tests.MockEnv) + t.Parallel() + + type testCase struct { + Input string + Expected TipPriority + ErrorExpected bool + } + + temp := uint64(1234) + testCases := []testCase{ + { + Input: `"slow"`, + Expected: TipPriority{ + Priority: TipPrioritySlow, + }, + ErrorExpected: false, + }, + { + Input: `"normal"`, + Expected: TipPriority{ + Priority: TipPriorityNormal, + }, + ErrorExpected: false, + }, + { + Input: `"fast"`, + Expected: TipPriority{ + Priority: TipPriorityFast, + }, + ErrorExpected: false, + }, + { + Input: "{\"custom\": 1234}", + Expected: TipPriority{ + Custom: &temp, + }, + ErrorExpected: false, + }, + } + + for _, test := range testCases { + t.Run(test.Input, func(t *testing.T) { + t.Parallel() + + if test.Expected.Priority != 0 { + CompareEnumsHelper(t, test.Input, test.Expected.Priority, test.ErrorExpected) + assert.Nil(t, test.Expected.Custom) + + return + } + + // test unmarshalling + var localTip TipPriority + err := localTip.UnmarshalJSON([]byte(test.Input)) + require.NoError(t, err) + + assert.Equal(t, test.Expected.Custom, localTip.Custom) + assert.Zero(t, localTip.Priority) + + // test marshalling + raw, err := localTip.MarshalJSON() + require.NoError(t, err) + assert.JSONEq(t, test.Input, string(raw)) + }) + } +} + +// Test the BuildTransaction method with different transaction types and fee modes. +func TestBuildTransaction(t *testing.T) { + t.Parallel() + + // *** setup for deploy type transactions + classHash := internalUtils.TestHexToFelt( + t, + "0x61dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f", // OZ account class hash + ) + + deploymentData := &AccountDeploymentData{ + Address: internalUtils.TestHexToFelt( + t, + "0x736b7c3fac1586518b55cccac1f675ca1bd0570d7354e2f2d23a0975a31f220", + ), + ClassHash: classHash, + Salt: internalUtils.DeadBeef, + Calldata: []*felt.Felt{internalUtils.DeadBeef}, + SignatureData: []*felt.Felt{internalUtils.DeadBeef}, + Version: Cairo1, + } + + // *** setup for invoke type transactions + accountAddress := internalUtils.TestHexToFelt( + t, + "0x5c74db20fa8f151bfd3a7a462cf2e8d4578a88aa4bd7a1746955201c48d8e5e", + ) + transferAmount, _ := internalUtils.HexToU256Felt("0xfff") + + invokeData := &UserInvoke{ + UserAddress: accountAddress, + Calls: []Call{ + { + To: STRKContractAddress, + Selector: internalUtils.GetSelectorFromNameFelt("transfer"), + Calldata: append([]*felt.Felt{accountAddress}, transferAmount...), + }, + { + // same ERC20 contract as in examples/simpleInvoke + To: internalUtils.TestHexToFelt( + t, + "0x0669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54", + ), + Selector: internalUtils.GetSelectorFromNameFelt("mint"), + Calldata: []*felt.Felt{new(felt.Felt).SetUint64(10000), &felt.Zero}, + }, + }, + } + + t.Run("integration", func(t *testing.T) { + t.Parallel() + tests.RunTestOn(t, tests.IntegrationEnv) + + t.Run("deploy transaction type", func(t *testing.T) { + t.Parallel() + // *** build request + reqBody := BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnDeploy, + Deployment: deploymentData, + }, + Parameters: UserParameters{}, + } + + t.Run("sponsored fee mode", func(t *testing.T) { + t.Parallel() + pm, spy := SetupPaymaster(t) + + request := reqBody + request.Parameters = UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + }, + } + + resp, err := pm.BuildTransaction(t.Context(), &request) + require.NoError(t, err) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(spy.LastResponse()), string(rawResp)) + }) + + t.Run("default fee mode", func(t *testing.T) { + t.Parallel() + pm, _ := SetupPaymaster(t) + + request := reqBody + request.Parameters = UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeDefault, + GasToken: STRKContractAddress, + Tip: nil, + }, + } + + _, err := pm.BuildTransaction(t.Context(), &request) + require.Error( + t, + err, + ) // it seems that the default fee mode is not supported for the 'deploy' transaction type + }) + }) + + t.Run("invoke transaction type", func(t *testing.T) { + t.Parallel() + + reqBody := BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnInvoke, + Invoke: invokeData, + }, + Parameters: UserParameters{}, + } + + t.Run("sponsored fee mode - with nil tip", func(t *testing.T) { + t.Parallel() + pm, spy := SetupPaymaster(t) + + request := reqBody + request.Parameters = UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + }, + } + + resp, err := pm.BuildTransaction(t.Context(), &request) + require.NoError(t, err) + // The default tip priority is normal + assert.Equal(t, TipPriorityNormal, resp.Parameters.FeeMode.Tip.Priority) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(spy.LastResponse()), string(rawResp)) + }) + + t.Run("default fee mode - with custom tip", func(t *testing.T) { + t.Parallel() + pm, spy := SetupPaymaster(t) + + customTip := uint64(1000) + request := reqBody + request.Parameters = UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeDefault, + GasToken: STRKContractAddress, + Tip: &TipPriority{ + Custom: &customTip, + }, + }, + } + + resp, err := pm.BuildTransaction(t.Context(), &request) + require.NoError(t, err) + + assert.Equal(t, customTip, *resp.Parameters.FeeMode.Tip.Custom) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(spy.LastResponse()), string(rawResp)) + }) + }) + + t.Run("deploy-and-invoke transaction type", func(t *testing.T) { + t.Parallel() + + reqBody := BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnDeployAndInvoke, + Invoke: invokeData, + }, + Parameters: UserParameters{}, + } + reqBody.Transaction.Deployment = deploymentData + reqBody.Transaction.Invoke = invokeData + + t.Run("sponsored fee mode - with slow tip", func(t *testing.T) { + t.Parallel() + pm, spy := SetupPaymaster(t) + + request := reqBody + request.Parameters = UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + Tip: &TipPriority{ + Priority: TipPrioritySlow, + }, + }, + } + + resp, err := pm.BuildTransaction(t.Context(), &request) + require.NoError(t, err) + + assert.Equal(t, TipPrioritySlow, resp.Parameters.FeeMode.Tip.Priority) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(spy.LastResponse()), string(rawResp)) + }) + + t.Run("default fee mode - with fast tip", func(t *testing.T) { + t.Parallel() + pm, spy := SetupPaymaster(t) + + request := reqBody + request.Parameters = UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeDefault, + GasToken: STRKContractAddress, + Tip: &TipPriority{ + Priority: TipPriorityFast, + }, + }, + } + + resp, err := pm.BuildTransaction(t.Context(), &request) + require.NoError(t, err) + + assert.Equal(t, TipPriorityFast, resp.Parameters.FeeMode.Tip.Priority) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(spy.LastResponse()), string(rawResp)) + }) + }) + }) + + t.Run("mock", func(t *testing.T) { + t.Parallel() + tests.RunTestOn(t, tests.MockEnv) + + t.Run("deploy transaction type - sponsored fee mode", func(t *testing.T) { + t.Parallel() + // *** build request + request := BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnDeploy, + Deployment: deploymentData, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + }, + }, + } + + // *** assert the request marshalled is equal to the expected request + expectedReqs := *internalUtils.TestUnmarshalJSONFileToType[[]json.RawMessage](t, "testdata/build_txn/deploy-request.json", "params") + expectedReq := expectedReqs[0] + + rawReq, err := json.Marshal(request) + require.NoError(t, err) + + assert.JSONEq(t, string(expectedReq), string(rawReq)) + + // *** assert the response marshalled is equal to the expected response + expectedResp := *internalUtils.TestUnmarshalJSONFileToType[json.RawMessage](t, "testdata/build_txn/deploy-response.json", "result") + + var response BuildTransactionResponse + err = json.Unmarshal(expectedResp, &response) + require.NoError(t, err) + + pm := SetupMockPaymaster(t) + pm.c.EXPECT().CallContextWithSliceArgs( + t.Context(), + gomock.AssignableToTypeOf(new(BuildTransactionResponse)), + "paymaster_buildTransaction", + &request, + ).Return(nil). + SetArg(1, response) + + resp, err := pm.BuildTransaction(t.Context(), &request) + require.NoError(t, err) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(expectedResp), string(rawResp)) + }) + + t.Run("invoke transaction type - default fee mode with custom tip", func(t *testing.T) { + t.Parallel() + // *** build request + customTip := uint64(1000) + + request := BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnInvoke, + Invoke: invokeData, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeDefault, + GasToken: STRKContractAddress, + Tip: &TipPriority{ + Custom: &customTip, + }, + }, + }, + } + + // *** assert the request marshalled is equal to the expected request + expectedReqs := *internalUtils.TestUnmarshalJSONFileToType[[]json.RawMessage](t, "testdata/build_txn/invoke-request.json", "params") + expectedReq := expectedReqs[0] + + rawReq, err := json.Marshal(request) + require.NoError(t, err) + + assert.JSONEq(t, string(expectedReq), string(rawReq)) + + // *** assert the response marshalled is equal to the expected response + expectedResp := *internalUtils.TestUnmarshalJSONFileToType[json.RawMessage](t, "testdata/build_txn/invoke-response.json", "result") + + var response BuildTransactionResponse + err = json.Unmarshal(expectedResp, &response) + require.NoError(t, err) + + pm := SetupMockPaymaster(t) + pm.c.EXPECT().CallContextWithSliceArgs( + t.Context(), + gomock.AssignableToTypeOf(new(BuildTransactionResponse)), + "paymaster_buildTransaction", + &request, + ).Return(nil). + SetArg(1, response) + + resp, err := pm.BuildTransaction(t.Context(), &request) + require.NoError(t, err) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(expectedResp), string(rawResp)) + }) + + t.Run( + "deploy-and-invoke transaction type - sponsored fee mode with slow tip priority", + func(t *testing.T) { + t.Parallel() + // *** build request + request := BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnDeployAndInvoke, + Deployment: deploymentData, + Invoke: invokeData, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + Tip: &TipPriority{ + Priority: TipPrioritySlow, + }, + }, + }, + } + + // *** assert the request marshalled is equal to the expected request + expectedReqs := *internalUtils.TestUnmarshalJSONFileToType[[]json.RawMessage](t, "testdata/build_txn/deploy_and_invoke-request.json", "params") + expectedReq := expectedReqs[0] + + rawReq, err := json.Marshal(request) + require.NoError(t, err) + + assert.JSONEq(t, string(expectedReq), string(rawReq)) + + // *** assert the response marshalled is equal to the expected response + expectedResp := *internalUtils.TestUnmarshalJSONFileToType[json.RawMessage](t, "testdata/build_txn/deploy_and_invoke-response.json", "result") + + var response BuildTransactionResponse + err = json.Unmarshal(expectedResp, &response) + require.NoError(t, err) + + pm := SetupMockPaymaster(t) + pm.c.EXPECT().CallContextWithSliceArgs( + t.Context(), + gomock.AssignableToTypeOf(new(BuildTransactionResponse)), + "paymaster_buildTransaction", + &request, + ).Return(nil). + SetArg(1, response) + + resp, err := pm.BuildTransaction(t.Context(), &request) + require.NoError(t, err) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(expectedResp), string(rawResp)) + }, + ) + }) +} diff --git a/paymaster/errors.go b/paymaster/errors.go new file mode 100644 index 00000000..b5c9fb1c --- /dev/null +++ b/paymaster/errors.go @@ -0,0 +1,84 @@ +package paymaster + +import ( + "github.com/NethermindEth/starknet.go/client/rpcerr" +) + +// aliases to facilitate usage + +type ( + RPCError = rpcerr.RPCError + StringErrData = rpcerr.StringErrData +) + +// Paymaster-specific errors based on SNIP-29 specification +// +//nolint:exhaustruct // Omitting the Data field for errors that don't have data. +var ( + ErrInvalidAddress = &RPCError{ + Code: 150, + Message: "An error occurred (INVALID_ADDRESS)", + } + + ErrTokenNotSupported = &RPCError{ + Code: 151, + Message: "An error occurred (TOKEN_NOT_SUPPORTED)", + } + + ErrInvalidSignature = &RPCError{ + Code: 153, + Message: "An error occurred (INVALID_SIGNATURE)", + } + + ErrMaxAmountTooLow = &RPCError{ + Code: 154, + Message: "An error occurred (MAX_AMOUNT_TOO_LOW)", + } + + ErrClassHashNotSupported = &RPCError{ + Code: 155, + Message: "An error occurred (CLASS_HASH_NOT_SUPPORTED)", + } + + ErrTransactionExecutionError = &RPCError{ + Code: 156, + Message: "An error occurred (TRANSACTION_EXECUTION_ERROR)", + Data: &TxnExecutionErrData{}, + } + + ErrInvalidTimeBounds = &RPCError{ + Code: 157, + Message: "An error occurred (INVALID_TIME_BOUNDS)", + } + + ErrInvalidDeploymentData = &RPCError{ + Code: 158, + Message: "An error occurred (INVALID_DEPLOYMENT_DATA)", + } + + ErrInvalidClassHash = &RPCError{ + Code: 159, + Message: "An error occurred (INVALID_ADDRESS)", + } + + ErrInvalidID = &RPCError{ + Code: 160, + Message: "An error occurred (INVALID_ID)", + } + + ErrUnknownError = &RPCError{ + Code: 163, + Message: "An error occurred (UNKNOWN_ERROR)", + Data: StringErrData(""), + } +) + +// TxnExecutionErrData represents the structured data for TRANSACTION_EXECUTION_ERROR +type TxnExecutionErrData struct { + ExecutionError string `json:"execution_error"` +} + +// ErrorMessage implements the RPCData interface +func (t TxnExecutionErrData) ErrorMessage() string { + return t.ExecutionError +} diff --git a/paymaster/execute_txn.go b/paymaster/execute_txn.go new file mode 100644 index 00000000..c12d123b --- /dev/null +++ b/paymaster/execute_txn.go @@ -0,0 +1,92 @@ +package paymaster + +import ( + "context" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/client/rpcerr" + "github.com/NethermindEth/starknet.go/typedata" +) + +// ExecuteTransaction sends the signed typed data to the paymaster service for execution +// +// Parameters: +// - ctx: The context.Context object for controlling the function call +// - request: The signed typed data of the transaction to be executed by the paymaster service +// +// Returns: +// - *ExecuteTransactionResponse: The hash of the transaction broadcasted by the paymaster and +// the tracking ID corresponding to the user `execute` request +// - error: An error if any error occurs +func (p *Paymaster) ExecuteTransaction( + ctx context.Context, + request *ExecuteTransactionRequest, +) (ExecuteTransactionResponse, error) { + var response ExecuteTransactionResponse + if err := p.c.CallContextWithSliceArgs( + ctx, + &response, + "paymaster_executeTransaction", + request, + ); err != nil { + return response, rpcerr.UnwrapToRPCErr( + err, + ErrInvalidAddress, + ErrClassHashNotSupported, + ErrInvalidDeploymentData, + ErrInvalidSignature, + ErrUnknownError, + ErrMaxAmountTooLow, + ErrTransactionExecutionError, + ) + } + + return response, nil +} + +// ExecuteTransactionRequest is the request to execute a transaction +// via the paymaster (transaction + parameters). +type ExecuteTransactionRequest struct { + // Typed data build by calling paymaster_buildTransaction signed by the + // user to be executed by the paymaster service + Transaction ExecutableUserTransaction `json:"transaction"` + // Execution parameters to be used when executing the transaction + Parameters UserParameters `json:"parameters"` +} + +// ExecutableUserTransaction is a user transaction ready for execution +// (deploy, invoke, or both). +type ExecutableUserTransaction struct { + // The type of the transaction to be executed by the paymaster + Type UserTxnType `json:"type"` + // The deployment data for the transaction, used for `deploy` and + // `deploy_and_invoke` transaction types. + // Should be `nil` for `invoke` transaction types. + Deployment *AccountDeploymentData `json:"deployment,omitempty"` + // Invoke data signed by the user to be executed by the paymaster service, used for`invoke` and + // `deploy_and_invoke` transaction types. + // Should be `nil` for `deploy` transaction types. + Invoke *ExecutableUserInvoke `json:"invoke,omitempty"` +} + +// ExecutableUserInvoke is a signed typed data of an invoke transaction ready +// to be executed by the paymaster service. +type ExecutableUserInvoke struct { + // The address of the user account + UserAddress *felt.Felt `json:"user_address"` + // Typed data returned by the endpoint paymaster_buildTransaction + TypedData *typedata.TypedData `json:"typed_data"` + // Signature of the associated Typed Data + Signature []*felt.Felt `json:"signature"` +} + +// ExecuteTransactionResponse is the response from executing a transaction +// (tracking ID and transaction hash). +type ExecuteTransactionResponse struct { + // A unique identifier used to track an execution request of a user. Its purpose is to track + // possibly different transactions sent by the paymaster and which are associated with a same + // user request. Such cases can happen during congestion, where a fee or tip bump may be needed + // in order for a transaction to enter a block + TrackingID *felt.Felt `json:"tracking_id"` + TransactionHash *felt.Felt `json:"transaction_hash"` +} diff --git a/paymaster/execute_txn_test.go b/paymaster/execute_txn_test.go new file mode 100644 index 00000000..94458d2e --- /dev/null +++ b/paymaster/execute_txn_test.go @@ -0,0 +1,500 @@ +package paymaster + +import ( + "encoding/json" + "testing" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/contracts" + "github.com/NethermindEth/starknet.go/curve" + "github.com/NethermindEth/starknet.go/internal/tests" + internalUtils "github.com/NethermindEth/starknet.go/internal/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestExecuteTransaction(t *testing.T) { + t.Parallel() + + t.Run("integration", func(t *testing.T) { + t.Parallel() + tests.RunTestOn(t, tests.IntegrationEnv) + + privKey, pubKey, _, err := curve.GetRandomKeys() + require.NoError(t, err) + + pubKeyFelt := new(felt.Felt).SetBigInt(pubKey) + privKeyFelt := new(felt.Felt).SetBigInt(privKey) + + t.Run("execute deploy transaction", func(t *testing.T) { + t.Parallel() + + pm, spy := SetupPaymaster(t) + t.Log("paymster successfully initialised") + + deployTxn := buildDeployTxn(t, pm, pubKeyFelt) + assert.NotNil(t, deployTxn) + + t.Log("executing the deploy transaction in the paymaster") + + request := ExecuteTransactionRequest{ + Transaction: ExecutableUserTransaction{ + Type: UserTxnDeploy, + Deployment: deployTxn.Deployment, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + Tip: &TipPriority{ + Priority: TipPriorityNormal, + }, + }, + }, + } + + resp, err := pm.ExecuteTransaction(t.Context(), &request) + require.NoError(t, err) + + t.Log("transaction successfully executed") + t.Logf("Tracking ID: %s", resp.TrackingID) + t.Logf("Transaction Hash: %s", resp.TransactionHash) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(spy.LastResponse()), string(rawResp)) + }) + + t.Run("execute invoke transaction", func(t *testing.T) { + t.Parallel() + + pm, spy := SetupPaymaster(t) + t.Log("paymaster successfully initialised") + + privK, _, accAdd := GetStrkAccountData(t) + t.Log("account data fetched") + + invokeTxn := buildInvokeTxn(t, pm, accAdd) + assert.NotNil(t, invokeTxn) + + mshHash, err := invokeTxn.TypedData.GetMessageHash(accAdd.String()) + require.NoError(t, err) + t.Log("message hash:", mshHash) + + r, s, err := curve.SignFelts(mshHash, privK) + require.NoError(t, err) + t.Log("typed data signature:", r, s) + + t.Log("executing the invoke transaction in the paymaster") + + request := ExecuteTransactionRequest{ + Transaction: ExecutableUserTransaction{ + Type: UserTxnInvoke, + Invoke: &ExecutableUserInvoke{ + UserAddress: accAdd, + TypedData: invokeTxn.TypedData, + Signature: []*felt.Felt{r, s}, + }, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + Tip: &TipPriority{ + Priority: TipPriorityNormal, + }, + }, + }, + } + + resp, err := pm.ExecuteTransaction(t.Context(), &request) + require.NoError(t, err) + + t.Log("transaction successfully executed") + t.Logf("Tracking ID: %s", resp.TrackingID) + t.Logf("Transaction Hash: %s", resp.TransactionHash) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(spy.LastResponse()), string(rawResp)) + }) + + t.Run("execute deploy_and_invoke transaction", func(t *testing.T) { + t.Parallel() + + pm, spy := SetupPaymaster(t) + t.Log("paymster successfully initialised") + + builtTxn := buildDeployAndInvokeTxn(t, pm, pubKeyFelt) + assert.NotNil(t, builtTxn) + + accAdd := builtTxn.Deployment.Address + mshHash, err := builtTxn.TypedData.GetMessageHash(accAdd.String()) + require.NoError(t, err) + t.Log("message hash:", mshHash) + + r, s, err := curve.SignFelts(mshHash, privKeyFelt) + require.NoError(t, err) + t.Log("typed data signature:", r, s) + + t.Log("executing the deploy_and_invoke transaction in the paymaster") + + request := ExecuteTransactionRequest{ + Transaction: ExecutableUserTransaction{ + Type: UserTxnDeployAndInvoke, + Deployment: builtTxn.Deployment, + Invoke: &ExecutableUserInvoke{ + UserAddress: accAdd, + TypedData: builtTxn.TypedData, + Signature: []*felt.Felt{r, s}, + }, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + Tip: &TipPriority{ + Priority: TipPriorityFast, + }, + }, + }, + } + + resp, err := pm.ExecuteTransaction(t.Context(), &request) + require.NoError(t, err) + + t.Log("transaction successfully executed") + t.Logf("Tracking ID: %s", resp.TrackingID) + t.Logf("Transaction Hash: %s", resp.TransactionHash) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(spy.LastResponse()), string(rawResp)) + }) + }) + + t.Run("mock", func(t *testing.T) { + t.Parallel() + tests.RunTestOn(t, tests.MockEnv) + + pubKey := internalUtils.TestHexToFelt( + t, + "0x1cf6046c81f47d488c528e52066482f6756029bed10cf5df35608bb8eebac9", + ) + + t.Run("execute deploy transaction", func(t *testing.T) { + t.Parallel() + t.Log("building deploy request") + + deploymentData := createDeploymentData(t, pubKey) + + request := ExecuteTransactionRequest{ + Transaction: ExecutableUserTransaction{ + Type: UserTxnDeploy, + Deployment: deploymentData, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + }, + }, + } + + t.Log("asserting the request marshalled is equal to the expected request") + expectedReqs := *internalUtils.TestUnmarshalJSONFileToType[[]json.RawMessage](t, "testdata/execute_txn/deploy-request.json", "params") + expectedReq := expectedReqs[0] + + rawReq, err := json.Marshal(request) + require.NoError(t, err) + + assert.JSONEq(t, string(expectedReq), string(rawReq)) + + t.Log("asserting the response marshalled is equal to the expected response") + expectedResp := *internalUtils.TestUnmarshalJSONFileToType[json.RawMessage](t, "testdata/execute_txn/response.json", "result") + + var response ExecuteTransactionResponse + err = json.Unmarshal(expectedResp, &response) + require.NoError(t, err) + + t.Log("setting up mock paymaster and mock call") + pm := SetupMockPaymaster(t) + pm.c.EXPECT().CallContextWithSliceArgs( + t.Context(), + gomock.AssignableToTypeOf(new(ExecuteTransactionResponse)), + "paymaster_executeTransaction", + &request, + ).Return(nil). + SetArg(1, response) + + resp, err := pm.ExecuteTransaction(t.Context(), &request) + require.NoError(t, err) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(expectedResp), string(rawResp)) + }) + + //nolint:dupl // A function just to wrap the test body is overkill + t.Run("execute invoke transaction", func(t *testing.T) { + t.Parallel() + t.Log("building invoke request") + + t.Log("asserting the request marshalled is equal to the expected request") + expectedReqs := *internalUtils.TestUnmarshalJSONFileToType[[]json.RawMessage](t, "testdata/execute_txn/invoke-request.json", "params") + expectedReq := expectedReqs[0] + + // since the invoke request is more complex, let's take it from the file + var request ExecuteTransactionRequest + err := json.Unmarshal(expectedReq, &request) + require.NoError(t, err) + + rawReq, err := json.Marshal(request) + require.NoError(t, err) + + // assert if the MarshalJSON is correct + assert.JSONEq(t, string(expectedReq), string(rawReq)) + + t.Log("asserting the response marshalled is equal to the expected response") + expectedResp := *internalUtils.TestUnmarshalJSONFileToType[json.RawMessage](t, "testdata/execute_txn/response.json", "result") + + var response ExecuteTransactionResponse + err = json.Unmarshal(expectedResp, &response) + require.NoError(t, err) + + t.Log("setting up mock paymaster and mock call") + pm := SetupMockPaymaster(t) + pm.c.EXPECT().CallContextWithSliceArgs( + t.Context(), + gomock.AssignableToTypeOf(new(ExecuteTransactionResponse)), + "paymaster_executeTransaction", + &request, + ).Return(nil). + SetArg(1, response) + + t.Log("executing the invoke transaction in the mock paymaster") + resp, err := pm.ExecuteTransaction(t.Context(), &request) + require.NoError(t, err) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(expectedResp), string(rawResp)) + }) + + //nolint:dupl // A function just to wrap the test body is overkill + t.Run("execute deploy_and_invoke transaction", func(t *testing.T) { + t.Parallel() + t.Log("building deploy_and_invoke request") + + t.Log("asserting the request marshalled is equal to the expected request") + expectedReqs := *internalUtils.TestUnmarshalJSONFileToType[[]json.RawMessage](t, "testdata/execute_txn/deploy_and_invoke-request.json", "params") + expectedReq := expectedReqs[0] + + // since the deploy_and_invoke request is more complex, let's take it from the file + var request ExecuteTransactionRequest + err := json.Unmarshal(expectedReq, &request) + require.NoError(t, err) + + rawReq, err := json.Marshal(request) + require.NoError(t, err) + + // assert if the MarshalJSON is correct + assert.JSONEq(t, string(expectedReq), string(rawReq)) + + t.Log("asserting the response marshalled is equal to the expected response") + expectedResp := *internalUtils.TestUnmarshalJSONFileToType[json.RawMessage](t, "testdata/execute_txn/response.json", "result") + + var response ExecuteTransactionResponse + err = json.Unmarshal(expectedResp, &response) + require.NoError(t, err) + + t.Log("setting up mock paymaster and mock call") + pm := SetupMockPaymaster(t) + pm.c.EXPECT().CallContextWithSliceArgs( + t.Context(), + gomock.AssignableToTypeOf(new(ExecuteTransactionResponse)), + "paymaster_executeTransaction", + &request, + ).Return(nil). + SetArg(1, response) + + t.Log("executing the deploy_and_invoke transaction in the mock paymaster") + resp, err := pm.ExecuteTransaction(t.Context(), &request) + require.NoError(t, err) + + rawResp, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, string(expectedResp), string(rawResp)) + }) + }) +} + +// same as account.PrecomputeAccountAddress, but to avoid circular dependency +func precomputeAccountAddress( + salt, classHash *felt.Felt, + constructorCalldata []*felt.Felt, +) *felt.Felt { + return contracts.PrecomputeAddress(&felt.Zero, salt, classHash, constructorCalldata) +} + +// createDeploymentData creates the deployment data for a deploy transaction +func createDeploymentData(t *testing.T, pubKey *felt.Felt) *AccountDeploymentData { + t.Helper() + + t.Log("creating deployment data") + + // Argent account class hash that supports outside executions + classHash := internalUtils.TestHexToFelt( + t, + "0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + ) + constructorCalldata := []*felt.Felt{&felt.Zero, pubKey, new(felt.Felt).SetUint64(1)} + precAddress := precomputeAccountAddress(internalUtils.DeadBeef, classHash, constructorCalldata) + t.Log("precomputed address:", precAddress) + + deploymentData := &AccountDeploymentData{ + Address: precAddress, + ClassHash: classHash, + Salt: internalUtils.DeadBeef, + Calldata: constructorCalldata, + SignatureData: []*felt.Felt{}, + Version: Cairo1, + } + t.Logf("deployment data: %+v", deploymentData) + + return deploymentData +} + +// buildDeployTxn builds a deploy transaction calling the paymaster_buildTransaction method +// +//nolint:dupl // It is similar to buildInvokeTxn, but it has small differences +func buildDeployTxn( + t *testing.T, + pm *Paymaster, + pubKey *felt.Felt, +) *BuildTransactionResponse { + t.Helper() + + t.Log("building deploy transaction") + t.Log("public key:", pubKey) + + deploymentData := createDeploymentData(t, pubKey) + + t.Log("calling paymaster_buildTransaction method") + + resp, err := pm.BuildTransaction(t.Context(), &BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnDeploy, + Deployment: deploymentData, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + }, + }, + }) + require.NoError(t, err) + t.Log("deploy transaction successfully built by the paymaster") + + return &resp +} + +// createInvokeData creates the invoke data for an invoke transaction +func createInvokeData(t *testing.T, accAdd *felt.Felt) *UserInvoke { + t.Helper() + + t.Log("creating invoke data") + + invokeData := &UserInvoke{ + UserAddress: accAdd, + Calls: []Call{ + { + // same ERC20 contract as in examples/simpleInvoke + To: internalUtils.TestHexToFelt( + t, + "0x0669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54", + ), + Selector: internalUtils.GetSelectorFromNameFelt("mint"), + Calldata: []*felt.Felt{new(felt.Felt).SetUint64(10000), &felt.Zero}, + }, + }, + } + t.Logf("invoke data: %+v", invokeData) + + return invokeData +} + +// buildInvokeTxn builds an invoke transaction calling the paymaster_buildTransaction method +// +//nolint:dupl // It is similar to buildDeployTxn, but it has small differences +func buildInvokeTxn( + t *testing.T, + pm *Paymaster, + accAdd *felt.Felt, +) *BuildTransactionResponse { + t.Helper() + + t.Log("building deploy transaction") + t.Log("account address:", accAdd) + + invokeData := createInvokeData(t, accAdd) + + t.Log("calling paymaster_buildTransaction method") + + resp, err := pm.BuildTransaction(t.Context(), &BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnInvoke, + Invoke: invokeData, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + }, + }, + }) + require.NoError(t, err) + t.Log("invoke transaction successfully built by the paymaster") + + return &resp +} + +// buildDeployAndInvokeTxn builds a deploy and invoke transaction calling the paymaster_buildTransaction method +func buildDeployAndInvokeTxn( + t *testing.T, + pm *Paymaster, + pubKey *felt.Felt, +) *BuildTransactionResponse { + t.Helper() + + t.Log("building deploy_and_invoke transaction") + t.Log("public key:", pubKey) + + deploymentData := createDeploymentData(t, pubKey) + invokeData := createInvokeData(t, deploymentData.Address) + + t.Log("calling paymaster_buildTransaction method") + + resp, err := pm.BuildTransaction(t.Context(), &BuildTransactionRequest{ + Transaction: UserTransaction{ + Type: UserTxnDeployAndInvoke, + Deployment: deploymentData, + Invoke: invokeData, + }, + Parameters: UserParameters{ + Version: UserParamV1, + FeeMode: FeeMode{ + Mode: FeeModeSponsored, + Tip: &TipPriority{ + Priority: TipPriorityFast, + }, + }, + }, + }) + require.NoError(t, err) + t.Log("deploy_and_invoke transaction successfully built by the paymaster") + + return &resp +} diff --git a/paymaster/get_tokens.go b/paymaster/get_tokens.go new file mode 100644 index 00000000..2444e063 --- /dev/null +++ b/paymaster/get_tokens.go @@ -0,0 +1,37 @@ +package paymaster + +import ( + "context" + + "github.com/NethermindEth/juno/core/felt" +) + +// Get a list of the tokens that the paymaster supports, together with their prices in STRK +// +// Parameters: +// - ctx: The context.Context object for controlling the function call +// +// Returns: +// - []TokenData: An array of token data +// - error: An error if any +func (p *Paymaster) GetSupportedTokens(ctx context.Context) ([]TokenData, error) { + var response []TokenData + if err := p.c.CallContextWithSliceArgs( + ctx, &response, "paymaster_getSupportedTokens", + ); err != nil { + return nil, err + } + + return response, nil +} + +// Object containing data about the token: contract address, number of +// decimals and current price in STRK +type TokenData struct { + // Token contract address + TokenAddress *felt.Felt `json:"token_address"` + // The number of decimals of the token + Decimals uint8 `json:"decimals"` + // Price in STRK (in FRI units) + PriceInStrk string `json:"price_in_strk"` // u256 as a hex string +} diff --git a/paymaster/get_tokens_test.go b/paymaster/get_tokens_test.go new file mode 100644 index 00000000..61f371db --- /dev/null +++ b/paymaster/get_tokens_test.go @@ -0,0 +1,75 @@ +package paymaster + +import ( + "context" + "encoding/json" + "testing" + + "github.com/NethermindEth/starknet.go/internal/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// Test the 'paymaster_getSupportedTokens' method +func TestGetSupportedTokens(t *testing.T) { + t.Parallel() + t.Run("integration", func(t *testing.T) { + tests.RunTestOn(t, tests.IntegrationEnv) + t.Parallel() + + pm, spy := SetupPaymaster(t) + tokens, err := pm.GetSupportedTokens(context.Background()) + require.NoError(t, err) + + rawResult, err := json.Marshal(tokens) + require.NoError(t, err) + assert.EqualValues(t, spy.LastResponse(), rawResult) + }) + + t.Run("mock", func(t *testing.T) { + tests.RunTestOn(t, tests.MockEnv) + t.Parallel() + + pm := SetupMockPaymaster(t) + + expectedRawResult := `[ + { + "token_address": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "decimals": 18, + "price_in_strk": "0x288aa92ed8c5539ae80" + }, + { + "token_address": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "decimals": 18, + "price_in_strk": "0xde0b6b3a7640000" + }, + { + "token_address": "0x53b40a647cedfca6ca84f542a0fe36736031905a9639a7f19a3c1e66bfd5080", + "decimals": 6, + "price_in_strk": "0x48e1ecdbbe883b08" + }, + { + "token_address": "0x30058f19ed447208015f6430f0102e8ab82d6c291566d7e73fe8e613c3d2ed", + "decimals": 6, + "price_in_strk": "0x2c3460a7992f8a" + } + ]` + + var expectedResult []TokenData + err := json.Unmarshal([]byte(expectedRawResult), &expectedResult) + require.NoError(t, err) + + pm.c.EXPECT(). + CallContextWithSliceArgs(context.Background(), gomock.AssignableToTypeOf(new([]TokenData)), "paymaster_getSupportedTokens"). + SetArg(1, expectedResult). + Return(nil) + result, err := pm.GetSupportedTokens(context.Background()) + assert.NoError(t, err) + assert.Equal(t, expectedResult, result) + + rawResult, err := json.Marshal(result) + require.NoError(t, err) + assert.JSONEq(t, expectedRawResult, string(rawResult)) + }) +} diff --git a/paymaster/is_available.go b/paymaster/is_available.go new file mode 100644 index 00000000..cf7817a9 --- /dev/null +++ b/paymaster/is_available.go @@ -0,0 +1,21 @@ +package paymaster + +import "context" + +// IsAvailable returns the status of the paymaster service. +// If the paymaster service is correctly functioning, return true. Else, return false +// +// Parameters: +// - ctx: The context.Context object for controlling the function call +// +// Returns: +// - bool: True if the paymaster service is correctly functioning, false otherwise +// - error: An error if any +func (p *Paymaster) IsAvailable(ctx context.Context) (bool, error) { + var response bool + if err := p.c.CallContextWithSliceArgs(ctx, &response, "paymaster_isAvailable"); err != nil { + return false, err + } + + return response, nil +} diff --git a/paymaster/is_available_test.go b/paymaster/is_available_test.go new file mode 100644 index 00000000..876157f0 --- /dev/null +++ b/paymaster/is_available_test.go @@ -0,0 +1,42 @@ +package paymaster + +import ( + "context" + "strconv" + "testing" + + "github.com/NethermindEth/starknet.go/internal/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// Test the 'paymaster_isAvailable' method +// +//nolint:tparallel // Each subtest runs in different environments +func TestIsAvailable(t *testing.T) { + t.Parallel() + t.Run("integration", func(t *testing.T) { + tests.RunTestOn(t, tests.IntegrationEnv) + + pm, spy := SetupPaymaster(t) + available, err := pm.IsAvailable(context.Background()) + require.NoError(t, err) + + assert.Equal(t, string(spy.LastResponse()), strconv.FormatBool(available)) + assert.True(t, available) + }) + + t.Run("mock", func(t *testing.T) { + tests.RunTestOn(t, tests.MockEnv) + + pm := SetupMockPaymaster(t) + pm.c.EXPECT(). + CallContextWithSliceArgs(context.Background(), gomock.AssignableToTypeOf(new(bool)), "paymaster_isAvailable"). + SetArg(1, true). + Return(nil) + available, err := pm.IsAvailable(context.Background()) + assert.NoError(t, err) + assert.True(t, available) + }) +} diff --git a/paymaster/main_test.go b/paymaster/main_test.go new file mode 100644 index 00000000..6a0a5dc7 --- /dev/null +++ b/paymaster/main_test.go @@ -0,0 +1,96 @@ +package paymaster + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/client" + "github.com/NethermindEth/starknet.go/internal/tests" + internalUtils "github.com/NethermindEth/starknet.go/internal/utils" + "github.com/NethermindEth/starknet.go/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +const avnuPaymasterURL = "https://sepolia.paymaster.avnu.fi" + +func TestMain(m *testing.M) { + tests.LoadEnv() + os.Exit(m.Run()) +} + +type MockPaymaster struct { + *Paymaster + // this should be a pointer to the mock client used in the Paymaster struct. + // This is intended to have an easy access to the mock client, without having to + // type cast it from the `callCloser` interface every time. + c *mocks.MockClient +} + +// Creates a real Sepolia paymaster client and a spy for integration tests. +func SetupPaymaster(t *testing.T, debug ...bool) (*Paymaster, tests.Spyer) { + t.Helper() + + apiKey := os.Getenv("AVNU_API_KEY") + require.NotEmpty(t, apiKey, "AVNU_API_KEY is not set") + apiHeader := client.WithHeader("x-paymaster-api-key", apiKey) + + pm, err := New(context.Background(), avnuPaymasterURL, apiHeader) + require.NoError(t, err, "failed to create paymaster client") + + spy := tests.NewJSONRPCSpy(pm.c, debug...) + pm.c = spy + + return pm, spy +} + +// Creates a mock paymaster client. +func SetupMockPaymaster(t *testing.T) *MockPaymaster { + t.Helper() + + pmClient := mocks.NewMockClient(gomock.NewController(t)) + mpm := &MockPaymaster{ + Paymaster: &Paymaster{c: pmClient}, + c: pmClient, + } + + return mpm +} + +// GetStrkAccountData returns the STRK account data from the environment variables. +// This is used for integration tests, where we need a real testnet account with STRK tokens. +func GetStrkAccountData(t *testing.T) (privKey, pubKey, accountAddress *felt.Felt) { + t.Helper() + + strkPrivKey := os.Getenv("STARKNET_PRIVATE_KEY") + strkPubKey := os.Getenv("STARKNET_PUBLIC_KEY") + strkAccountAddress := os.Getenv("STARKNET_ACCOUNT_ADDRESS") + + privKey = internalUtils.TestHexToFelt(t, strkPrivKey) + pubKey = internalUtils.TestHexToFelt(t, strkPubKey) + accountAddress = internalUtils.TestHexToFelt(t, strkAccountAddress) + + return privKey, pubKey, accountAddress +} + +// CompareEnumsHelper compares an enum type with the expected value and error expected. +func CompareEnumsHelper[T any](t *testing.T, input string, expected T, errorExpected bool) { + t.Helper() + + var actual T + err := json.Unmarshal([]byte(input), &actual) + if errorExpected { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + marshalled, err := json.Marshal(actual) + assert.NoError(t, err) + assert.Equal(t, input, string(marshalled)) + } +} diff --git a/paymaster/paymaster.go b/paymaster/paymaster.go new file mode 100644 index 00000000..90969efa --- /dev/null +++ b/paymaster/paymaster.go @@ -0,0 +1,80 @@ +package paymaster + +import ( + "context" + "net/http" + "net/http/cookiejar" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/client" + "golang.org/x/net/publicsuffix" +) + +// Paymaster is a client for interacting with a paymaster service via the SNIP-29 API. +// It provides methods to build and execute transactions, check service +// status, and track transaction status. +type Paymaster struct { + // c is the underlying client for the paymaster service. + c callCloser +} + +// Used to assert that the Paymaster struct implements all the paymaster methods. +// Ref: https://github.com/starknet-io/SNIPs/blob/ea46a8777d8c8d53a43f45b7beb1abcc301a1a69/assets/snip-29/paymaster_api.json +// +//nolint:lll // The link would be unclickable if we break the line. +type paymasterInterface interface { + IsAvailable(ctx context.Context) (bool, error) + GetSupportedTokens(ctx context.Context) ([]TokenData, error) + TrackingIDToLatestHash(ctx context.Context, trackingID *felt.Felt) (TrackingIDResponse, error) + BuildTransaction( + ctx context.Context, + request *BuildTransactionRequest, + ) (BuildTransactionResponse, error) + ExecuteTransaction( + ctx context.Context, + request *ExecuteTransactionRequest, + ) (ExecuteTransactionResponse, error) +} + +var _ paymasterInterface = (*Paymaster)(nil) + +// callCloser is an interface that defines the methods for calling a remote procedure. +// It was created to match the Client struct from the 'client' package. +type callCloser interface { + CallContext(ctx context.Context, result interface{}, method string, args interface{}) error + CallContextWithSliceArgs( + ctx context.Context, + result interface{}, + method string, + args ...interface{}, + ) error + Close() +} + +// Creates a new paymaster client for the given service URL. +// Additional options can be passed to the client to configure the connection. +// +// Parameters: +// - url: The URL of the paymaster service +// - options: Additional options to configure the client +// +// Returns: +// - *Paymaster: A new paymaster client instance +// - error: An error if the client creation fails +func New(ctx context.Context, url string, options ...client.ClientOption) (*Paymaster, error) { + jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + return nil, err + } + httpClient := &http.Client{Jar: jar} //nolint:exhaustruct // Only the Jar field is used. + // prepend the custom client to allow users to override + options = append([]client.ClientOption{client.WithHTTPClient(httpClient)}, options...) + c, err := client.DialOptions(ctx, url, options...) + if err != nil { + return nil, err + } + + paymaster := &Paymaster{c: c} + + return paymaster, nil +} diff --git a/paymaster/testdata/build_txn/deploy-request.json b/paymaster/testdata/build_txn/deploy-request.json new file mode 100644 index 00000000..736d70b1 --- /dev/null +++ b/paymaster/testdata/build_txn/deploy-request.json @@ -0,0 +1,31 @@ +{ + "jsonrpc":"2.0", + "method":"paymaster_buildTransaction", + "params":[ + { + "transaction":{ + "type":"deploy", + "deployment":{ + "address": "0x736b7c3fac1586518b55cccac1f675ca1bd0570d7354e2f2d23a0975a31f220", + "class_hash":"0x61dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f", + "salt":"0xdeadbeef", + "calldata":[ + "0xdeadbeef" + ], + "sigdata":[ + "0xdeadbeef" + ], + "version": 1 + } + }, + "parameters":{ + "version":"0x1", + "fee_mode":{ + "mode":"sponsored" + }, + "time_bounds": null + } +} + ], + "id":1 +} \ No newline at end of file diff --git a/paymaster/testdata/build_txn/deploy-response.json b/paymaster/testdata/build_txn/deploy-response.json new file mode 100644 index 00000000..19c12061 --- /dev/null +++ b/paymaster/testdata/build_txn/deploy-response.json @@ -0,0 +1,34 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "type": "deploy", + "deployment": { + "address": "0x736b7c3fac1586518b55cccac1f675ca1bd0570d7354e2f2d23a0975a31f220", + "class_hash": "0x61dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f", + "salt": "0xdeadbeef", + "calldata": [ + "0xdeadbeef" + ], + "sigdata": [ + "0xdeadbeef" + ], + "version": 1 + }, + "parameters": { + "version": "0x1", + "fee_mode": { + "mode": "sponsored", + "tip": "normal" + }, + "time_bounds": null + }, + "fee": { + "gas_token_price_in_strk": "0xde0b6b3a7640000", + "estimated_fee_in_strk": "0xd0867e191fcc0", + "estimated_fee_in_gas_token": "0xd0867e191fcc0", + "suggested_max_fee_in_strk": "0x4e326f496bec80", + "suggested_max_fee_in_gas_token": "0x4e326f496bec80" + } + } +} \ No newline at end of file diff --git a/paymaster/testdata/build_txn/deploy_and_invoke-request.json b/paymaster/testdata/build_txn/deploy_and_invoke-request.json new file mode 100644 index 00000000..a50671eb --- /dev/null +++ b/paymaster/testdata/build_txn/deploy_and_invoke-request.json @@ -0,0 +1,54 @@ +{ + "jsonrpc": "2.0", + "method": "paymaster_buildTransaction", + "params": [ + { + "transaction": { + "type": "deploy_and_invoke", + "deployment": { + "address": "0x736b7c3fac1586518b55cccac1f675ca1bd0570d7354e2f2d23a0975a31f220", + "class_hash": "0x61dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f", + "salt": "0xdeadbeef", + "calldata": [ + "0xdeadbeef" + ], + "sigdata": [ + "0xdeadbeef" + ], + "version": 1 + }, + "invoke": { + "user_address": "0x5c74db20fa8f151bfd3a7a462cf2e8d4578a88aa4bd7a1746955201c48d8e5e", + "calls": [ + { + "to": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "selector": "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "calldata": [ + "0x5c74db20fa8f151bfd3a7a462cf2e8d4578a88aa4bd7a1746955201c48d8e5e", + "0xfff", + "0x0" + ] + }, + { + "to": "0x669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54", + "selector": "0x2f0b3c5710379609eb5495f1ecd348cb28167711b73609fe565a72734550354", + "calldata": [ + "0x2710", + "0x0" + ] + } + ] + } + }, + "parameters": { + "version": "0x1", + "fee_mode": { + "mode": "sponsored", + "tip": "slow" + }, + "time_bounds": null + } + } + ], + "id": 1 +} \ No newline at end of file diff --git a/paymaster/testdata/build_txn/deploy_and_invoke-response.json b/paymaster/testdata/build_txn/deploy_and_invoke-response.json new file mode 100644 index 00000000..69c2ba89 --- /dev/null +++ b/paymaster/testdata/build_txn/deploy_and_invoke-response.json @@ -0,0 +1,124 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "type": "deploy_and_invoke", + "deployment": { + "address": "0x736b7c3fac1586518b55cccac1f675ca1bd0570d7354e2f2d23a0975a31f220", + "class_hash": "0x61dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f", + "salt": "0xdeadbeef", + "calldata": [ + "0xdeadbeef" + ], + "sigdata": [ + "0xdeadbeef" + ], + "version": 1 + }, + "typed_data": { + "types": { + "StarknetDomain": [ + { + "name": "name", + "type": "shortstring" + }, + { + "name": "version", + "type": "shortstring" + }, + { + "name": "chainId", + "type": "shortstring" + }, + { + "name": "revision", + "type": "shortstring" + } + ], + "OutsideExecution": [ + { + "name": "Caller", + "type": "ContractAddress" + }, + { + "name": "Nonce", + "type": "felt" + }, + { + "name": "Execute After", + "type": "u128" + }, + { + "name": "Execute Before", + "type": "u128" + }, + { + "name": "Calls", + "type": "Call*" + } + ], + "Call": [ + { + "name": "To", + "type": "ContractAddress" + }, + { + "name": "Selector", + "type": "selector" + }, + { + "name": "Calldata", + "type": "felt*" + } + ] + }, + "domain": { + "name": "Account.execute_from_outside", + "version": "2", + "chainId": "SN_SEPOLIA", + "revision": "1" + }, + "primaryType": "OutsideExecution", + "message": { + "Caller": "0x75a180e18e56da1b1cae181c92a288f586f5fe22c18df21cf97886f1e4b316c", + "Nonce": "0xc994e1f239c9edadcf4fc39693e03b7c", + "Execute After": "0x1", + "Execute Before": "0x68cb10b3", + "Calls": [ + { + "To": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "Selector": "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "Calldata": [ + "0x5c74db20fa8f151bfd3a7a462cf2e8d4578a88aa4bd7a1746955201c48d8e5e", + "0xfff", + "0x0" + ] + }, + { + "To": "0x669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54", + "Selector": "0x2f0b3c5710379609eb5495f1ecd348cb28167711b73609fe565a72734550354", + "Calldata": [ + "0x2710", + "0x0" + ] + } + ] + } + }, + "parameters": { + "version": "0x1", + "fee_mode": { + "mode": "sponsored", + "tip": "slow" + }, + "time_bounds": null + }, + "fee": { + "gas_token_price_in_strk": "0xde0b6b3a7640000", + "estimated_fee_in_strk": "0x1bb9c13f50bbc0", + "estimated_fee_in_gas_token": "0x1bb9c13f50bbc0", + "suggested_max_fee_in_strk": "0xa65a877be46680", + "suggested_max_fee_in_gas_token": "0xa65a877be46680" + } + } +} \ No newline at end of file diff --git a/paymaster/testdata/build_txn/invoke-request.json b/paymaster/testdata/build_txn/invoke-request.json new file mode 100644 index 00000000..83e5c3df --- /dev/null +++ b/paymaster/testdata/build_txn/invoke-request.json @@ -0,0 +1,45 @@ +{ + "jsonrpc": "2.0", + "method": "paymaster_buildTransaction", + "params": [ + { + "transaction": { + "type": "invoke", + "invoke": { + "user_address": "0x5c74db20fa8f151bfd3a7a462cf2e8d4578a88aa4bd7a1746955201c48d8e5e", + "calls": [ + { + "to": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "selector": "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "calldata": [ + "0x5c74db20fa8f151bfd3a7a462cf2e8d4578a88aa4bd7a1746955201c48d8e5e", + "0xfff", + "0x0" + ] + }, + { + "to": "0x669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54", + "selector": "0x2f0b3c5710379609eb5495f1ecd348cb28167711b73609fe565a72734550354", + "calldata": [ + "0x2710", + "0x0" + ] + } + ] + } + }, + "parameters": { + "version": "0x1", + "fee_mode": { + "mode": "default", + "gas_token": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "tip": { + "custom": 1000 + } + }, + "time_bounds": null + } + } + ], + "id": 1 +} \ No newline at end of file diff --git a/paymaster/testdata/build_txn/invoke-response.json b/paymaster/testdata/build_txn/invoke-response.json new file mode 100644 index 00000000..540d6002 --- /dev/null +++ b/paymaster/testdata/build_txn/invoke-response.json @@ -0,0 +1,124 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "type": "invoke", + "typed_data": { + "types": { + "StarknetDomain": [ + { + "name": "name", + "type": "shortstring" + }, + { + "name": "version", + "type": "shortstring" + }, + { + "name": "chainId", + "type": "shortstring" + }, + { + "name": "revision", + "type": "shortstring" + } + ], + "OutsideExecution": [ + { + "name": "Caller", + "type": "ContractAddress" + }, + { + "name": "Nonce", + "type": "felt" + }, + { + "name": "Execute After", + "type": "u128" + }, + { + "name": "Execute Before", + "type": "u128" + }, + { + "name": "Calls", + "type": "Call*" + } + ], + "Call": [ + { + "name": "To", + "type": "ContractAddress" + }, + { + "name": "Selector", + "type": "selector" + }, + { + "name": "Calldata", + "type": "felt*" + } + ] + }, + "domain": { + "name": "Account.execute_from_outside", + "version": "2", + "chainId": "SN_SEPOLIA", + "revision": "1" + }, + "primaryType": "OutsideExecution", + "message": { + "Caller": "0x75a180e18e56da1b1cae181c92a288f586f5fe22c18df21cf97886f1e4b316c", + "Nonce": "0x7bde5b6d5ffaa858a445a4ebad13786", + "Execute After": "0x1", + "Execute Before": "0x68cb0db5", + "Calls": [ + { + "To": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "Selector": "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "Calldata": [ + "0x5c74db20fa8f151bfd3a7a462cf2e8d4578a88aa4bd7a1746955201c48d8e5e", + "0xfff", + "0x0" + ] + }, + { + "To": "0x669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54", + "Selector": "0x2f0b3c5710379609eb5495f1ecd348cb28167711b73609fe565a72734550354", + "Calldata": [ + "0x2710", + "0x0" + ] + }, + { + "To": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "Selector": "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "Calldata": [ + "0x75a180e18e56da1b1cae181c92a288f586f5fe22c18df21cf97886f1e4b316c", + "0x6bcd6cebb3e040", + "0x0" + ] + } + ] + } + }, + "parameters": { + "version": "0x1", + "fee_mode": { + "mode": "default", + "gas_token": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "tip": { + "custom": 1000 + } + }, + "time_bounds": null + }, + "fee": { + "gas_token_price_in_strk": "0xde0b6b3a7640000", + "estimated_fee_in_strk": "0x11f7922748a560", + "estimated_fee_in_gas_token": "0x11f7922748a560", + "suggested_max_fee_in_strk": "0x6bcd6cebb3e040", + "suggested_max_fee_in_gas_token": "0x6bcd6cebb3e040" + } + } +} \ No newline at end of file diff --git a/paymaster/testdata/execute_txn/deploy-request.json b/paymaster/testdata/execute_txn/deploy-request.json new file mode 100644 index 00000000..b6988bcf --- /dev/null +++ b/paymaster/testdata/execute_txn/deploy-request.json @@ -0,0 +1,30 @@ +{ + "jsonrpc": "2.0", + "method": "paymaster_executeTransaction", + "params": [ + { + "transaction": { + "type": "deploy", + "deployment": { + "address": "0x71387536587c0e164c75c3ed972fee9aff1b261192749a752ce880c16ac3bf2", + "class_hash": "0x36078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "salt": "0xdeadbeef", + "calldata": [ + "0x0", + "0x1cf6046c81f47d488c528e52066482f6756029bed10cf5df35608bb8eebac9", + "0x1" + ], + "version": 1 + } + }, + "parameters": { + "version": "0x1", + "fee_mode": { + "mode": "sponsored" + }, + "time_bounds": null + } + } + ], + "id": 1 +} \ No newline at end of file diff --git a/paymaster/testdata/execute_txn/deploy_and_invoke-request.json b/paymaster/testdata/execute_txn/deploy_and_invoke-request.json new file mode 100644 index 00000000..22cb3d4c --- /dev/null +++ b/paymaster/testdata/execute_txn/deploy_and_invoke-request.json @@ -0,0 +1,119 @@ +{ + "jsonrpc": "2.0", + "method": "paymaster_executeTransaction", + "params": [ +{ + "transaction": { + "type": "deploy_and_invoke", + "deployment": { + "address": "0x14de49678059510c1544d1da042be2b5d0c80e03906ddcf6a24151b966e1492", + "class_hash": "0x36078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f", + "salt": "0xdeadbeef", + "calldata": [ + "0x0", + "0x13e195a5933270cd3c3cf637b72c4caaf9983ccb2a0da6181659c1042d7718", + "0x1" + ], + "version": 1 + }, + "invoke": { + "user_address": "0x14de49678059510c1544d1da042be2b5d0c80e03906ddcf6a24151b966e1492", + "typed_data": { + "types": { + "Call": [ + { + "name": "To", + "type": "ContractAddress" + }, + { + "name": "Selector", + "type": "selector" + }, + { + "name": "Calldata", + "type": "felt*" + } + ], + "OutsideExecution": [ + { + "name": "Caller", + "type": "ContractAddress" + }, + { + "name": "Nonce", + "type": "felt" + }, + { + "name": "Execute After", + "type": "u128" + }, + { + "name": "Execute Before", + "type": "u128" + }, + { + "name": "Calls", + "type": "Call*" + } + ], + "StarknetDomain": [ + { + "name": "name", + "type": "shortstring" + }, + { + "name": "version", + "type": "shortstring" + }, + { + "name": "chainId", + "type": "shortstring" + }, + { + "name": "revision", + "type": "shortstring" + } + ] + }, + "primaryType": "OutsideExecution", + "domain": { + "name": "Account.execute_from_outside", + "version": "2", + "chainId": "SN_SEPOLIA", + "revision": "1" + }, + "message": { + "Caller": "0x75a180e18e56da1b1cae181c92a288f586f5fe22c18df21cf97886f1e4b316c", + "Calls": [ + { + "Calldata": [ + "0x2710", + "0x0" + ], + "Selector": "0x2f0b3c5710379609eb5495f1ecd348cb28167711b73609fe565a72734550354", + "To": "0x669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54" + } + ], + "Execute After": "0x1", + "Execute Before": "0x68d6b242", + "Nonce": "0x9ab1bbf59c0c9b82a74bcf6668c4f6c0" + } + }, + "signature": [ + "0x228fffde4140c340085a1a5db076816eda02876b4a19af7a96e396f24dcab76", + "0x5f44d545352b517ae1186b92de1ee9e12f6d030c5c90c99ef47063783344261" + ] + } + }, + "parameters": { + "version": "0x1", + "fee_mode": { + "mode": "sponsored", + "tip": "fast" + }, + "time_bounds": null + } +} + ], + "id": 1 +} \ No newline at end of file diff --git a/paymaster/testdata/execute_txn/invoke-request.json b/paymaster/testdata/execute_txn/invoke-request.json new file mode 100644 index 00000000..8c8b9e6b --- /dev/null +++ b/paymaster/testdata/execute_txn/invoke-request.json @@ -0,0 +1,108 @@ +{ + "jsonrpc": "2.0", + "method": "paymaster_executeTransaction", + "params": [ + { + "transaction": { + "type": "invoke", + "invoke": { + "user_address": "0x5c74db20fa8f151bfd3a7a462cf2e8d4578a88aa4bd7a1746955201c48d8e5e", + "typed_data": { + "types": { + "Call": [ + { + "name": "To", + "type": "ContractAddress" + }, + { + "name": "Selector", + "type": "selector" + }, + { + "name": "Calldata", + "type": "felt*" + } + ], + "OutsideExecution": [ + { + "name": "Caller", + "type": "ContractAddress" + }, + { + "name": "Nonce", + "type": "felt" + }, + { + "name": "Execute After", + "type": "u128" + }, + { + "name": "Execute Before", + "type": "u128" + }, + { + "name": "Calls", + "type": "Call*" + } + ], + "StarknetDomain": [ + { + "name": "name", + "type": "shortstring" + }, + { + "name": "version", + "type": "shortstring" + }, + { + "name": "chainId", + "type": "shortstring" + }, + { + "name": "revision", + "type": "shortstring" + } + ] + }, + "primaryType": "OutsideExecution", + "domain": { + "name": "Account.execute_from_outside", + "version": "2", + "chainId": "SN_SEPOLIA", + "revision": "1" + }, + "message": { + "Caller": "0x75a180e18e56da1b1cae181c92a288f586f5fe22c18df21cf97886f1e4b316c", + "Calls": [ + { + "Calldata": [ + "0x2710", + "0x0" + ], + "Selector": "0x2f0b3c5710379609eb5495f1ecd348cb28167711b73609fe565a72734550354", + "To": "0x669e24364ce0ae7ec2864fb03eedbe60cfbc9d1c74438d10fa4b86552907d54" + } + ], + "Execute After": "0x1", + "Execute Before": "0x68d6af77", + "Nonce": "0xbfb889ffaccd4489e04743330626a442" + } + }, + "signature": [ + "0x76ad9fcf754cbd7e56931eb3995ae5d8150732cfe8b6c669266084f2255c9c6", + "0x7ed536fdbb7b3479cf8e7ee0b9ca2067141ddb225174f073c28753403ecec21" + ] + } + }, + "parameters": { + "version": "0x1", + "fee_mode": { + "mode": "sponsored", + "tip": "normal" + }, + "time_bounds": null + } + } + ], + "id": 1 +} \ No newline at end of file diff --git a/paymaster/testdata/execute_txn/response.json b/paymaster/testdata/execute_txn/response.json new file mode 100644 index 00000000..a9795459 --- /dev/null +++ b/paymaster/testdata/execute_txn/response.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "transaction_hash": "0x303de97c66775a1e74fdb42df94fdf3cdb9a3225445461564fdcf46ae0ab419", + "tracking_id": "0x0" + } +} \ No newline at end of file diff --git a/paymaster/tracking_id.go b/paymaster/tracking_id.go new file mode 100644 index 00000000..73841ec0 --- /dev/null +++ b/paymaster/tracking_id.go @@ -0,0 +1,94 @@ +package paymaster + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/starknet.go/client/rpcerr" +) + +// TrackingIDToLatestHash gets the latest transaction hash and status for a given tracking ID. +// Returns a TrackingIdResponse. +// +// Parameters: +// - ctx: The context.Context object for controlling the function call +// - trackingID: A unique identifier used to track an execution request of a user. +// This identitifier is returned by the paymaster after a successful call to `execute`. +// Its purpose is to track the possibly different transaction hashes in the mempool which +// are associated with a same user request. +// +// Returns: +// - *TrackingIDResponse: The hash of the latest transaction broadcasted by the paymaster +// corresponding to the requested ID and the status of the ID. +// - error: An error if any +func (p *Paymaster) TrackingIDToLatestHash( + ctx context.Context, + trackingID *felt.Felt, +) (TrackingIDResponse, error) { + var response TrackingIDResponse + if err := p.c.CallContextWithSliceArgs( + ctx, + &response, + "paymaster_trackingIdToLatestHash", + trackingID, + ); err != nil { + return TrackingIDResponse{}, rpcerr.UnwrapToRPCErr(err, ErrInvalidID) + } + + return response, nil +} + +// TrackingIDResponse is the response for the `paymaster_trackingIdToLatestHash` method. +type TrackingIDResponse struct { + // The hash of the most recent tx sent by the paymaster and corresponding to the ID + TransactionHash *felt.Felt `json:"transaction_hash"` + // The status of the transaction associated with the ID + Status TxnStatus `json:"status"` +} + +// An enum representing the status of the transaction associated with a tracking ID +type TxnStatus string + +const ( + // Indicates that the latest transaction associated with the ID is not yet + // included in a block but is still being handled and monitored by the paymaster + TxnActive TxnStatus = "active" + // Indicates that a transaction associated with the ID has been accepted on L2 + TxnAccepted TxnStatus = "accepted" + // Indicates that no transaction associated with the ID managed to enter a block + // and the request has been dropped by the paymaster + TxnDropped TxnStatus = "dropped" +) + +// MarshalJSON marshals the TxnStatus to JSON. +func (t TxnStatus) MarshalJSON() ([]byte, error) { + switch t { + case TxnActive, TxnAccepted, TxnDropped: + return json.Marshal(string(t)) + } + + return nil, fmt.Errorf("invalid transaction status: %s", t) +} + +// UnmarshalJSON unmarshals the JSON data into a TxnStatus. +func (t *TxnStatus) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + switch s { + case "active": + *t = TxnActive + case "accepted": + *t = TxnAccepted + case "dropped": + *t = TxnDropped + default: + return fmt.Errorf("invalid transaction status: %s", s) + } + + return nil +} diff --git a/paymaster/tracking_id_test.go b/paymaster/tracking_id_test.go new file mode 100644 index 00000000..8dde2589 --- /dev/null +++ b/paymaster/tracking_id_test.go @@ -0,0 +1,94 @@ +package paymaster + +import ( + "context" + "encoding/json" + "testing" + + "github.com/NethermindEth/starknet.go/internal/tests" + internalUtils "github.com/NethermindEth/starknet.go/internal/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// Test the TxnStatus enum type +// +//nolint:dupl // The enum tests are similar, but with different enum values +func TestTxnStatusType(t *testing.T) { + tests.RunTestOn(t, tests.MockEnv) + t.Parallel() + + type testCase struct { + Input string + Expected TxnStatus + ErrorExpected bool + } + + testCases := []testCase{ + { + Input: `"active"`, + Expected: TxnActive, + ErrorExpected: false, + }, + { + Input: `"accepted"`, + Expected: TxnAccepted, + ErrorExpected: false, + }, + { + Input: `"dropped"`, + Expected: TxnDropped, + ErrorExpected: false, + }, + { + Input: `"unknown"`, + ErrorExpected: true, + }, + } + + for _, test := range testCases { + t.Run(test.Input, func(t *testing.T) { + t.Parallel() + CompareEnumsHelper(t, test.Input, test.Expected, test.ErrorExpected) + }) + } +} + +// Test the 'paymaster_trackingIdToLatestHash' method +func TestTrackingIdToLatestHash(t *testing.T) { + // The AVNU paymaster does not support this method yet, so we can't have integration tests + tests.RunTestOn(t, tests.MockEnv) + t.Parallel() + + expectedRawResp := `{ + "transaction_hash": "0xdeadbeef", + "status": "active" + }` + + var expectedResp TrackingIDResponse + err := json.Unmarshal([]byte(expectedRawResp), &expectedResp) + require.NoError(t, err) + + trackingID := internalUtils.DeadBeef + + pm := SetupMockPaymaster(t) + pm.c.EXPECT(). + CallContextWithSliceArgs( + context.Background(), + gomock.AssignableToTypeOf(new(TrackingIDResponse)), + "paymaster_trackingIdToLatestHash", + trackingID, + ). + SetArg(1, expectedResp). + Return(nil) + + response, err := pm.TrackingIDToLatestHash(context.Background(), trackingID) + require.NoError(t, err) + assert.Equal(t, TxnActive, response.Status) + assert.Equal(t, expectedResp.TransactionHash, response.TransactionHash) + + rawResp, err := json.Marshal(response) + require.NoError(t, err) + assert.JSONEq(t, expectedRawResp, string(rawResp)) +} diff --git a/rpc/contract.go b/rpc/contract.go index b45938c8..65bd5124 100644 --- a/rpc/contract.go +++ b/rpc/contract.go @@ -241,7 +241,7 @@ func (provider *Provider) EstimateMessageFee( // prefix of the requested leaf, thus effectively proving non-membership // - error: an error if any occurred during the execution // -//nolint:gocritic //TODO: pass the input as a pointer + func (provider *Provider) StorageProof( ctx context.Context, storageProofInput StorageProofInput, diff --git a/rpc/events.go b/rpc/events.go index cfe195f5..e9222b70 100644 --- a/rpc/events.go +++ b/rpc/events.go @@ -16,7 +16,7 @@ import ( // - eventChunk: The retrieved events // - error: An error if any // -//nolint:gocritic //TODO: pass the input as a pointer + func (provider *Provider) Events(ctx context.Context, input EventsInput) (*EventChunk, error) { var result EventChunk if err := do(ctx, provider.c, "starknet_getEvents", &result, input); err != nil { diff --git a/typedata/typedData.go b/typedata/typedData.go index 06e0ee4e..a89b1da4 100644 --- a/typedata/typedData.go +++ b/typedata/typedData.go @@ -1158,7 +1158,7 @@ func (domain Domain) MarshalJSON() ([]byte, error) { // MarshalJSON implements the json.Marshaler interface for TypeDefinition // -//nolint:gocritic // json.Marshaler interface requires a value receiver + func (td TypeDefinition) MarshalJSON() ([]byte, error) { return json.Marshal(td.Parameters) }