Invisible Wallets (TypeScript)

How to utilize the Shinami Invisible Wallet

Overview

In this tutorial, we show you how to use the Shinami Invisible Wallet API. Shinami’s Invisible Wallets are app-controlled, backend wallets under the shared custody of your app and Shinami. Both parties must cooperate in order to obtain a valid signature. We also offer user-controlled zkLogin wallet services. Both can be "invisible" from the perspective of the end-user, who benefits from Web3 ownership without the burden of wallet management.

The examples below show how to create a new wallet and then sign and execute transactions using that wallet. We'll be constructing programmable transactions using the Sui TypeScript SDK provided by Mysten. We'll also use the Shinami Clients SDK to make JSON-RPC calls to create and use Invisible Wallets.

The examples also show how to leverage Shinami's Gas Station to seamlessly sponsor, sign and execute a transaction in one request. When you create an Invisible Wallet for a user it won't have any SUI in it. With Gas Station you can sponsor your user's transaction fees so they don't have to download a wallet app and complete KYC to buy SUI. Removing this friction - along with the burden of remembering recovery phrases and reading signing pop-ups - is a great way to smoothly onboard Web2-native users.

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 Invisible Wallet API as well as the "General Error Codes" section at the top that apply to all Shinami services.

Tutorial

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 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/backend_examples 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 invisible_wallet.ts file in the shinami-examples/sui/typescript/backend_examples/src directory.

3: Create an API access key with Testnet rights to all Shinami services

You use API access keys to interact with Shinami's services. For this tutorial, we'll create one access key with rights for all Testnet services - Node Service, Gas Station, and Wallet Services - because one of the calls requires rights to all services. Some requests require rights to just one service. For simplicity, in this tutorial we'll just use the one all-services key for all requests. See our Authentication and API Keys guide for info on how to set up a key with rights to all services.

Once you've created the key, enter is as the value for ALL_SERVICES_TESTNET_ACCESS_KEY:

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

// 2. Copy your access key value
const ALL_SERVICES_TESTNET_ACCESS_KEY = "{{allServicesTestnetAccessKey}}";

4: Create an Invisible Wallet

For each Invisible Wallet you create, you need to create and store two new pieces of information: a unique walletId and its associated, ideally unique secret. This 1-to-1 pairing can be thought of as a username/password equivalent you use with Shinami's API for each wallet. A walletId only works with the secret used when the wallet was created, so your application MUST remember each (walletId, secret) pair. If you forget or change either value, the wallet's private key will be unrecoverable. For more information and images, see WalletId and Secret Pairing.

You'll need to define a (walletId, secret) pair first. Choose them and enter them into the file as the values for WALLET_ONE_ID and WALLET_ONE_SECRET (Step 3 in the code block below).

In steps 4-6 below, we set up our Shinami clients, create an instance of a ShinamiWalletSigner to make wallet management easier, and then create the Invisible Wallet.

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

// 3. Set up a wallet id and an associated secret
const WALLET_ONE_ID = "{{walletOneId}}";
const WALLET_ONE_SECRET = "{{walletOneSecret}}";

// 4. Instantiate your Shinami clients
const keyClient = new KeyClient(ALL_SERVICES_TESTNET_ACCESS_KEY);
const walletClient = new WalletClient(ALL_SERVICES_TESTNET_ACCESS_KEY);
const nodeClient = createSuiClient(ALL_SERVICES_TESTNET_ACCESS_KEY);

// 5. Create a signer for the Invisible Wallet
const signer = new ShinamiWalletSigner(
  WALLET_ONE_ID,
  walletClient,
  WALLET_ONE_SECRET,
  keyClient
);

// 6. Create the wallet. This request returns the Sui address of an 
//     Invisible Wallet, creating it if it hasn't been created yet
const CREATE_WALLET_IF_NOT_FOUND = true;
const WALLET_ONE_SUI_ADDRESS = await signer.getAddress(CREATE_WALLET_IF_NOT_FOUND);
console.log("Invisible wallet Sui address:", WALLET_ONE_SUI_ADDRESS);

You'll use the WALLET_ONE_SUI_ADDRESS value in the code below and also to look up your wallet in a Sui explorer like Suivision or Suiscan (make sure the explorer is set to Testnet first).

5: Generate a GaslessTransaction

