Gas Station (TypeScript)

How to sponsor transactions with Shinami on Sui

Overview

In this tutorial you'll learn how to sponsor transactions with Shinami's Gas Station and submit them for execution on the Sui blockchain. We also cover key tips for using our Gas Station.

For our examples, we'll be constructing our programmable transactions using the Sui TypeScript SDK provided by Mysten. We'll also use the Shinami Clients SDK to make JSON-RPC calls to Shinami Gas Station for transaction sponsorship and Node Service for transaction execution. This is not meant as a complete template for production code, but as a way to show you how to perform all the requests our Gas Station API supports.

What is a sponsored transaction?

When calling sui_executeTransactionBlock a Base64 encoded string is passed in which encodes the transactions to be executed, the sender address, and the gas information for the transaction block. This unit is called a TransactionData object. Along with one or more signatures, it's what's needed to write to the chain.

When a transaction is being sponsored, the sponsor sends Shinami Gas Station two parts of a TransactionData object: the sender address and the transaction block to be executed - with gas information omitted this is considered a TransactionKind object. Gas Station attaches a GasData object from your Gas Station fund that will pay for the transaction. Then, it produces the BCS-serialized, Base64-encoded string that represents the full TransactionData object, and sends it back to you along with the sponsor signature authorizing this transaction. From there, the last piece needed to call sui_executeTransactionBlock is the sender's signature over the TransactionData. We'll go through all of this below.

Gas Station requests must be from your BE

Our Gas Station does not support CORS, so if you attempt to make requests to it from the FE you'll get a CORS error. We do this because exposing a Gas Station API key on the FE is a security risk - an attacker could drain the SUI in your Gas Station fund associated with the key by using it to sponsor transactions. Here is a flow for transactions that depend on the FE for TransactionBlock inputs - e.g. a number or Sui address for a Move call - and a sender signature.

  1. Send the transaction inputs from the FE to the BE.
  2. Build and sponsor the transaction on the BE.
  3. Send the sponsored transaction bytes to the FE as a response to the request in step 1.
  4. Get the signature from the user (by prompting the user if it's a connected wallet, or doing it behind the scenes if it's a zkLogin wallet using the ephermal KeyPair).
  5. Send the signature to the BE.
  6. Execute the transaction from the BE.
  7. Send a successful or unsuccessful status back to the FE as a response to the request in step 5.

For an image of the above flow with zkLogin wallets, see the images in our Next.js zkLogin SDK Readme.

Understanding errors

Check the error code and message of any errors you get. We outline common errors in our Error Reference - make sure to check out the section specific to Sui Gas Station as well as the "General Error Codes" section at the top that apply to all Shinami services.

Create, sponsor, sign, and execute a transaction

Note: this tutorial requires a Shinami account. If you do not have one, you can sign up here - use referral code "Sui Gas Station".

1. Create a Shinami Gas Station fund on Testnet and deposit SUI

See our product FAQ for guidance on how to set up a fund on Testnet and add free SUI to it if you don't already have one.

2. Clone the github repo

Clone the shinami-examples github repo, cd into the shinami-examples/sui/typescript directory, and run npm installto install the dependencies for running the code. Run tsc in your terminal, and if the command isn't found run npm install typescript --save-dev (see other options here ) . If you need to install npm and Node.js, see here. Below, we'll be using the gas_station.ts file in the shinami-examples/sui/typescript/src directory.

3: Create an API access key and copy it into the file.

Create a key

You use API access keys to interact with Shinami's services. For this tutorial, we'll create one access key that has both Node Service and Gas Station rights for Testnet (you could also make two keys, one with only Node Service rights and one with only Gas Station rights). See our Authentication and API Keys guide for info on how to set up a key with rights to multiple services.

Add it to the gas_station.ts file

Once you have your key, use it as the value for the GAS_AND_NODE_TESTNET_ACCESS_KEY constant. You'll see that we immediately use it to instantiate a Gas Station client (for sponsoring transactions) and a Node client (for executing them).

