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 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/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 calls require rights to just one service, so you could create three additional keys if desired - one for each service. For simplicity, in this tutorial we'll just use one key in all cases. 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: Choose and input a walletID and secret

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.

Creating a wallet

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.

In steps 4-6 below, before we create the wallet, we'll set up our Shinami clients. We'll use them with a ShinamiWalletSigner (Step 6) for wallet creation and operations as it simplifies things by abstracting away session token management.

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. Set up a variable we'll use in two examples below
let senderSignature = null;

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

// 7. 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;
let 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: Review and run the code to sponsor, sign and execute a transaction

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.

Explore the code

The below code It 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 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.

// 8. 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')]
  });
}
});

// 9. Sponsor, sign, and execute the transaction
//    We are omitting the gasBudget parameter to take advantage of auto-budgeting.
const sponsorSignAndExecuteResponse = await signer.executeGaslessTransactionBlock(
  gaslessPayloadBase64,
  undefined, // setting gasBudget to undefined triggers our auto-budgeting feature
  { showEffects: true },
  "WaitForLocalExecution"
)

// You can look up the digest in a Sui explorer - make sure to switch to Testnet
console.log("sponsorSignAndExecuteResponse.digest:", sponsorSignAndExecuteResponse.digest);
// Example output: "8CjRvpcSQpDHAJQiUQxpXtkapxdMXQuDCReqxquQZevJ"
//  which I search for in the images below.

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.

Notes:

  • 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.
  • When you use Gas Station to sponsor a transaction, the sponsorship has a 1 hour TTL. The associated SUI will be locked up in your fund until the transaction is executed or one hour passes without execution.

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 directory, run tsc to compile the file. Then, run node build/invisible_wallet.ts to run the code.

Bonus: if you want to listen to the event emitted by the call you make here over a WebSocket connection, see the cURL and TypeScript examples for suix_subscribeEvent in our WebSocket API doc.

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, with Shinami's Invisible Wallet you have flexibility on how to integrate for your needs. 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.
  2. Signs the full TransactionBlock returned by Gas Station.
  3. Submits the signed, sponsored transaction to our Node service to execute it on chain.
//
//  Sponsor, sign, and execute a transaction in three requests.
//  Returns the associated transaction digest if successful.
async function signSponsorExecuteInThreeRequests(signer: ShinamiWalletSigner, transactionKind: string) : Promise<string> {

  // 1. Sponsor the TransactionKind with a call to Gas Station
  const gasStationClient = new GasStationClient(ALL_SERVICES_TESTNET_ACCESS_KEY); 
  const sponsoredResponse = await gasStationClient.sponsorTransactionBlock(
    transactionKind,
    await signer.getAddress()
  );

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

  // 3. Use the TransactionBlock 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: "WaitForLocalExecution",
  });
  console.log("\nSign, sponsor, execute in three requests.\ndigest: ", executeSponsoredTxResponse.digest);
  console.log("status:", executeSponsoredTxResponse.effects?.status.status);

  return executeSponsoredTxResponse.digest;
}

Run the code

Uncomment the function call

Uncomment the following line of the ADDITIONAL EXAMPLES TO RUN section of the code that calls the signAndVerifyPersonalMessage function:

//
// -- ADDITIONAL EXAMPLES TO RUN -- //
//

// Perform the above sponsorship in three requests instead of one
//
let txDigest = await signSponsorExecuteInThreeRequests(signer, gaslessPayloadBase64);

Save, compile, run

