Skip to main content

Extend a persistent entry and contract using the JavaScript SDK

Persistent storage gives you a durable place to keep contract data on the network for a long time. Anything you can't cheaply recreate (user balances, configuration, and the like) belongs there. A contract's instance entry and the Wasm code entry it points to are separate ledger entries, but they live under the same archival rules, so they need the same care.

Every persistent entry on the ledger is a key-value pair made up of:

  • a key (the identifier for the data),
  • a value (the data itself), and
  • a Time To Live (TTL), which is how many ledgers remain until the entry is no longer live. (The absolute cutoff is stored on the entry itself as its liveUntilLedger.)

Once an entry's TTL runs out, the network archives it. Archived persistent data isn't lost (it can be restored), but nothing can read it until that happens. Keeping the TTL topped up before it expires is the cheaper, less exciting path, and that's what we'll walk through here.

By the end of this guide you'll have four helper functions you can drop into a project: one that extends a single persistent entry, one for a contract's instance, one for the Wasm code that instance points to, and one that extends all three together in a single operation.

The extension process

  1. Build the ledger key for the entry you want to extend.
  2. Build a transaction containing an extendFootprintTtl operation with your target TTL, and attach the Soroban transaction data.
  3. Prepare the transaction (this runs simulation to fill in the resources and fee for you), sign it with your secret key, and submit it to the network.
note

extendFootprintTtl sets a floor, not an exact value. Per the ExtendFootprintTTLOp semantics, the operation guarantees each entry in the read-only footprint lives for at least extendTo ledgers from now. Entries that already live longer than your target are left alone, so a repeated extension is harmless.

There's also a ceiling: an entry can only be extended up to the network's maximum TTL, which is a network parameter (check the network limits in the Stellar Lab for the current value).

Extending a persistent entry

The example below assumes your contract keys its data with a symbol, which is the common case. If your contract uses a vec, a map, or a struct as its storage key, build the ScVal that matches instead of reaching for scvSymbol.

import * as StellarSdk from "@stellar/stellar-sdk";
import { Server } from "@stellar/stellar-sdk/rpc";