// Copy your Testnet Gas Station and Node Service key value
const GAS_AND_NODE_TESTNET_ACCESS_KEY = "{{gasAndNodeServiceTestnetAccessKey}}";

// Set up your Gas Station and Node Service clients
const nodeClient = createSuiClient(GAS_AND_NODE_TESTNET_ACCESS_KEY);
const gasStationClient = new GasStationClient(GAS_AND_NODE_TESTNET_ACCESS_KEY);

4. Generate a new Keypair and log its secret key for subsequent runs

We need a Sui address to act as the sender, so we'll generate a new KeyPair. On the first run, the secret key will be printed to the console. We'll reuse the same address for all runs, so you can see all the history in one account.

Note: Your app should have it's own way to manage accounts and keys. This is just meant as a convenience when doing your initial tests with Gas Station using our tutorial code.

Here's what you'll do:

  1. Run the code by running the tsc command in a terminal at the root of the shinami-examples/sui/typescript directory to compile the .ts files in the /src directory.
  2. Run node build/gas_station.js to run the file.
  3. A secret key will be printed to the console, e.g. secretKey: suiprivkey... value.
  4. Before running the code again, replace the call to generateSecretKey() with the value of the secret key.

Initial code:

// Create a KeyPair to act as the sender
async function generateSecretKey() : Promise<string> {
  let keyPair = new Ed25519Keypair();
  console.log("secretKey:", keyPair.getSecretKey())
  return keyPair.getSecretKey();
}
let ENCODED_SECRET_KEY = await generateSecretKey(); // replace the function call with the key printed to the console

// Output: this is too short for an actual key, and never share your keys!!
// secretKey: suiprivkey1qqdn8409tumvyq9wvhjesr5a

After running the code, change it to:

// Step 4: Create a KeyPair to act as the sender
async function generateSecretKey() : Promise<string> {
  let keyPair = new Ed25519Keypair();
  // console.log("secretKey:", keyPair.getSecretKey())
  return keyPair.getSecretKey();
}
let ENCODED_SECRET_KEY = "suiprivkey1qqdn8409tumvyq9wvhjesr5a" // again, too short for a real key

5. Build, sponsor, sign, and execute a transaction

Viewing sponsorships in the Shinami dashboard

In anticipation of running the code, open up the Gas Station page of your Shinami dashboard and click on the "In-flight transactions" tab.

Right after running the code take a look at this page. It may take a few moments or clicking "Refresh data", but you'll see the digest of the transaction you sponsored appear. In-flight transactions will remain here until they are executed, or the sponsorship expires before an execution attempt was made (all sponsorships have a 1 hour TTL). The code you run will execute the transaction, but the digest can remain in-flight for a few minutes until our Gas Station updates the status of the associated gas objects. Switch to the "Completed Transactions" tab and you'll see it appear there soon.

Uncomment the code we'll run

This code generates a TransactionKind - a transaction without gas data - for sponsorship, in the Base64 string format our Gas Station expects. It then calls a method that sponsors, gets a sender signature from the KeyPair we pass it, and then executes the transaction, returning the digest and status. You'll need to un-comment it:

Before uncommenting:

// Generate the TransactionKind for sponsorship as a Base64 encoded string
// let gaslessPayloadBase64 = await clockMoveCallTransactionKind();

// Sponsor, sign, and execute the transaction
// let txDigest = await sponsorAndExecuteTransactionForKeyPairSender(
//                          gaslessPayloadBase64, keyPairFromSecretKey);
// console.log("Transaction Digest:", txDigest);

After uncommenting:

// Generate the TransactionKind for sponsorship as a Base64 encoded string
let gaslessPayloadBase64 = await clockMoveCallTransactionKind();

// Sponsor, sign, and execute the transaction
let txDigest = await sponsorAndExecuteTransactionForKeyPairSender(
                         gaslessPayloadBase64, keyPairFromSecretKey);
