suioop
v2.3.2
Published
Sui Owned Object Pools is a library that provides a set of tools for managing multiple concurrent transactions on the Sui network to help avoiding object equivocation and locking
Downloads
61
Readme
Sui Owned Object Pools (SuiOOP)
A library that provides a set of tools for managing multiple concurrent transactions on the Sui network to help avoid object equivocation and locking.
Quickstart
Installation
npm i suioop
High-level usage
Note: This is not a working example - it just shows a high-level overview of how the library is used. You can find a more detailed example in the section below.
// Initialize the ExecutorServiceHandler.
const eshandler = await ExecutorServiceHandler.initialize(
adminKeypair,
suiClient,
);
/// An then for each incoming request...
/// ...get the incoming transactionBlock
const myTransactionBlock;
/// and send it for execution
const promise = eshandler.execute(myTransactionBlock, suiClient, splitStrategy);
Motivation
Equivocation is a common pitfall for builders using owned objects: Implementing horizontal scaling or concurrency for a service that executes transactions on Sui in the natural way results in an architecture that issues multiple transactions in parallel from the same account.
The community largely avoids using owned objects as a result, which also means they don’t benefit from their lower latency, which is a unique selling point for Sui. On top of that, they are impossible to completely avoid, because the transaction’s gas coin must be owned.
Finally, the situation is exacerbated by gas smashing (which combines automatically
all transaction’s gas coins into one) and our SDK’s default coin selection logic
which uses all the 0x2::coin::Coin<0x2::sui::SUI>
s owned by an address for every transaction’s
gas payment. These defaults make sending transactions from an individual’s wallet
simple (doing so automatically cleans up coin dust), but mean that developers
writing services need to work against the defaults to maintain distinct gas
coins to run transactions in parallel.
This library is a solution to the above, simplifying access to owned objects from back-end services that also need to take advantage of concurrency, without equivocating their objects.
Solution
The main modules of the library are executorServiceHandler.ts
and pool.ts
.
executorServiceHandler.ts
contains the logic of the executor service - meaning that it acts like a load balancer, distributing the transactions to the worker pools.pool.ts
contains the logic of the worker pools.
As a user of the library you will only need to use the executorServiceHandler.ts
module.
The basic idea of our solution is the ExecutorServiceHandler
to use multiple worker pools
contained in a workersQueue
where each one of them will execute one of the transactions
provided by the user when calling the execute(...)
function.
The flow goes as follows:
First we initialize the
ExecutorServiceHandler
containing only onemainPool
. Then whenever a transaction is submitted to theExecutorServiceHandler
, it will try to find if there is an available worker pool to sign and execute the transaction.Note that the main pool is not a worker pool, meaning that it does not execute transactions. It is only used to store the objects and coins of the account, and to provide them to the worker pools when needed.
If a worker pool is not found, the executor handler will create one by splitting the mainPool - i.e. taking a part of the mainPool's objects and coins and creating a new worker pool.
This is how the executor handler scales up. You can define the split logic by providing aSplitStrategy
object to theExecutorServiceHandler
on initialization. If you don't provide a splitStrategy, theDefaultSplitStrategy
will be used.
Example code
Let's define an example to make things clearer: Assume that we need to execute 10 transactions that transfer 100 MIST each to a fixed recipient.
Prerequisites for the code of this section to run:
- You need to already have at least one coin of type
0x2::coin::Coin<0x2::sui::SUI>
in your wallet for each transaction that you need to execute in parallel (in our case 10 coins). - Each
Coin<SUI>
should have enough balance to execute each transaction.
import { SuiClient } from '@mysten/sui.js/client';
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { Ed25519Keypair } from '@mysten/sui.js/keypairs/ed25519';
import { fromB64 } from '@mysten/sui.js/utils';
/* HERE ARE DEFINED THE PREPARATORY STEPS IF YOU WANT TO CODE ALONG*/
// Define the transaction block
function createPaymentTxb(recipient: string): TransactionBlock {
const txb = new TransactionBlock();
const [coin] = txb.splitCoins(
txb.gas,
[txb.pure(1000000)], // Amount to be transferred to the recipient
);
txb.transferObjects([coin], txb.pure(recipient));
return txb;
}
// Define your admin keypair and client
const ADMIN_SECRET_KEY: string = '<your-address-secret-key>';
const adminPrivateKeyArray = Uint8Array.from(
Array.from(fromB64(ADMIN_SECRET_KEY)),
);
const adminKeypair = Ed25519Keypair.fromSecretKey(
adminPrivateKeyArray.slice(1),
);
const client = new SuiClient({
url: process.env.SUI_NODE!,
});
Now we set up the service handler and to execute the transactions we
defined above, we will use the execute
method of the ExecutorServiceHandler
class.
import { ExecutorServiceHandler } from 'suioop';
// Setup the executor service
const eshandler = await ExecutorServiceHandler.initialize(adminKeypair, client);
// Define the number of transactions to execute
const promises = [];
let txb: TransactionBlockWithLambda;
for (let i = 0; i < 10; i++) {
txb = new TransactionBlockWithLambda(() =>
ceatePaymentTxb('<recipient-address>'),
);
promises.push(eshandler.execute(txb, client));
}
// Collect the promise results
const results = await Promise.allSettled(promises);
Notice that we are using a
TransactionBlockWithLambda()
and notTransactionBlock()
.TransactionBlockWithLambda
is a more flexible way of defining transaction blocks. What differs is that the transaction block will be created later, just before the transaction execution is done by a worker pool.
It's that simple! 🚀
Defining a custom SplitStrategy
In the above example, given that we have not defined a split strategy explicitly, we have
used the DefaultSplitStrategy
.
This default split strategy only picks enough gas coins (i.e., coins of type 0x2::coin::Coin<0x2::sui::SUI>
)
from the mainPool
that their sum of balances surpasses a minimum threshold, and creates a new
worker pool only containing these gas coins.
It fulfils the minimum requirement needed for a transaction block to be executed: the client should always need to be able to pay for the gas of the transaction.
However, in more complex scenarios, you might want to define your own split strategy.
Let's assume that you would like to execute multiple transactions that transfer an object
of type CapyNFT
each to a different recipient.
In order for this to work, the ExecutorServiceHandler
would need to split
the mainPool
in a way such that every worker:
- Contains at least one
CapyNFT
object - Contains at least a coin (or set of coins) with a total balance enough to pay for the gas of the transaction.
To do this, you have to implement the SplitStrategy
interface. In detail:
class MyCustomSplitStrategy implements SplitStrategy {
private capyIncluded = false;
private balanceSoFar = 0;
private readonly minimumBalance;
public pred(obj: PoolObject | undefined) {
if (!obj) throw new Error('No object found!.');
// If we have fulfilled each requirement then terminate the split by returning null
// This will stop the split process and the worker pool will be created
const terminateWhen =
this.balanceSoFar >= this.minimumBalance && this.capyIncluded;
if (terminateWhen) {
return null;
}
// If we have not already included a CapyNFT object, and the object is a CapyNFT, then include it
if (!capyIncluded && obj.type.includes('CapyNFT')) {
this.capyIncluded = true;
return true;
}
// If the object is a coin and we still need to get coins, then we include it to the new pool
if (this.balanceSoFar >= this.minimumBalance && isCoin(obj.type)) {
this.balanceSoFar += obj.balance ?? 0;
return true;
} else {
return false;
}
}
// This function is called during the split process to check if the split was successful
public succeeded() {
return this.balanceSoFar >= this.minimumBalance && this.adminCapIncluded;
}
}
You can find more examples of split strategies (including an admin cap inclusion case)
in the splitStrategies.ts
file.
Tying it all together: end-to-end examples
Use case 1: Parallel coin transfers service—Multiple Coins
Assume that we have a service that needs to do payments of size 50000000
MIST to multiple recipients in parallel.
Before creating an ExecutorServiceHandler
instance that will execute each incoming transaction,
we first need to have a set of coins that will be used to do the coin transferring and pay for the gas of each transaction.
Note:
ExecutorServiceHandler
creates worker pools that handle the execution of the transactions. The maximum number of worker pools that can be created is tightly coupled with the number of your account's coins.
Here is the code that creates the coins by splitting a single coin 20 times and transferring the new coins to your address:
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { SuiClient } from '@mysten/sui.js/client/';
import type { SuiObjectRef, SuiObjectResponse } from '@mysten/sui.js/client/';
const client = new SuiClient({
url: 'https://fullnode.testnet.sui.io:443',
});
const objectId: string = '<your-coin-object-id>'; // A
const yourAddressSecretKey: string = '<your-address-secret-key>';
const numberOfCoinsToCreate = 5;
/// Splits a specific coin and then transfer the new coins to the same address.
for (let i = 0; i < numberOfCoinsToCreate; i++) {
await splitCoinAndTransferToSelf(client, objectId, yourAddressSecretKey);
}
async function splitCoinAndTransferToSelf(
client: SuiClient,
coinObjectId: string,
yourAddressSecretKey: string,
) {
const txb = new TransactionBlock();
const coinToPay = await client.getObject({ id: coinObjectId });
const newcoins1 = txb.splitCoins(txb.gas, [txb.pure(300000000)]);
// const newcoins2 = txb.splitCoins(txb.gas, [txb.pure(300000000)]);
txb.transferObjects(
[
newcoins1,
// newcoins2
],
txb.pure(getKeyPair(yourAddressSecretKey).getPublicKey().toSuiAddress()),
);
txb.setGasPayment([toSuiObjectRef(coinToPay)]);
txb.setGasBudget(100000000);
await client
.signAndExecuteTransactionBlock({
signer: getKeyPair(yourAddressSecretKey),
transactionBlock: txb,
requestType: 'WaitForLocalExecution',
options: {
showEffects: true,
},
})
.then((txRes) => {
const status = txRes.effects?.status?.status;
if (status !== 'success') {
throw new Error(
`Could not split coin! ${txRes.effects?.status?.error}`,
);
}
return txRes;
})
.catch((err) => {
throw new Error(`Error thrown: Could not split coin!: ${err}`);
});
}
Now that we have the coins, we can create the ExecutorServiceHandler
instance and execute the transactions.
Note that we provide the DefaultSplitStrategy
as a parameter to the ExecutorServiceHandler
constructor
setting the minimumBalance
equal to 300000000
, meaning that the worker pool that will be created will need
to have a set of coins that the sum of their balances is greater or equal to this.
import { ExecutorServiceHandler } from 'suioop';
import { DefaultSplitStrategy } from 'suioop';
// Setup the executor service
const eshandler = await ExecutorServiceHandler.initialize(adminKeypair, client);
// Define the number of transactions to execute
const promises = [];
let txb: TransactionBlockWithLambda;
for (let i = 0; i < 10; i++) {
txb = new TransactionBlockWithLambda(
() => createPaymentTxb('<recipient-address>'), // Use a test user address to receive the txbs
);
promises.push(
eshandler.execute(
txb,
client,
new DefaultSplitStrategy(300000000), // Each pool will contain coins with a total balance of 300000000 MIST
),
);
}
// Collect the promise results
const results = await Promise.allSettled(promises);
Note: Providing a
SplitStrategy
is optional. If you don't provide one, theDefaultSplitStrategy
will be used, which also has a default minimum balance. But for demonstration purposes, we provide here a minimum pool balance of 300000000.
Use case 2: mint & transfer NFTs concurrently using multiple Admin Caps
Assume that we have a service that needs to mint and transfer NFTs concurrently to multiple recipients. Similar to the previous example, we first need to have a set of coins that will be used to pay for the gas of each transaction. In addition, we need to have a set of admin caps that will be used to mint the NFTs.
Apart from the coin creation that we did earlier, you need to create multiple admin caps for your account.
To do this, you can either use the createAdminCap
function in your
smart contract, or generate multiple admin caps on package publishing.
You can test this by using the move_examples/nft_app
example.
Notice how we create multiple admin caps in genesis.move
:
// ...
// Generate 20 Admin Caps, for parallelization of transactions
let i = 0;
while (i <= 20) {
// Transfer Admin Cap to sender
transfer::public_transfer(AdminCap {
id: object::new(ctx)
}, sender(ctx));
i = i + 1;
}
// ...
Now that we have the coins and the admin caps, we can define the
mintNFTTxb
function that will create and return the transaction block
of type TransactionBlockWithLambda
.
function mintNFTTxb(
env: EnvironmentVariables,
adminKeypair: Ed25519Keypair,
): TransactionBlockWithLambda {
const txbLambda = (adminCapId: string) => {
const txb = new TransactionBlock();
const hero = txb.moveCall({
arguments: [
txb.object(adminCapId),
txb.pure('zed'),
txb.pure('gold'),
txb.pure(3),
txb.pure('ipfs://example.com/'),
],
target: `${env.NFT_APP_PACKAGE_ID}::hero_nft::mint_hero`,
});
txb.transferObjects(
[hero],
txb.pure(adminKeypair.getPublicKey().toSuiAddress()),
);
txb.setGasBudget(10000000);
return txb;
};
return new TransactionBlockWithLambda(txbLambda, ['AdminCap']);
}
Having done the preparatory steps above, it's time to create the
ExecutorServiceHandler
instance and execute the transactions.
const eshandler = await ExecutorServiceHandler.initialize(adminKeypair, client);
const promises: Promise<SuiTransactionBlockResponse>[] = [];
let txb: TransactionBlockWithLambda;
for (let i = 0; i < NUMBER_OF_TRANSACTION_TO_EXECUTE; i++) {
txb = mintNFTTxb(env, adminKeypair);
promises.push(
eshandler.execute(
txb,
client,
new IncludeAdminCapStrategy('<your-nft-package-id>'),
),
);
}
const results = await Promise.allSettled(promises);
results.forEach((result) => {
if (result.status === 'rejected') {
console.error(result.reason);
}
});
Processing Flow
The overall processing flow is depicted in the following flowchart:
Local Development
Installing the library
Install dependencies with npm install
Code consistency
Before committing your changes, run npm run lint
to check for code style consistency.
Testing
Tests are a great way to get familiar with the library. For each test scenario, there is a small description of the test's purpose and the library's commands to achieve that.
To set up the tests environment navigate to the test folder and run ./initial_setup.sh <network>
where network can be testnet
, mainnet
or devnet
. Omit the network param for localnet
.
The script will create a .test.env
file in the test folder.
When the script is complete you only need to add a ADMIN_SECRET_KEY
and a TEST_USER_SECRET
to the .env
.
Usually we use the testnet network for testing. Switch to testnet with: sui client switch --env testnet
At the end of the setup your .env should look like the template .test.env.example. i.e.
# The Admin should also be the publisher of the nft_app smart contract
ADMIN_SECRET_KEY=
# A user address that is used as a receiver of txbs. Used for testing.
TEST_USER_ADDRESS=
TEST_USER_SECRET=
# Used for testing. Get this by publishing the move_examples/nft_app/
NFT_APP_PACKAGE_ID=
NFT_APP_ADMIN_CAP=
# Example: "https://fullnode.testnet.sui.io:443"
SUI_NODE=
GET_WORKER_TIMEOUT_MS=1000
Tip: You can see your addresses' secret keys by running cat ~/.sui/sui_config/sui.keystore
. Each
secret's corresponding address is in the same row line that appears in sui client addresses
.
We use the jest framework for testing. Having installed the project's packages with npm install
, you can run the tests by either:
- The vscode
jest
extension (Extension Id: Orta.vscode-jest) - [Recommended]
The extension provides a flask to the IDE sidebar where you run the tests (altogether or one-by-one) and show the results in the editor. You can also run the tests in debug mode and set breakpoints in the code. Very useful when doing TDD.
- ... or from the command line using
npm run test
- Best for CI/CD