Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 37 additions & 25 deletions crates/node-litesvm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,37 +17,49 @@ This example just transfers lamports from Alice to Bob without loading
any programs of our own. It uses the [Node.js test runner](https://nodejs.org/api/test.html).

```ts
import { test } from "node:test";
mport { test } from "node:test";
import assert from "node:assert/strict";
import { LiteSVM } from "litesvm";
import {
PublicKey,
Transaction,
SystemProgram,
Keypair,
LAMPORTS_PER_SOL,
} from "@solana/web3.js";
generateKeyPairSigner,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
signTransactionMessageWithSigners,
pipe,
blockhash,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";

test("one transfer", () => {
const LAMPORTS_PER_SOL = 1_000_000_000n;

test("one transfer", async () => {
const svm = new LiteSVM();
const payer = new Keypair();
svm.airdrop(payer.publicKey, BigInt(LAMPORTS_PER_SOL));
const receiver = PublicKey.unique();
const blockhash = svm.latestBlockhash();
const payer = await generateKeyPairSigner();
svm.airdrop(payer.address, LAMPORTS_PER_SOL);

const receiver = await generateKeyPairSigner();
const latestBlockhash = blockhash(svm.latestBlockhash());
const transferLamports = 1_000_000n;
const ixs = [
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: receiver,
lamports: transferLamports,
}),
];
const tx = new Transaction();
tx.recentBlockhash = blockhash;
tx.add(...ixs);
tx.sign(payer);
svm.sendTransaction(tx);
const balanceAfter = svm.getBalance(receiver);

const transferInstruction = getTransferSolInstruction({
source: payer,
destination: receiver.address,
amount: transferLamports,
});

const transactionMessage = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(payer, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash({ blockhash: latestBlockhash, lastValidBlockHeight: 0n }, tx),
(tx) => appendTransactionMessageInstructions([transferInstruction], tx),
);

const signedTransaction = await signTransactionMessageWithSigners(transactionMessage);

svm.sendTransaction(signedTransaction);
const balanceAfter = svm.getBalance(receiver.address);
assert.strictEqual(balanceAfter, transferLamports);
});
```
Expand Down
117 changes: 61 additions & 56 deletions crates/node-litesvm/litesvm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,42 +33,45 @@ export {
TransactionMetadata,
TransactionReturnData,
} from "./internal";

import type { Address, Transaction } from "@solana/kit";
import {
AccountInfo,
PublicKey,
Transaction,
VersionedTransaction,
} from "@solana/web3.js";
getAddressEncoder,
getAddressDecoder,
getTransactionEncoder,
getCompiledTransactionMessageDecoder,
AccountInfoBase,
lamports
} from "@solana/kit";

export type AccountInfoBytes = AccountInfo<Uint8Array>;
export type AccountInfoBytes = AccountInfoBase & { data: Uint8Array };

function toAccountInfo(acc: Account): AccountInfoBytes {
const owner = new PublicKey(acc.owner());
const owner = getAddressDecoder().decode(acc.owner());
return {
executable: acc.executable(),
owner,
lamports: Number(acc.lamports()),
lamports: lamports(acc.lamports()),
data: acc.data(),
rentEpoch: Number(acc.rentEpoch()),
space: BigInt(acc.data().length),
};
}

function fromAccountInfo(acc: AccountInfoBytes): Account {
const maybeRentEpoch = acc.rentEpoch;
const rentEpoch = maybeRentEpoch || 0;
return new Account(
BigInt(acc.lamports),
acc.data,
acc.owner.toBytes(),
acc.executable,
BigInt(rentEpoch),
);
const owner = getAddressEncoder().encode(acc.owner);
return new Account(
BigInt(acc.lamports),
new Uint8Array(acc.data),
new Uint8Array(owner),
acc.executable,
BigInt(acc.space)
);
}

function convertAddressAndAccount(
val: AddressAndAccount,
): [PublicKey, Account] {
return [new PublicKey(val.address), val.account()];
): [Address, Account] {
return [getAddressDecoder().decode(val.address), val.account()];
}