Next, we make a call to buildGaslessTransaction, which takes a feePayer transaction and returns Shinami type GaslessTransaction.

// 7. Generate a GaslessTransaction for sponsorship 
const gaslessTx = await buildGaslessTransaction(
  await clockMoveCallTransaction(),
  { sui: nodeClient }
);

The call to clockMoveCallTransaction builds 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 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.

async function clockMoveCallTransaction() : Promise<Transaction> {
  const tx = new Transaction();
  tx.moveCall({
    target: "0xfa0e78030bd16672174c2d6cc4cd5d1d1423d03c28a74909b2a148eda8bcca16::clock::access",
    arguments: [tx.object('0x6')]
  });
  return tx;
}

Note: building transactions 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 Transaction.build() guide.

6: Sponsor, sign and execute a transaction

Understand the code

In Step 8, you have three methods to choose from. We'll be running the first, sponsorSignExecuteInOneRequest. The other two functions are reviewed in the Appendix.

After executing the transaction we wait for the Full node we're using to have processed the checkpoint with the transaction in it and then we print out the status and digest (which you can look up in a Sui explorer like Suivision or Suiscan).

//
// 8. Choose a sample code method to run
//
const txDigest = await
  sponsorSignExecuteInOneRequest(signer, gaslessTx);
  // sponsorSignExecuteInThreeRequests(signer, gaslessTx);
  // signAndExecuteANonSponsoredTransaction(signer); // Requires the wallet to own a SUI coin.
                                                     //  Use the faucet to transfer 1 SUI to it.

const txInfo = await nodeClient.waitForTransaction({ 
  digest: txDigest,
  options: { showEffects: true }
});

// You can look up the digest in a Sui explorer - make sure to switch to Testnet
console.log("\ntxDigest: ", txDigest);
console.log("status:", txInfo.effects?.status.status);

This method is why we needed an API access key to rights with all services, as it uses Gas Station to sponsor the transaction and produce the sponsor's signature, Wallet Service to sign the sponsored transaction as the sender, and Node Service to execute the transaction.

The sponsorSignExecuteInOneRequest function

This is very short because we take advantage of the Invisible Wallet API's executeGaslessTransaction method, which sponsors a transaction, has the Invisible Wallet sign it, and then submits it to the Sui network.

async function sponsorSignExecuteInOneRequest(signer: ShinamiWalletSigner, 
                                              gaslessTx: GaslessTransaction) : Promise<string> {
  const sponsorSignAndExecuteResponse = await signer.executeGaslessTransaction(
    gaslessTx, // by not setting gaslessTx.gasBudget we take advantage of Shinami auto-budgeting
    { showEffects: true },
    "WaitForEffectsCert" 
    // or use  "WaitForLocalExecution" if you need read-after-write 
    // consistency for an immediate read after the transaction
  )
  return sponsorSignAndExecuteResponse.digest;
}

We make things even simpler by taking advantage of our auto-budgeting feature by not setting a value for gaslessTx.gasBudget. This way we don't have to do the work to calculate the ideal budget - Shinami does it for us! If you want to learn more about using auto-budgeting or setting a gasBudget manually, see our Gas Station Tutorial's section on the topic.

Run the code

Make sure you've saved the changes to the file made above: adding your API access key and wallet ID and secret. In the shinami-examples/sui/typescript/backend_examples directory, run tsc to transpile the file into JavaScript. Then, run the resulting code with node build/invisible_wallet.js.

You can look up the transaction digest printed to the console in a Sui explorer like Suivision or Suiscan (make sure the explorer is set to Testnet first). Make sure you're viewing Testnet when you search as that's where we're executing our transactions.

Conclusion

As you can see, creating and using Shinami Invisible Wallets is very easy! There are more examples to try out in the sample code, as explained in the Appendix below. We hope this tutorial has been helpful. Let us know if you have any issues or feedback by shooting us an email at [email protected].


Appendix

Sponsor, sign, and execute a transaction in three requests

The below code does the exact same thing we did to start the tutorial, but using three requests instead of one. That's more work, but it gives you greater flexibility if you need it.

Explore the code

The signSponsorExecuteInThreeRequests function

This function:

  1. Makes a sponsorship request to Shinami Gas Station
  2. Signs the Transaction returned by Gas Station (which now has gas information).
  3. Submits the signed, sponsored Transaction to our Node service to execute it on chain.
