Sponsored Transaction TypeScript Tutorial

How to sponsor transactions with Shinami on Sui

Overview

In this tutorial, we'll go over how to sponsor transactions with Shinami's Gas Station and submit them for execution on the Sui blockchain. The full code for this TypeScript tutorial is available on GitHub.

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.

Sponsored Transactions

When calling sui_executeTransactionBlock a base64 encoded string is passed in which encodes the transaction to be executed, the sender address, and the gas information for the transaction block. This unit is called a TransactionData object, and 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). The gas station is responsible for attaching a GasData object that represents the gas object that will be powering the transaction. Finally, the BCS-serialized, Base64-encoded string that represents the full TransactionData object is produced and sent back along with the gas station's signature authorizing this transaction.

From there, the last piece needed to call sui_executeTransactionBlock is the sender's signature over the TransactionData.

Tutorial: creating, sponsoring, and executing a transaction

Note: this tutorial requires a Shinami account. If you do not have one, you can sign up here.

1. Create a Shinami Gas Station fund on testnet

See our Gas Station Guide for how to set up a fund on Testnet and add free Sui to it if you don't have one.

2: Set up your access key

You use API access keys to interact with Shinami's services. You create them in your Shinami Dashboard. For this tutorial, we'll create one access key that has both Node Service and Gas Station rights for Testnet (you could also make a two keys, one with only Node Service rights and one with only Gas Station rights).

When you create a key with Node Service rights, you assign a max QPS and max number of WebSocket subscriptions the key is allowed. You can change a key's values later by clicking on the + next to the key in Access Keys table to open the key editor. When you create an access key with Gas Station rights, you need to link it to a Gas Station fund. All requests for sponsorship using the key draw Sui from that fund. So, you'll link this key to the Testnet fund you created in step one. For example:

3. Sponsor a transaction with a newly-generated KeyPair as sender

The below code creates a transaction 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 of 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.

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.

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

// 1. Import everything we'll need for the rest of the tutorial
import { Ed25519Keypair } from "@mysten/sui.js/keypairs/ed25519";
import { TransactionBlock } from "@mysten/sui.js/transactions";
import { GasStationClient, createSuiClient, buildGaslessTransactionBytes } from "@shinami/clients";

// 2. Copy your testnet Gas Station and Node Service key values
const GAS_AND_NODE_TESTNET_ACCESS_KEY = "{{gasAndNodeServiceTestnetAccessKey}}";

// 3. 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 to represent the sender
let keyPair = new Ed25519Keypair();
const SENDER_ADDRESS = keyPair.toSuiAddress();

// 5. Generate the TransactionKind for sponsorship as a Base64 encoded string
let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
  sui: nodeClient,
  build: async (txb) => {
    txb.moveCall({
      target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
      arguments: [txb.object('0x6')]
    });
  }
});

// 6. 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(
  gaslessPayloadBase64,
  SENDER_ADDRESS
);

// 7. The transaction should be sponsored now, so its status will be "IN_FLIGHT"
let sponsoredStatus = await gasStationClient.getSponsoredTransactionBlockStatus(
  sponsoredResponse.txDigest
);
console.log("Transaction Digest:", sponsoredResponse.txDigest);
  // For me this printed "Transaction Digest: GE6rWNfjVk7GiNSRHExaYnQB6TNKRpWBbQrAAK1Cqax5"
  // which we'll see in the image below. 
console.log("Sponsorship Status:", sponsoredStatus);
  // Printed "Sponsorship Status: IN_FLIGHT"

At this point, if you look in your Shinami dashboard at the In flight transactions, you'll see the digest you just printed out (of course, your digest will be different). If you run this code twice, you'll get two digests as a different gas object is being used each time.

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). After a transaction is executed, the digest can remain in-flight for a few minutes until our Gas Station updates the status of the associated gas objects.

4. Sign and execute the transaction

Now, we'll go ahead and execute the transaction we sponsored. The below code continues where we left off:

// 8. Sign the full transaction payload with the sender's key.
let senderSig = await TransactionBlock.from(sponsoredResponse.txBytes).sign(
  {
    signer: keyPair,
  }
);

// 9. Send the full transaction payload, along with the gas owner 
//     and sender's signatures for execution on the Sui network
let executeResponse = await nodeClient.executeTransactionBlock({
  transactionBlock: sponsoredResponse.txBytes,
  signature: [senderSig.signature, sponsoredResponse.signature],
  options: { showEffects: true },
  requestType: "WaitForLocalExecution",
});

console.log("Transaction Digest:", executeResponse.digest);
  // Printed "Transaction Digest: GE6rWNfjVk7GiNSRHExaYnQB6TNKRpWBbQrAAK1Cqax5"
console.log("Execution Status:", executeResponse.effects?.status.status);
  // Printed "Execution Status: success"

The digest printed out will be the same one as what we sponsored, as expected, since the digest is created over the same transaction operations, gas object, and sender address. If we look in the Sui Explorer on Testnet, we can see the digest (of course, your digest will be different):