async function extendPersistentEntryTTL(
contractId: string,
storageKey: string | Buffer<ArrayBufferLike>,
sourceKeypair: StellarSdk.Keypair,
) {
const server = new Server("https://soroban-testnet.stellar.org");
const account = await server.getAccount(sourceKeypair.publicKey());
const fee = "100"; // BASE_FEE of 100 stroops

// Create an identifier for the persistent entry
const persistentEntry = StellarSdk.xdr.LedgerKey.contractData(
new StellarSdk.xdr.LedgerKeyContractData({
contract: StellarSdk.Address.fromString(contractId).toScAddress(),
key: StellarSdk.xdr.ScVal.scvSymbol(storageKey),
durability: StellarSdk.xdr.ContractDataDurability.persistent(),
}),
);

// Build the transaction
let transaction: StellarSdk.Transaction | StellarSdk.TransactionBuilder =
new StellarSdk.TransactionBuilder(account, {
fee,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.extendFootprintTtl({
extendTo: 100_000, // The number of ledgers past the LCL (last closed ledger) by which to extend. Roughly 5 days
}),
)
.setTimeout(30);

// Attach Soroban transaction data
const sorobanData = new StellarSdk.SorobanDataBuilder()
.setReadOnly([persistentEntry])
.build();
transaction.setSorobanData(sorobanData);

// Prepare the transaction for signing.
transaction = await server.prepareTransaction(transaction.build());

transaction.sign(sourceKeypair);
return await server.sendTransaction(transaction);
}

Extending the contract instance

Contract.getFootprint() hands you the ledger key for the deployed contract instance, so you don't have to assemble that one by hand.

note

This extends the contract instance only. The Wasm code the instance points to is a separate ledger entry with its own TTL, and an extendFootprintTtl operation only touches the keys you actually put in the read-only footprint. A live instance backed by archived code still leaves your contract unusable, so the next section covers the code entry too.

If you're coming from the Rust side, this is a real difference worth internalizing: inside a contract, env.storage().instance().extend_ttl() extends the instance and the code together. The operation used here does not.

import * as StellarSdk from "@stellar/stellar-sdk";
import { Server } from "@stellar/stellar-sdk/rpc";

async function extendContractInstanceTTL(
contractId: string,
sourceKeypair: StellarSdk.Keypair,
) {
const server = new Server("https://soroban-testnet.stellar.org");
const account = await server.getAccount(sourceKeypair.publicKey());
const fee = "100";

// Get the ledger key for the contract instance
const contract = new StellarSdk.Contract(contractId);
const footprint = contract.getFootprint();

// Build the transaction
let transaction: StellarSdk.Transaction | StellarSdk.TransactionBuilder =
new StellarSdk.TransactionBuilder(account, {
fee,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.extendFootprintTtl({
extendTo: 100_000, //The number of ledgers past the LCL by which to extend
}),
)
.setTimeout(30);

// Attach Soroban transaction data
const sorobanData = new StellarSdk.SorobanDataBuilder()
.setReadOnly([footprint])
.build();
transaction.setSorobanData(sorobanData);

// Prepare the transaction
transaction = await server.prepareTransaction(transaction.build());

transaction.sign(sourceKeypair);
return await server.sendTransaction(transaction);
}

Extending the contract code

The code entry is keyed by the Wasm hash rather than by the contract address, and there's no local way to derive one from the other. That means one extra round trip: read the contract's instance entry from the network, pull the Wasm hash out of its executable, and build a contractCode ledger key from it.

It's worth knowing every contract deployed from the same Wasm shares that single code entry, so extending it benefits all of them at once.

import * as StellarSdk from "@stellar/stellar-sdk";
import { Server } from "@stellar/stellar-sdk/rpc";

// Look up the hash of the Wasm code that a deployed contract runs.
async function getWasmHash(server: Server, contractId: string) {
const contract = new StellarSdk.Contract(contractId);
const { entries } = await server.getLedgerEntries(contract.getFootprint());

if (!entries || entries.length === 0) {
throw new Error(`No instance entry found for ${contractId}`);
}

const instance = entries[0].val.contractData().val().instance();
return instance.executable().wasmHash();
}

async function extendContractCodeTTL(
contractId: string,
sourceKeypair: StellarSdk.Keypair,
) {
const server = new Server("https://soroban-testnet.stellar.org");
const account = await server.getAccount(sourceKeypair.publicKey());
const fee = "100";

// Create an identifier for the Wasm code entry
const codeEntry = StellarSdk.xdr.LedgerKey.contractCode(
new StellarSdk.xdr.LedgerKeyContractCode({
hash: await getWasmHash(server, contractId),
}),
);

// Build the transaction
let transaction: StellarSdk.Transaction | StellarSdk.TransactionBuilder =
new StellarSdk.TransactionBuilder(account, {
fee,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.extendFootprintTtl({
extendTo: 100_000, // The number of ledgers past the LCL by which to extend
}),
)
.setTimeout(30);

// Attach Soroban transaction data
const sorobanData = new StellarSdk.SorobanDataBuilder()
.setReadOnly([codeEntry])
.build();
transaction.setSorobanData(sorobanData);

// Prepare the transaction
transaction = await server.prepareTransaction(transaction.build());

transaction.sign(sourceKeypair);
return await server.sendTransaction(transaction);
}
caution

getWasmHash assumes the contract runs an executable that was deployed. A Stellar Asset Contract instance has a built-in executable instead of a Wasm hash, so executable().wasmHash() will throw for one of those. Check instance.executable().switch() first if your code has to handle both.

Extending all three together

A Soroban operation has to be the only operation in its transaction, so you might expect to need one transaction per entry. Happily, you don't. A single ExtendFootprintTTLOp extends every entry listed in the transaction's read-only footprint, applying the same extendTo target to all of them. When a batch of entries should live until the same ledger, list them all in one footprint and extend them together. Here's all three at once, reusing the getWasmHash helper from the previous section:

import * as StellarSdk from "@stellar/stellar-sdk";
import { Server } from "@stellar/stellar-sdk/rpc";

async function extendContractAndPersistentEntry(
contractId: string,
storageKey: string | Buffer<ArrayBufferLike>,
sourceKeypair: StellarSdk.Keypair,
) {
const server = new Server("https://soroban-testnet.stellar.org");
const account = await server.getAccount(sourceKeypair.publicKey());
const fee = "100";

// Ledger key for the persistent entry...
const persistentEntry = StellarSdk.xdr.LedgerKey.contractData(
new StellarSdk.xdr.LedgerKeyContractData({
contract: StellarSdk.Address.fromString(contractId).toScAddress(),
key: StellarSdk.xdr.ScVal.scvSymbol(storageKey),
durability: StellarSdk.xdr.ContractDataDurability.persistent(),
}),
);

// ...the contract instance...
const contract = new StellarSdk.Contract(contractId);

// ...and the Wasm code that instance runs.
const codeEntry = StellarSdk.xdr.LedgerKey.contractCode(
new StellarSdk.xdr.LedgerKeyContractCode({
hash: await getWasmHash(server, contractId),
}),
);

// A single ExtendFootprintTtl operation extends the whole read-only footprint.
let transaction: StellarSdk.Transaction | StellarSdk.TransactionBuilder =
new StellarSdk.TransactionBuilder(account, {
fee,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.extendFootprintTtl({
extendTo: 100_000, // one shared target, applied to every key below
}),
)
.setTimeout(30);

// List every key to extend together in one read-only footprint.
const sorobanData = new StellarSdk.SorobanDataBuilder()
.setReadOnly([persistentEntry, contract.getFootprint(), codeEntry])
.build();
transaction.setSorobanData(sorobanData);

transaction = await server.prepareTransaction(transaction.build());
transaction.sign(sourceKeypair);
return await server.sendTransaction(transaction);
}

That shared extendTo is the one real constraint. Entries that need different target TTLs can't share an operation, so extend each one in its own transaction. That's exactly what the extendPersistentEntryTTL, extendContractInstanceTTL, and extendContractCodeTTL helpers above do: each builds its own operation, so each can carry its own extendTo target.

note

Batching keys into one footprint grows the transaction's resource usage, since diskReadBytes has to cover the serialized size of every entry in the read-only set. server.prepareTransaction works that out for you, but a large enough batch can bump into the network's transaction limits. If that happens, split the batch across a few transactions.

Putting one of these to work looks like this:

const contractId = "CBEE2DHGMYRKJKSJO5E55LBKFMPXE57ZWKTXTCRMC5ANIRJ7IW2Y2WVE";
const storageKey = "BALANCE";
const sourceKeypair = StellarSdk.Keypair.fromSecret(
"SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
);

extendPersistentEntryTTL(contractId, storageKey, sourceKeypair)
.then((result) => console.log("Extension successful:", result))
.catch((error) => console.error("Extension failed:", error));