console.log("Transaction Digest:", txDigest);

Exploring the code we'll run

The clockMoveCallTransactionKind function

This function builds a TransactionBlock that calls a function on a Move module we've deployed to Testnet. This is a very simple Move module based on a sample Move project from Mysten. We're calling its one function, access, which takes a read-only reference to the sui::clock::Clock instance located at address 0x6 as its single parameter. The function emits a single event that contains the current timestamp obtained from the Clock instance. As a bonus, if you want to listen to the event emitted by the Move call over a WebSocket connection, see the cURL and TypeScript examples for suix_subscribeEvent in our WebSocket API doc.

//
// Builds a Move call for sponsorship in one step using our SDK helper function
//
async function clockMoveCallTransactionKind() : Promise<string> {
  let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
    sui: nodeClient,
    build: async (txb) => {
      txb.moveCall({
        target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
        arguments: [txb.object('0x6')]
      });
    }
  });
  console.log("\nbuildGaslessTransactionBytes response (your TransactionKind for sponsorship):");
  console.log(gaslessPayloadBase64);

  return gaslessPayloadBase64
}

Note: building transaction blocks creates requests to your Node Service account, which counts towards your access key's QPS and your daily Node Service request count (and so your bill if you're on a Growth plan). For more information on how Sui's programmable transaction blocks are built, see our TransactionBlock.build() guide.

The sponsorAndExecuteTransactionForKeyPairSender function

The function sponsors the TransactionKind string we created with a call to Shinami's Gas Station. The full TransactionData bytes from the response then get signed by the sender (our KeyPair). Finally, we execute the transaction with a call to Shinami's Node Service, including both the sponsor and sender signatures.

The code has additional comments that explain what's happening.

//
// Sponsors, signs, and executes a transaction for a Ed25519Keypair (sender) 
// Returns the transaction digest of the executed transaction.
//
async function sponsorAndExecuteTransactionForKeyPairSender(
  transactionKindBase64: string, keypair: Ed25519Keypair): Promise<string> {

  //  Send the TransactionKind to Shinami Gas Station for sponsorship.
  //  We are omitting the gasBudget parameter to take advantage of auto-budgeting.
  let sponsoredResponse = await gasStationClient.sponsorTransactionBlock(
    transactionKindBase64,
    keypair.toSuiAddress() // sender address
  );
  console.log("\nsponsorTransactionBlock response:");
  console.log(sponsoredResponse);

  // Sign the full transaction payload with the sender's key.
  let senderSig = await TransactionBlock.from(sponsoredResponse?.txBytes).sign(
    { signer: keyPairFromSecretKey }
  );
  console.log("\nTransactionBlock.sign() response with the sender signature:");
  console.log(senderSig);

  // Send the full transaction payload, along with the gas owner 
  // and sender signatures for execution on the Sui network
  let executeResponse = await nodeClient.executeTransactionBlock({
    transactionBlock: sponsoredResponse?.txBytes,
    signature: [senderSig?.signature, sponsoredResponse?.signature],
    requestType: "WaitForEffectsCert" 
    // or use  "WaitForLocalExecution" if you need read-after-write 
    // consistency for an immediate read after the transaction
  });

  return executeResponse.digest;
}

Run the code and view it in an explorer

Compile and run the code

Run tsc in your terminal after saving the changes from uncommenting out the code to run above. Then, run node build/gas_station.js. You'll see a lot of output in the console - we added a lot of printing so you can see the results of function calls the first few times you run them to better understand things. Of course, once you understand them you can comment out the console.log() statements.

View the transaction in an explorer

The last thing printed to the console is the digest of the transaction you sponsored. It will be the same value as the txDigest returned by our gasStationClient.sponsorTransactionBlock request, since at that point the digest is created over the full TransactionData that will be submitted to the chain (which includes transaction operations, gas object, and sender address). Your digest will of course be different than my example since the sponsorship gas object and the sender are different:

Transaction Digest: 2r6BNi4XkL8eUXATzSJDayqq4pQF4gAoBuSQwt1VF65G

You can look up the digest in a Sui explorer like Suivision or Suiscan (make sure you've set the explorer to Testnet first). This is an image from Suivision showing that my transaction emitted one TimeEvent as mentioned above:


Appendix

You can't access the gas coin in a sponsored transaction

In a sponsored transaction, you cannot use the gas object provided by Shinami for other purposes. For example, you cannot write const [coin] = txb.splitCoins(txb.gas,[txb.pure(100)]); because it's accessing txb.gas. If you try to sponsor a TransactionKind that uses the gas object you will get a JSON-RPC -32602 error.


Check a fund's balance and deposit more SUI in the fund

You can use our gas_getFund endpoint to get the balance for the Gas Station fund tied to the request's API access key. So, in this tutorial you'll be getting the balance of the fund the API key you've been using is tied to.

We generate a deposit address for the fund when you look it up in the Shinami dashboard - go ahead and do that now (see how). If you don't, the depositAddress will be null - which is why we guard against it in the code below.

Notes:

  1. In order to run this code, you'll need to have a SUI coin in the address controlled by the KeyPair we're using as a sender. A simple way is to use the faucet to send 1 SUI to that address.
  2. You'll need to un-comment the lines that match lines 10-16 below (the actual comment lines are double-commented), copy the coin object id from note 1 as the value for suiCoinObjectIdToDeposit, save the changes, recompile with tsc and then run the code with node build/gas_station.js

Replace all instances of {{name}} with the actual value for that name

//
//
// -- Check a fund's balance and deposit more SUI in the fund if it's low -- //
//
//
let { balance, depositAddress }  = await gasStationClient.getFund();
const MIN_FUND_BALANCE_MIST = 50_000_000_000; // 50 SUI
// Deposit address can be null - see our FAQ for how to generate an address
if (depositAddress && balance < MIN_FUND_BALANCE_MIST) {
    // console.log("\nGetting ready to deposit to Gas Station fund address:", depositAddress);
    // let suiCoinObjectIdToDeposit = "{{coinObjectID}}";
    // // We're not actually checking it's a SUI coin we're transferring, which you should do
    // let txKindB64String = await transferObjectToRecipientTransactionKind(suiCoinObjectIdToDeposit, depositAddress);
    // // We're sponsoring with the gas fund we're depositing to (which only work if there's a little SUI left)
    // let txDigest = await sponsorAndExecuteTransactionForKeyPairSender(txKindB64String, keyPairFromSecretKey);
    // console.log("Transaction Digest from fund deposit:", txDigest);
}

Tips for setting your sponsorship budget

Auto-budgeting

When you omit the gasBudget parameter in a sponsorship request using our Gas Station API or our Invisible Wallet API, we estimate the transaction cost for you. We then add a buffer of 5% for non-shared objects and 25% for shared objects. The larger buffer for shared objects is because in the time between sponsorship and execution, shared objects can change in a way that increases their transaction cost due to transactions from other apps and individuals. Therefore, we encourage you to execute sponsored transactions quickly if possible.

While we believe our buffers work well in most cases, we encourage you to monitor the success rate of your auto-budgeted transactions to gauge whether your specific use-case requires manually setting an even larger gasBudget.

As a part of auto-budgeting, we put your transactionBytes through a sui_dryRunTransactionBlock request as a free service before we attempt to sponsor it. This call will generate error messages for certain invalid transactions, such as if the transactionBytes are transferring an object that's not owned by the sender address you provide. We'll return these errors back to you, which should be the same as if you had made a sui_dryRunTransactionBlock request yourself. We do not do this step if you manually budget, so any issues that would be caught by sui_dryRunTransactionBlock will instead produce an error when you try to execute the transaction.

Manual budgeting

When you provide a value for the gasBudget parameter, remember that:

  1. The budget set for a transaction along with a fee will be "held" from your gas credits fund while the transaction is in-flight.
  2. If your budget is set too low for the chain to process, the transaction will fail, but gas credits will still be deducted for the computation cost of running the failed transaction on the Sui blockchain.

This ends up being an exercise in fine tuning so your budgeted gas is enough to push a transaction through, but not overly provisioned that you are inefficient in your gas credit usage. One place to review the cost of your successful transactions is in a Sui explorer like Suivision or Suiscan.

You can also use sui_devInspectTransactionBlock to estimate the gas cost needed by the transaction, but note that this request can produce additional requests that affect your billing. Also, sui_devInspectTransactionBlock does not catch all transaction execution errors and should not be used for testing the correctness of a transaction. sui_dryRunTransactionBlock is better for testing transaction correctness, but requires an attached gas object and also can produce extra requests that affect your billing.

There's an overview of how gas fees are calculated here for more detail.


Other TransactionBlock types

There are more types of programmable transactions than a Move function call like we did above. Below are some additional examples of gas-less transactions you can sponsor and execute.

They all generate a TransactionKind as a Base64 string, so you can replace the call to clockMoveCallTransactionKind() in Step 5 with a call to one of them, e.g.

before:

// Step 5: Generate the TransactionKind for sponsorship as a Base64 encoded string
let gaslessPayloadBase64 = await clockMoveCallTransactionKind();

after:

// Step 5: Generate the TransactionKind for sponsorship as a Base64 encoded string
let ownedCoinID = "{{ownedCoinId}}"; // a coin owned by the KeyPair's address
let gaslessPayloadBase64 = splitCoinOwnedBy(ownedCoinID ,keyPairFromSecretKey.toSuiAddress());

Replace all instances of {{name}} with the actual value for that name

// -- Other TransactionBlock examples  -- //
//
//

//  Create two new small coins by taking MIST from a larger one.
//    and transfering them to the larger coin's owner.
async function splitCoinOwnedBy(coinToSplitID: string, ownerAddress: string) : Promise<string> {
  let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
    sui: nodeClient,
    build: async (txb) => {
      const [coin1, coin2] = txb.splitCoins(txb.object(coinToSplitID), [
        txb.pure(100),
        txb.pure(100),
      ]);
      // each new object created in a transaction must be sent to an owner
      txb.transferObjects([coin1, coin2], txb.pure(ownerAddress));
    }
  });

  return gaslessPayloadBase64;
}