After saving the change, run tsc to compile the changes. Then run node build/invisible_wallet.ts. 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 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
  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 verifyPersonalMessage(messageBytes, signature);

  // 5. Check whether the signer's address matches the Invisible Wallet's address
  if (publicKey.toSuiAddress() !== await signer.getAddress()) {
      console.log("\nSignature was valid for the 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 following line of the ADDITIONAL EXAMPLES TO RUN section of the code that calls the signAndVerifyPersonalMessage function:

//
// -- ADDITIONAL WALLET OPERATIONS BELOW -- //
//

...

// Sign a personal message with the Invisible Wallet and verify that the wallet signed it.
// 
let wasSuccessful = await signAndVerifyPersonalMessage(signer);

Save, compile, run

After saving the change, run tsc to compile the changes. Then run node build/invisible_wallet.ts. 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

This code submits a non-sponsored transaction on behalf of the wallet, splitting off two coins with values of 10,000 and 20,000 MIST from a main SUI coin, and transferring them back to the wallet address. See our Gas Station tutorial for examples of other types of transactions.

Get Testnet SUI

In this case, the Invisible Wallet we're using will need some 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. You'll need to make two transfers:

  • A 1 SUI coin that we'll split into smaller coins.
  • A 1 SUI coin that will be used to pay for the gas.

Once I transfer the SUI to the Invisible Wallet address I created, I can use a Sui explorer like Suivision or Suiscan to lookup my wallet address. From there, I can find my SUI coins and copy the object id of one to use in the example below (make sure you've set the explorer to Testnet before searching for your wallet address):

Explore the code

The signAndExecuteANonSponsoredTransaction function

This function takes a coin owned by the sender and splits two, new coins off from it. It also uses another coin owned by the sender to pay for gas.

async function signAndExecuteANonSponsoredTransaction(signer: ShinamiWalletSigner, coinToSplitID: string) : Promise<string> {

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

  // 1. Create a new TransactionBlock and add the operations to create
  //     two new coins from MIST contained by the COIN_TO_SPLIT
  const txb = new TransactionBlock();
  const [coin1, coin2] = txb.splitCoins(txb.object(coinToSplitID), [
    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));
    // Set gas context and sender
  txb.setSender(SENDER_ADDRESS);
  txb.setGasBudget(GAS_BUDGET);
  txb.setGasOwner(SENDER_ADDRESS);

  // 2. Generate the BCS serialized transaction data WITH gas data by setting onlyTransactionKind to false
  const txBytes = await txb.build({ client: nodeClient, onlyTransactionKind: false});

  // 3. Convert the byte array to a Base64 encoded string
  const txBytesBase64 = btoa(
    txBytes
        .reduce((data, byte) => data + String.fromCharCode(byte), '')
  );

  // 4. Sign the transaction (the Invisible Wallet is the sender)
  senderSignature = await signer.signTransactionBlock(
    txBytesBase64
  );

  // 5. Execute the transaction 
  const executeNonSponsoredTxResponse = await nodeClient.executeTransactionBlock({
    transactionBlock: txBytesBase64,
    signature: [senderSignature.signature],
    options: { showEffects: true },
    requestType: "WaitForLocalExecution",
  });

  console.log("\nNon-sponsored Transaction.\ndigest: ", executeNonSponsoredTxResponse.digest);
  console.log("status:", executeNonSponsoredTxResponse.effects?.status.status);
  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

Uncomment the following two lines of the ADDITIONAL EXAMPLES TO RUN section of the code. Then, paste one coin's object id as the value for COIN_TO_SPLIT.

// Sign and execute a non-sponsored transaction. 
//  This requires two SUI coins in the address of the signer - one to split and one for gas. 
//   You can use the Sui discord Testnet faucet channel, the faucet in Sui Wallet, etc.
//
const COIN_TO_SPLIT_ID = "{{coinToSplitObjectId}}"; // Set this to the id of a SUI coin owned by the sender address
let unsponsoredDigest = await signAndExecuteANonSponsoredTransaction(signer, COIN_TO_SPLIT_ID);

Save, compile, run

Make sure you've saved the changes made above. In the shinami-examples/sui/typescript directory, run tsc to compile the file. Then, run node build/invisible_wallet.ts to run the code.

Refresh the Sui explorer you used and check your wallet's contents again. You should see the two original coins, which are slightly smaller because one paid a gas fee and we took MIST from the other two make two new coins. You should also see the two new, small coins you created.