From c865983b68fe25ec4366f296966d041c5e446b3c Mon Sep 17 00:00:00 2001 From: George Date: Tue, 5 Sep 2023 08:29:19 -0700 Subject: [PATCH] Adds a JavaScript tutorial on how to perform state restoration (#522) * Add initial skeleton to state-exp tutorial * I hate this prettier config but so be it :shrug: * Add untested code around restoring state * Add snippet on building a restore tx * Fixup headers: single # is hidden * Add explanation around filtering entries * Clean up code * Keep code on pace with stellar/js-stellar-base#660 * fmt fixup * Some code/doc/markdown fixups * Add a link * Add simulation error check * Doc fixup * Update tutorial to match latest Soroban RPC / client schemas * Add details, switch to await-based code * Formatting fixup * Add cross-ref links to each example * Remove extraneous instructions * Update copy after self-review * Code compilation fixups * Incorporate easy PR feedback * Clean up language based on feedback * couple grammar nits * Fixup the s -> server rename * Fixup sentence wording * Be number-specific Co-authored-by: Leigh McCulloch <351529+leighmcculloch@users.noreply.github.com> --------- Co-authored-by: Bri Wylde <92327786+briwylde08@users.noreply.github.com> Co-authored-by: Leigh McCulloch <351529+leighmcculloch@users.noreply.github.com> --- .../state-expiration.mdx | 250 +++++++++++++++++- 1 file changed, 238 insertions(+), 12 deletions(-) diff --git a/docs/fundamentals-and-concepts/state-expiration.mdx b/docs/fundamentals-and-concepts/state-expiration.mdx index 97fc09b6..cfa704b7 100644 --- a/docs/fundamentals-and-concepts/state-expiration.mdx +++ b/docs/fundamentals-and-concepts/state-expiration.mdx @@ -20,20 +20,20 @@ description: Smart contract state expiration. Contract data is made up of three different types: `Persistent`, `Temporary`, and `Instance`. In a contract, these are accessed with `env.storage().persistent()`, `env.storage().temporary()`, and `env.storage().instance()` respectively; see the [`storage()` docs](https://docs.rs/soroban-sdk/latest/soroban_sdk/storage/struct.Storage.html). -All contract data has a "lifetime" that must be periodically bumped. If an entry's lifetime is not periodically bumped, the entry will eventually reach the end of its lifetime and "expire". Each type of storage functions similarly, but have different fees and expiration behavior: +All contract data has a "lifetime" that must be periodically bumped. If an entry's lifetime is not periodically bumped, the entry will eventually reach the end of its lifetime and "expire". Each type of storage functions similarly, but has different fees and expiration behavior: - When a `Temporary` entry expires, it is deleted from the ledger and is permanently inaccessible. - When a `Persistent` or `Instance` entry expires, it is inaccessible, but can be "restored" and used again via the [`RestoreFootprintOp`]. ## Contract Data Type Descriptions -The general usage and interface is identical for all storage types. They differ only in fees and expiration behavior as follows: +The general usage and interface are identical for all storage types. They differ only in fees and expiration behavior as follows: ### `Temporary` - Cheapest fees. - Permanently deleted on expiration, cannot be restored. -- Suitable for time bounded data (i.e. price oracles, signatures, etc.) and easily recreateable data. +- Suitable for time-bounded data (i.e. price oracles, signatures, etc.) and easily recreateable data. - Unlimited amount of storage. ### `Instance` @@ -52,11 +52,9 @@ The general usage and interface is identical for all storage types. They differ - Unlimited amount of storage. - Suitable for user data that cannot be `Temporary` (i.e. balances). - [`RestoreFootprintOp`]: #RestoreFootprintOp - ## Contract Data Best Practices -As a general rule, `Temporary` storage should only be used for data that can be easily recreated or is only valid for a period of time, where `Persistent` or `Instance` storage should be used for data that cannot be recreated and should be kept permanently, such as a user's token balance. +As a general rule, `Temporary` storage should only be used for data that can be easily recreated or is only valid for a period of time, whereas `Persistent` or `Instance` storage should be used for data that cannot be recreated and should be kept permanently, such as a user's token balance. Each storage type is in a separate key space. To demonstrate this, see the code snippet below: @@ -75,9 +73,7 @@ of the entry being bumped as well as the new lifetime. A call to `bump(N)` ensures that the current lifetime of the contract instance entry is _at least_ N ledgers. For example, if `bump(100)` is called and the contract instance entry has a current lifetime of 50 ledgers, the lifetime will be extended to 100 ledgers. If `bump(100)` is called and the contract instance entry has a current lifetime of 150 ledgers, the lifetime will not be extended and the `bump()` call is a no-op. -In addition to contract defined lifetime extensions using the `bump()` function, a contract data entry's lifetime can be extended via the [`BumpFootprintExpirationOp`] operation. - -[`BumpFootprintExpirationOp`]: #BumpFootprintExpirationOp +In addition to contract-defined lifetime extensions using the `bump()` function, a contract data entry's lifetime can be extended via the [`BumpFootprintExpirationOp`] operation. ## Terms and Semantics @@ -102,7 +98,7 @@ is a network parameter and defaults to 16 ledgers for `Temporary` entries and 4, ### Maximum Lifetime On any given ledger, an entry's lifetime can be extended up to the maximum lifetime. This is a -network parameter and defaults to 1 year worth of ledgers. This maximum lifetime is not enforced +network parameter and defaults to 1 year's worth of ledgers. This maximum lifetime is not enforced based on when an entry was created, but based on the current ledger. For example, if an entry is created on January 1st, 2024, its lifetime could initially be bumped up to January 1st, 2025. After this initial lifetime bump, if the entry received another lifetime bump later on January 10th, 2024, @@ -158,8 +154,7 @@ only operation in a transaction. The transaction also needs to populate [here](../fundamentals-and-concepts/invoking-contracts-with-transactions.mdx#transaction-resources). To fill out `SorobanResources`, use preflight mentioned in the provided link, or make sure `readBytes` includes the key and entry size of every entry in the -`readOnly` set and make sure `extendedMetaDataSizeBytes` is at least double of -`readBytes`. +`readOnly` set. ### RestoreFootprintOp @@ -198,3 +193,234 @@ out `SorobanResources`, use preflight mentioned in the provided link, or make sure `writeBytes` includes the key and entry size of every entry in the `readWrite` set and make sure `extendedMetaDataSizeBytes` is at least double of `writeBytes`. + +--- + +## Examples + +We've done our best to build tooling around state expiration in both the Soroban RPC server as well as the JavaScript SDK to make it easier to deal with, and this set of examples demonstrates how to leverage it. + +### Overview + +Both restoring and bumping the expiration of ledger entries follows a three-step process regardless of their nature (contract data, instances, etc.): + +1. **Identify the ledger entries**. This usually means acquiring them from a Soroban RPC server as part of your initial transaction simulation (see the [preflight docs](https://soroban.stellar.org/docs/fundamentals-and-concepts/interacting-with-contracts#preflight) and the [`simulateTransaction`](https://soroban.stellar.org/api/methods/simulateTransaction) method). + +2. **Prepare your operation**. This means describing the ledger entries within the corresponding operation (i.e. `bumpFootprintOp` or `restoreFootprintOp`) and its ledger footprint (the `SorobanTransactionData` field), then simulating it to fill out fee and resource usage information (when restoring, you usually have simulation results already). + +3. **Submit the transaction** and start again with what you were trying to do in the first place. + +Each of the examples below will follow a structure like this. We'll work our way through two different scenarios: + +1. [a piece of persistent data in my contract expired](#example-my-data-expired) +2. [my contract instance or the WASM expired](#example-my-contract-expired) + +Remember, though, that any combination of these scenarios can occur in reality. + +### Preparation + +In order to help the scaffolding of the code, we'll introduce some reusable components. The following is a simple, rudimentary looping mechanism to submit a transaction to Soroban RPC and wait for a result: + +```typescript +import { + Server, + SorobanRpc, + Transaction, + FeeBumpTransaction, +} from "soroban-client"; + +const RPC_SERVER = "https://rpc-futurenet.stellar.org/"; +const server = new Server(RPC_SERVER); + +// Submits a tx and then polls for its status until a timeout is reached. +async function yeetTx( + tx: Transaction | FeeBumpTransaction, +): Promise { + return server.sendTransaction(tx).then(async (reply) => { + if (reply.status !== "PENDING") { + throw reply; + } + + let status; + let attempts = 0; + while (attempts++ < 5) { + const tmpStatus = await server.getTransaction(reply.hash); + switch (tmpStatus.status) { + case "FAILED": + throw tmpStatus; + case "NOT_FOUND": + await sleep(500); + continue; + case "SUCCESS": + status = tmpStatus; + break; + } + } + + if (attempts >= 5 || !status) { + throw new Error(`Failed to find transaction ${reply.hash} in time.`); + } + + return status; + }); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +``` + +We'll use this helper below to submit transactions and report their status reliably. + +:::caution + +Remember: You should always handle errors gracefully! This is a fail-hard and fail-fast approach that should only be used in these examples. + +::: + +In the following code, we will also leverage [`Server.prepareTransaction`](https://stellar.github.io/js-soroban-client/Server.html#prepareTransaction). This is a helpful method that, given a transaction, will simulate it, then amend the transaction with the simulation results (fees, etc.) and return that. Then, it can just be signed and submitted. We will also use [`SorobanDataBuilder`](https://stellar.github.io/js-soroban-client/SorobanDataBuilder.html), a convenient abstraction that lets us use a [builder pattern](https://en.wikipedia.org/wiki/Builder_pattern) to set the appropriate storage footprints for a transaction. + +### Example: My data expired! + +We'll start with the likeliest occurrence: my piece of persistent data expired off of the ledger because I haven't interacted with my contract in a while. How do I get it back? + +In this example, we will assume two things: the contract itself is still alive (i.e. others have been bumping its expiration while you've been away) and you don't know how your expired data is represented on the ledger. If you did, you could skip the steps below where we figure that out and just set up the restoration footprint directly. The process involves three discrete steps: + +1. Simulate our transaction as we normally would. +2. If the simulation indicated it, we perform restoration via [`Operation.restoreFootprint`](https://stellar.github.io/js-soroban-client/Operation.html#.restoreFootprint) using its hints. +3. We retry running our initial transaction. + +Let's see that in code: + +```typescript +import { + BASE_FEE, + Networks, + Keypair, + TransactionBuilder, + SorobanDataBuilder, + assembleTransaction, + xdr, +} from "soroban-client"; // add'l imports to preamble + +// assume that `server` is the Server() instance from the preamble + +async function submitOrRestoreAndRetry( + signer: Keypair, + tx: Transaction, +): Promise { + // We can't use `Server.prepareTransaction` here because we want to do + // restoration if necessary, basically assembling the simulation ourselves. + const sim = await server.simulateTransaction(tx); + + // Other failures are out of scope of this tutorial. + if (!SorobanRpc.isSimulationSuccess(sim)) { + throw sim; + } + + // If simulation didn't fail, we don't need to restore anything! Just send it. + if (!sim.restorePreamble) { + const prepTx = assembleTransaction(tx, Networks.FUTURENET, sim); + prepTx.sign(signer); + return yeetTx(prepTx); + } + + // + // Build the restoration operation using the RPC server's hints. + // + const account = await server.getAccount(signer.publicKey()); + let fee = parseInt(BASE_FEE); + fee += parseInt(sim.restorePreamble.minResourceFee); + + const restoreTx = new TransactionBuilder(account, { fee: fee.toString() }) + .setNetworkPassphrase(Networks.FUTURENET) + .setSorobanData(sim.restorePreamble.transactionData.build()) + .addOperation(Operation.restoreFootprint({})) + .build(); + + restoreTx.sign(signer); + + const resp = await yeetTx(restoreTx); + if (resp.status !== SorobanRpc.GetTransactionStatus.SUCCESS) { + throw resp; + } + + // + // now that we've restored the necessary data, we can retry our tx using + // the initial data from the simulation (which, hopefully, is still + // up-to-date) + // + const retryTxBuilder = TransactionBuilder.cloneFrom(tx, { + fee: (parseInt(tx.fee) + parseInt(sim.minResourceFee)).toString(), + sorobanData: sim.transactionData.build(), + }); + // because we consumed a sequence number when restoring, we need to make sure + // we set the correct value on this copy + retryTxBuilder.source.incrementSequenceNumber(); + + const retryTx = retryTxBuilder.build(); + retryTx.sign(signer); + + return yeetTx(retryTx); +} +``` + +Notice that when restoration is required, **simulation still succeeds**. The way that we know that something needs to be restored is the presence of a `restorePreamble` structure in the RPC's response. This contains both the footprint and fee needed for restoration, while the rest of the response contains the invocation simulation **as if** that restoration was done first. + +This is great, as it means fewer round-trips to get going again! + +### Example: My contract expired! + +As you can imagine, if the ledger cannot find your deployed contract instance or the code that backs it, it can't load it to execute your invocations. Remember, there's a distinct, one-to-many relationship on the chain between a contract's code and deployed instances of that contract: + +```mermaid +flowchart LR + A[my instance] & B[your instance]--> C[contract WASM] +``` + +We need **both** to stay on the ledger for our contract calls to work. + +Let's work through how these can be recovered. The recovery process is slightly different for a convenient reason: we don't need simulation to figure out the footprints. Instead, we can leverage [`Contract.getFootprint()`](https://stellar.github.io/js-soroban-client/Contract.html#getFootprint), which prepares a footprint with the ledger keys used by a given contract instance (including its backing WASM code). + +Unfortunately, we still need simulation to figure out the _fees_ for our restoration. This, however, can be easily covered by the SDK's [`Server.prepareTransaction`](https://stellar.github.io/js-soroban-client/Server.html#prepareTransaction) helper, which will do simulation and assembly for us: + +```typescript +import { + BASE_FEE, + Contract, + Keypair, + Networks, + TransactionBuilder, + SorobanDataBuilder, + Operation, + SorobanRpc, +} from "soroban-client"; + +async function restoreContract( + signer: Keypair, + c: Contract, +): Promise { + const account = await server.getAccount(signer.publicKey()); + const restoreTx = new TransactionBuilder(account, { fee: BASE_FEE }) + .setNetworkPassphrase(Networks.FUTURENET) + .setSorobanData( + // Set the restoration footprint (remember, it should be in the + // read-write part!) + new SorobanDataBuilder().setReadWrite(c.getFootprint()).build(), + ) + .addOperation(Operation.restoreFootprint({})) + .build(); + + const preppedTx = await server.prepareTransaction( + restoreTx, + Networks.FUTURENET, + ); + preppedTx.sign(signer); + return yeetTx(preppedTx); +} +``` + +The nice part about this approach is that it will restore both the instance and the backing WASM code if necessary, skipping either if they're already in the ledger state. + +[`RestoreFootprintOp`]: #RestoreFootprintOp +[`BumpFootprintExpirationOp`]: #BumpFootprintExpirationOp