async function sponsorSignExecuteInThreeRequests(signer: ShinamiWalletSigner, 
                                               gaslessTx: GaslessTransaction) : Promise<string>{

  // 1. Sponsor the GaslessTransaction with a call to Gas Station
  const gasStationClient = new GasStationClient(ALL_SERVICES_TESTNET_ACCESS_KEY); 
  gaslessTx.sender = await signer.getAddress(); 
  const sponsoredResponse = await gasStationClient.sponsorTransaction(
    gaslessTx // by not setting gaslessTx.gasBudget we take advantage of Shinami auto-budgeting

  // 2. Sign the transaction (the Invisible Wallet is the sender)
  const senderSignature = await signer.signTransaction(
    sponsoredResponse.txBytes
  );

  // 3. Use the transaction bytes and sponsor signature produced by 
  //  `sponsorTransactionBlock` along with the sender's signature 
  const executeSponsoredTxResponse = await nodeClient.executeTransactionBlock({
    transactionBlock: sponsoredResponse.txBytes,
    signature: [senderSignature.signature, sponsoredResponse.signature],
    options: { showEffects: true },
    requestType: "WaitForEffectsCert" 
    // or use  "WaitForLocalExecution" if you need read-after-write 
    // consistency for an immediate read after the transaction
  });
  return executeSponsoredTxResponse.digest;
}

Run the code

Uncomment the function call

Adjust step 8 so that only the sponsorSignExecuteInThreeRequests function is un-commented, as shown below:

// 8. Choose a sample code method to run
const txDigest = await
  // sponsorSignExecuteInOneRequest(signer, gaslessTx);
  sponsorSignExecuteInThreeRequests(signer, gaslessTx);
  // signAndExecuteANonSponsoredTransaction(signer); // Requires the wallet to own a SUI coin.
                                                     //  Use the faucet to transfer 1 SUI to it.

Run the code

Make sure you've saved the changes made above. In the shinami-examples/sui/typescript/backend_examples directory, run tsc to transpile the file into JavaScript. Then, run the resulting code with node build/invisible_wallet.js.

You can look up the transaction digest printed to the console in a Sui explorer like Suivision or Suiscan (make sure the explorer is set to Testnet first). Make sure you're viewing Testnet when you search as that's where we're executing our transactions.

Sign and verify a personal message

To prove ownership of a wallet, you can use Wallet Service's method to sign a personal message. The below also uses a method from the Mysten TypeScript SDK to verify whether the signature was from the expected address.

Explore the code

The signAndVerifyPersonalMessage function

This function takes a ShinamiWalletSigner (which can sign on behalf of an Invisible Wallet). This function:

  1. Creates a message for the Invisible Wallet to sign.
  2. Uses the signPersonalMessage function to sign it.
  3. Encodes the result in the format required by Mysten's verifyPersonalMessage function.
  4. Calls the verifyPersonalMessage function, getting back the PublicKey associated with the signature. This function throws an error if the signature is not valid for the message. Success does not yet confirm the right person signed it.
  5. Checks whether the address associated with the signature is the same as the address of our Invisible Wallet.

Of course, this is a trivial check because we know we signed it, but in practice someone else (who knows what the message was) is doing the checking.

async function signAndVerifyPersonalMessage(signer: ShinamiWalletSigner) : Promise<boolean> {

  // 1. Encode the message as a Base64 string
  const message = "I control the private key, haha!"; 
  const messageAsBase64String = btoa(message);

  // 2. Sign the message with the Invisible Wallet private key
  const signature = await signer.signPersonalMessage(
    messageAsBase64String
  );

  // 3. When we check the signature, we encode the message as a byte array
  // and not a Base64 string like when we signed it
  const messageBytes = new TextEncoder().encode(message); 

  // 4. Determine whether the signature is valid for the messsage. 
  //    Failure throws an Error with message: `Signature is not valid for the provided message`.
  //    Returns the public key associated with the signature.
  const publicKey = await verifyPersonalMessageSignature(messageBytes, signature);

  // 5. Check whether the signer's address matches the Invisible Wallet's address
  if (publicKey.toSuiAddress() !== await signer.getAddress()) {
      console.log("\nSignature valid for message, but was signed by a different key pair, :(");
      return false;
  } else {
      console.log("\nSignature was valid and signed by the Invisible Wallet!");
      return true;
  }
}

Note: for compatibility with the Mysten SDK, the wrapBcs parameter needs to be set to true. When using the Shinami Clients SDK, it's set to true by default.

Run the code

Uncomment the function call

Uncomment the function call in step 9:

// 9. (optional) Uncomment th enext line to sign a personal message with 
//      the Invisible Wallet and then verify that the wallet signed it.
await signAndVerifyPersonalMessage(signer);

Run the code

Make sure you've saved the changes made above. In the shinami-examples/sui/typescript/backend_examples directory, run tsc to transpile the file into JavaScript. Then, run the resulting code with node build/invisible_wallet.js.

There's no transaction digest associated with this code. You'll just see a printout of whether or not the check was successful.



Sign and execute a non-sponsored transaction

First, send Testnet SUI to the wallet

In this case, the Invisible Wallet we're using will need Testnet SUI to pay for the transaction. You can request some and transfer it to the wallet address in the same manner you did when setting up a Gas Station Fund. Once you've sent Testnet SUI to the wallet's address, proceed below.

Explore the code

The signAndExecuteANonSponsoredTransaction function

This function makes the same Move call we make elsewhere, but in this case the Invisible Wallet is paying for it (the transaction is not sponsored). This function:

  1. Generates the Move call Transaction.
  2. Sets sender and gas info
  3. Generates the transaction bytes for signing (with onlyTransactionKind: false because this is a non-sponsored Transaction).
  4. Has the Invisible Wallet sign the transaction.
  5. Submits the Transaction to the Sui blockchain, returning the digest if successul.
async function signAndExecuteANonSponsoredTransaction(signer: ShinamiWalletSigner) 
                                                                    : Promise<string> {

  const GAS_BUDGET = 10_000_000; // 10 Million MIST, or 0.01 SUI
  const SENDER_ADDRESS = await signer.getAddress();

  // 1. Generate the Move call Transaction
  const txb = await clockMoveCallTransaction();
  // 2. Set sender and gas info
  txb.setSender(SENDER_ADDRESS);
  txb.setGasBudget(GAS_BUDGET);
  txb.setGasOwner(SENDER_ADDRESS);

  // 3. Generate the BCS serialized transaction data WITH gas data by 
  //     setting onlyTransactionKind to false
  const txBytes = await txb.build({ client: nodeClient, onlyTransactionKind: false});
  // 4. Sign the transaction (the Invisible Wallet is the sender)
  const senderSignature = await signer.signTransaction(txBytes);

  // 5. Execute the transaction 
  const executeNonSponsoredTxResponse = await nodeClient.executeTransactionBlock({
    transactionBlock: txBytes,
    signature: [senderSignature.signature],
    options: { showEffects: true },
    requestType: "WaitForEffectsCert" 
    // or use  "WaitForLocalExecution" if you need read-after-write 
    // consistency for an immediate read after the transaction
  });
  return executeNonSponsoredTxResponse.digest;
}

Important notes

  • You cannot use the gas object in a sponsored transaction 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 an error. This is why in the example below we make sure the wallet has a coin in it that can be used for gas (by calling txb.setGasOwner(ownerAddress) it's set automatically).
  • For this non-sponsorship case, you'll set onlyTransactionKind to false when you build the transaction, since gas and sender data is included (unlike in the sponsorship case).

Run the code

Uncomment the code

Adjust step 8 so that only the signAndExecuteANonSponsoredTransaction function is un-commented, as shown below:

// 8. Choose a sample code method to run
const txDigest = await
  // sponsorSignExecuteInOneRequest(signer, gaslessTx);
  // sponsorSignExecuteInThreeRequests(signer, gaslessTx);
  signAndExecuteANonSponsoredTransaction(signer); // Requires the wallet to own a SUI coin.
                                                     //  Use the faucet to transfer 1 SUI to it.

Run the code

Make sure you've saved the changes made above. In the shinami-examples/sui/typescript/backend_examples directory, run tsc to transpile the file into JavaScript. Then, run the resulting code with node build/invisible_wallet.js.

You can look up the transaction digest printed to the console in a Sui explorer like Suivision or Suiscan (make sure the explorer is set to Testnet first). Make sure you're viewing Testnet when you search as that's where we're executing our transactions.