Skip to content
This repository has been archived by the owner on Aug 21, 2024. It is now read-only.

Commit

Permalink
Adds a JavaScript tutorial on how to perform state restoration (#522)
Browse files Browse the repository at this point in the history
* Add initial skeleton to state-exp tutorial

* I hate this prettier config but so be it 🤷

* 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 <[email protected]>

---------

Co-authored-by: Bri Wylde <[email protected]>
Co-authored-by: Leigh McCulloch <[email protected]>
  • Loading branch information
3 people authored Sep 5, 2023
1 parent 9901fff commit c865983
Showing 1 changed file with 238 additions and 12 deletions.
250 changes: 238 additions & 12 deletions docs/fundamentals-and-concepts/state-expiration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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:

Expand All @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<SorobanRpc.GetTransactionResponse> {
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<SorobanRpc.GetTransactionResponse> {
// 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<SorobanRpc.GetTransactionResponse> {
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

0 comments on commit c865983

Please sign in to comment.