Appendix

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 (5% for non-shared objects, 25% for shared objects) and use that total value as the budget of the sponsorship. 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.

Manual budgeting

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

  1. The budget set for a transaction will be "held" from your gas credits fund while the transaction is in flight.
  2. If your budget is set too low, 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 the Sui Explorer. You can also use sui_devInspectTransactionBlock to estimate the gas cost needed by the transaction. The price of transactions can change day to day - the validators vote on the reference gas price and that gets multiplied by the complexity of the transaction, so we recommend adding a buffer. There's an overview of how things are calculated here if you want more detail.

Note: 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 so is not ideal for pre-sponsorship gas budget testing.)

A note on shared objects

In the time between sponsorship and execution, shared objects can change in a way that increases their transaction cost. Therefore, we encourage you to execute sponsored transactions quickly, if possible, to ensure that the sponsorship amount is sufficient. This is why we add a larger buffer on auto-budgeted sponsorships when a shared object is involved. While we believe this buffer will work 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.

Other TransactionBlock types

Of course, 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. Note: they all use the same gaslessPayloadBase64 variable name and can replace step 4 of Section 2 above (so, if you have them all uncommented at the same time you'll get a compiler error).

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

// Comment out step 5 above:

// 5. Generate the TransactionKind for sponsorship as a Base64 encoded string
//let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
//  sui: nodeClient,
//  build: async (txb) => {
//    txb.moveCall({
//      target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
//      arguments: [txb.object('0x6')]
//    });
//  }
//});

// and replace it with one of:

// -- Other TransactionBlock examples  -- //

//  Create two new small coins by taking MIST from a larger one.
//    If you ask for testnet Sui for an account you have the 
//    private key or passphrase to, you can split two coins from that.
//
//
const COIN_TO_SPLIT = "{{coinObjectId}}";
let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
  sui: nodeClient,
  build: async (txb) => {
    const [coin1, coin2] = txb.splitCoins(txb.object(COIN_TO_SPLIT), [
      txb.pure(10000),
      txb.pure(20000),
    ]);
    // each new object created in a transaction must be sent to an owner
    txb.transferObjects([coin1, coin2], txb.pure(SENDER_ADDRESS));
  }
});



//  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.
//
//
const RECIPIENT_ADDRESS = "{{recipientSuiAddress}}";
const OBJECT_TO_SEND = "{{objectId}}";
let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
  sui: nodeClient,
  build: async (txb) => {
    txb.transferObjects(
      [txb.object(OBJECT_TO_SEND)],
      txb.pure(RECIPIENT_ADDRESS)
    );
  }
});



//  Merge one or more smaller coins into another, destroying the small coin(s)
//    and increasing the value of the large one.
//
//
const TARGET_COIN = "{{targetCoin}}";
const COIN_SOURCE1 = "{{coinSource1}}";
const COIN_SOURCE2 = "{{coinSource2}}";
let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
  sui: nodeClient,
  build: async (txb) => {
    txb.mergeCoins(txb.object(TARGET_COIN), [
      txb.object(COIN_SOURCE1),
      txb.object(COIN_SOURCE2),
    ]);
  }
});

Another way to generate a transaction to sponsor

Above, we used the convenience method buildGaslessTransactionBytes that builds a gas-less transaction, converts it to BCS serialized bytes, and then converts that into the base64 encoded string expected by gasStationClient.sponsorTransactionBlock. Here, we show those three steps broken out into individual pieces:

// Comment out step 5 above:

// 5. Generate the TransactionKind for sponsorship as a Base64 encoded string
//let gaslessPayloadBase64 = await buildGaslessTransactionBytes({
//  sui: nodeClient,
//  build: async (txb) => {
//    txb.moveCall({
//      target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
//      arguments: [txb.object('0x6')]
//    });
//  }
//});

// and replace it with:
 
// 4. Generate the TransactionKind for sponsorship as a Base64 encoded string
const 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), '')
);

Generate a KeyPair from a private key or passphrase

If you have the private key or recovery phrase from an existing wallet, you can use those to create the sender's KeyPair. Below is code demonstrating both options.

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

// -- Generate a KeyPair from a private key or passphrase -- //

// Create the sender's address key pair from the sender's private key
const SENDER_PRIVATE_KEY = "{{privateKey}}";
const buf = Buffer.from(SENDER_PRIVATE_KEY, "base64");
const keyPairFromSecretKey = Ed25519Keypair.fromSecretKey(buf.slice(1));
console.log(keyPairFromSecretKey.toSuiAddress());

// Create the sender's address key pair from the sender's recovery phrase
const SENDER_PASSPHRASE = "{{passphrase}}";
const keyPairFromPassphrase = Ed25519Keypair.deriveKeypair(SENDER_PASSPHRASE);
console.log(keyPairFromPassphrase.toSuiAddress());

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.