//  Transfer one or more object(s) owned by the sender address to the recipient
//    An easy example is a small coin you created with the above transaction.
async function transferObjectToRecipient(objectID: string, recipientAddress: string) : Promise<string> {
  let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
    sui: nodeClient,
    build: async (txb) => {
      txb.transferObjects(
        [txb.object(objectID)],
        txb.pure(recipientAddress)
      );
    }
  });
  return gaslessPayloadBase64;
}

//  Merge one coin (or more) into another, destroying the 
//   small coin(s) and increasing the value of the large one.
async function mergeCoinsTransactionKind(targetCoinID: string, coinToMergeID: string) : Promise<string> {
  let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
    sui: nodeClient,
    build: async (txb) => {
      txb.mergeCoins(txb.object(targetCoinID), [txb.object(coinToMergeID)]);
    }
  });
  return gaslessPayloadBase64;
}

Another way to build a TransactionKind

Above, we called the clockMoveCallTransactionKind() function, which uses the buildGaslessTransactionBytes helper function in our TypeScript SDK to build the transaction bytes and turn the bytes into a Base64 string in one request. If you have a need, you can also break those two steps up. The clockMoveCallTransactionKindAlternateVersion() does this. If you want, you can use it in place of the call to clockMoveCallTransactionKind() and you'll get the same result.