export class SimulatedTransactionInfo {
Expand All @@ -79,7 +82,7 @@ export class SimulatedTransactionInfo {
meta(): TransactionMetadata {
return this.inner.meta();
}
postAccounts(): [PublicKey, Account][] {
postAccounts(): [Address, Account][] {
return this.inner.postAccounts().map(convertAddressAndAccount);
}
}
Expand Down Expand Up @@ -227,8 +230,8 @@ export class LiteSVM {
* @param address - The account address to look up.
* @returns The account object, if the account exists.
*/
getAccount(address: PublicKey): AccountInfoBytes | null {
const inner = this.inner.getAccount(address.toBytes());
getAccount(address: Address): AccountInfoBytes | null {
const inner = this.inner.getAccount(new Uint8Array(getAddressEncoder().encode(address)));
return inner === null ? null : toAccountInfo(inner);
}

Expand All @@ -243,17 +246,17 @@ export class LiteSVM {
* @param address - The address to write to.
* @param account - The account object to write.
*/
setAccount(address: PublicKey, account: AccountInfoBytes) {
this.inner.setAccount(address.toBytes(), fromAccountInfo(account));
setAccount(address: Address, account: AccountInfoBytes) {
this.inner.setAccount(new Uint8Array(getAddressEncoder().encode(address)), fromAccountInfo(account));
}

/**
* Gets the balance of the provided account address.
* @param address - The account address.
* @returns The account's balance in lamports.
*/
getBalance(address: PublicKey): bigint | null {
return this.inner.getBalance(address.toBytes());
getBalance(address: Address): bigint | null {
return this.inner.getBalance(new Uint8Array(getAddressEncoder().encode(address)));
}

/**
Expand Down Expand Up @@ -283,28 +286,28 @@ export class LiteSVM {
* @returns The transaction result.
*/
airdrop(
address: PublicKey,
address: Address,
lamports: bigint,
): TransactionMetadata | FailedTransactionMetadata | null {
return this.inner.airdrop(address.toBytes(), lamports);
return this.inner.airdrop(new Uint8Array(getAddressEncoder().encode(address)), lamports);
}

/**
* Adds an SBF program to the test environment from the file specified.
* @param programId - The program ID.
* @param path - The path to the .so file.
*/
addProgramFromFile(programId: PublicKey, path: string) {
return this.inner.addProgramFromFile(programId.toBytes(), path);
addProgramFromFile(programId: Address, path: string) {
return this.inner.addProgramFromFile(new Uint8Array(getAddressEncoder().encode(programId)), path);
}

/**
* Adds am SBF program to the test environment.
* @param programId - The program ID.
* @param programBytes - The raw bytes of the compiled program.
*/
addProgram(programId: PublicKey, programBytes: Uint8Array) {
return this.inner.addProgram(programId.toBytes(), programBytes);
addProgram(programId: Address, programBytes: Uint8Array) {
return this.inner.addProgram(new Uint8Array(getAddressEncoder().encode(programId)), programBytes);
}

/**
Expand All @@ -313,19 +316,19 @@ export class LiteSVM {
* @returns TransactionMetadata if the transaction succeeds, else FailedTransactionMetadata
*/
sendTransaction(
tx: Transaction | VersionedTransaction,
tx: Transaction
): TransactionMetadata | FailedTransactionMetadata {
const internal = this.inner;
const serialized = tx.serialize({
requireAllSignatures: true,
verifySignatures: internal.getSigverify(),
});
const compiled = getCompiledTransactionMessageDecoder().decode(
tx.messageBytes
);
const wireBytes = getTransactionEncoder().encode(tx);

if (tx instanceof Transaction) {
return internal.sendLegacyTransaction(serialized);
} else {
return internal.sendVersionedTransaction(serialized);
}
if (compiled.version === "legacy") {
return internal.sendLegacyTransaction(new Uint8Array(wireBytes));
} else {
return internal.sendVersionedTransaction(new Uint8Array(wireBytes));
}
}

/**
Expand All @@ -334,20 +337,22 @@ export class LiteSVM {
* @returns SimulatedTransactionInfo if simulation succeeds, else FailedTransactionMetadata
*/
simulateTransaction(
tx: Transaction | VersionedTransaction,
tx: Transaction
): FailedTransactionMetadata | SimulatedTransactionInfo {
const internal = this.inner;
const serialized = tx.serialize({
requireAllSignatures: true,
verifySignatures: internal.getSigverify(),
});
const inner =
tx instanceof Transaction
? internal.simulateLegacyTransaction(serialized)
: internal.simulateVersionedTransaction(serialized);
return inner instanceof FailedTransactionMetadata
? inner
: new SimulatedTransactionInfo(inner);
const compiled = getCompiledTransactionMessageDecoder().decode(
tx.messageBytes
);
const wireBytes = getTransactionEncoder().encode(tx);
const inner = compiled.version === "legacy"
? internal.simulateLegacyTransaction(new Uint8Array(wireBytes))
: internal.simulateVersionedTransaction(new Uint8Array(wireBytes));

if (inner instanceof FailedTransactionMetadata) {
return inner;
} else {
return new SimulatedTransactionInfo(inner);
}
}

/**
Expand Down Expand Up @@ -494,4 +499,4 @@ export class LiteSVM {
setStakeHistory(history: StakeHistory) {
this.inner.setStakeHistory(history);
}
}
}
42 changes: 27 additions & 15 deletions crates/node-litesvm/no-ci-tests/copyAccounts.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { LiteSVM } from "litesvm";
import { PublicKey, Connection } from "@solana/web3.js";
import { LiteSVM, AccountInfoBytes } from "../litesvm";
import { address, createSolanaRpc, fetchJsonParsedAccount, lamports } from "@solana/kit";

test("copy accounts from devnet", async () => {
const owner = PublicKey.unique();
const usdcMint = new PublicKey(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
);
const connection = new Connection("https://api.devnet.solana.com");
const accountInfo = await connection.getAccountInfo(usdcMint);
// the rent epoch goes above 2**53 which breaks web3.js, so just set it to 0;
accountInfo.rentEpoch = 0;
const svm = new LiteSVM();
svm.setAccount(usdcMint, accountInfo);
const rawAccount = svm.getAccount(usdcMint);
assert.notStrictEqual(rawAccount, null);
});
const usdcMint = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const rpc = createSolanaRpc("https://api.devnet.solana.com");
const accountInfo = await fetchJsonParsedAccount(rpc, usdcMint);
const svm = new LiteSVM();
if ("exists" in accountInfo && !accountInfo.exists) {
assert.fail("Account does not exist on devnet");
return;
}
const account = accountInfo as any;
const dataArray = Array.isArray(account.data)
? new Uint8Array(account.data)
: new Uint8Array(Object.values(account.data));

const accountToSet: AccountInfoBytes = {
executable: Boolean(account.executable),
owner: account.owner || usdcMint,
lamports: lamports(BigInt(account.lamports || 0)),
data: dataArray,
space: BigInt(dataArray.length),
};

svm.setAccount(usdcMint, accountToSet);
const rawAccount = svm.getAccount(usdcMint);
assert.notStrictEqual(rawAccount, null);
});
9 changes: 6 additions & 3 deletions crates/node-litesvm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@
},
"license": "MIT",
"dependencies": {
"@solana/web3.js": "^1.98.4",
"@solana/kit": "^4.0.0",
"fastestsmallesttextencoderdecoder": "^1.0.22"
},
"devDependencies": {
"@algolia/client-search": "^5.39.0",
"@napi-rs/cli": "^3.2.0",
"@solana/spl-token": "^0.4.14",
"@solana-program/system": "0.9.0",
"@solana-program/token": "^0.7.0",
"@types/fastestsmallesttextencoderdecoder": "^1",
"@types/markdown-it": "^14.1.2",
"@types/node": "22.18.6",
"@types/node-fetch": "^2.6.13",
"@types/ws": "^8",
"markdown-it": "^14.1.0",
"markdown-it-include": "^2.0.0",
"rome": "^12.1.3",
Expand All @@ -39,7 +41,8 @@
"typedoc-plugin-markdown": "^4.9.0",
"typedoc-vitepress-theme": "^1.1.2",
"typescript": "^5.9.2",
"vitepress": "^1.6.4"
"vitepress": "^1.6.4",
"ws": "^8.18.3"
},
"engines": {
"node": ">= 20"
Expand Down
Loading