//
// Builds a Move call for sponsorship in multiple steps
//
async function clockMoveCallTransactionKindAlternateVersion() : Promise<string> {
  let txb = new TransactionBlock();
  txb.moveCall({
    target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
    arguments: [txb.object('0x6')]
  });

  // generate the bcs serialized transaction data without any gas object data
  const gaslessPayloadBytes = await txb.build({ client: nodeClient, onlyTransactionKind: true});

  // convert the byte array to a base64 encoded string
  const gaslessPayloadBase64 = btoa(
      gaslessPayloadBytes
          .reduce((data, byte) => data + String.fromCharCode(byte), '')
  );
  console.log("\ngaslessPayloadBase64 (your TransactionKind for sponsorship):");
  console.log(gaslessPayloadBase64);

  return gaslessPayloadBase64
}

Note: building transaction blocks creates requests to your Node Service account, which counts towards your access key's QPS and your daily Node Service request count (and so your bill if you're on a Growth plan). For more information on how Sui's programmable transaction blocks are built, see our TransactionBlock.build() guide.


Check the status of a sponsorship

Why you may not need to check the status of a sponsorship

For many use cases, you will not need to check the status of your sponsorships. This is because when you get a successful response from the gas_sponsorTransactionBlock request, you will immediately get a sender signature and submit it to our Node Service for execution.

Even in the case when a long time elapses between sponsoring the transaction and submitting it to the Sui network, there may not be a need to check the status. If the transaction is executed more than an hour after sponsorship, there will be an error with the associated gas object (because we enforce a 1 hour TTL). If you and the user still want to proceed with the transaction, you can just go through the process again to re-build it and re-sponsor it.

Finally, we return the transaction digest of the sponsored transaction in the txDigest response field. This can be used with a sui_getTransactionBlock request of our Sui Node JSON-RPC service to check on the status of the transaction. So, you have the option to check on the status of the transaction directly, rather than indirectly through checking the attached sponsorship.

How to check the status of a sponsorship

All of that said, there may be certain cases where your application needs to check the status of a sponsorship. To use our method for this:

  1. Uncomment out the equivalent of lines 8-16.
  2. Save the changes, recompile with tsc and then run the code with node build/gas_station.js
//
//
// Check the status of a sponsorship
//
// This partial code snippet uses the gaslessPayloadBase64 and SENDER_ADDRESS values from above
//

// let sponsorship = await gasStationClient.sponsorTransactionBlock(
//   gaslessPayloadBase64,
//   SENDER_ADDRESS
// );

// let sponsorshipStatus = await gasStationClient.getSponsoredTransactionBlockStatus(
//   sponsorship.txDigest
// );
// console.log("Sponsorship Status:", sponsorshipStatus);

Note that the status will likely be IN_FLIGHT even if you've just executed the transaction. This is because it can take a little time after execution for our system to register that the gas object for the transaction has been used. You can read about the possible sponsorship statuses on our Sui Gas Station API doc.


Integrating Gas Station with Shinami zkLogin wallet API

Shinami's zkLogin Wallet API powers user-controlled wallets that are fully compatible with transactions sponsored by Shinami Gas Station. After generating a zkProof, you can create a gas-less transaction, sponsor it with your Gas Station fund with the zkLogin wallet address as the sender, and sign the transaction with with zkWallet's ephemeral private key. For more information on zkLogin and the order of operations for producing and submitting a transaction to Sui, see Mysten's zkLogin doc. For an end-to-end tutorial showing you how to create your first zkLogin Next.js app that integrates with Shinami's Gas Station API and zkLogin Wallet API, see our zkLogin wallet Next.js Tutorial.


Integrating Gas Station with Shinami Invisible Wallets

Shinami Invisible Wallets are app-controlled wallets you can use to abstract away web3 elements like seed phrases, third-party wallet connections, gas fees, and signing popups. They have native integration with Gas Station, including a method that allow you to sponsor, sign, and execute a transaction in one method call. We've created a tutorial for Invisible Wallets that includes using them with Gas Station.