ts: Add program simulate namespace (#266)

This commit is contained in:
Armani Ferrante 2021-05-08 21:31:55 -07:00 committed by GitHub
parent 425997a12d
commit 9b446dbae1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 287 additions and 64 deletions

View File

@ -13,6 +13,7 @@ incremented for features.
## Features
* ts: Add `program.simulate` namespace ([#266](https://github.com/project-serum/anchor/pull/266)).
* cli: Add yarn flag to test command ([#267](https://github.com/project-serum/anchor/pull/267)).
## [0.5.0] - 2021-05-07

View File

@ -57,6 +57,13 @@ pub mod misc {
ctx.accounts.my_account.data = data;
Ok(())
}
pub fn test_simulate(_ctx: Context<TestSimulate>, data: u32) -> ProgramResult {
emit!(E1 { data });
emit!(E2 { data: 1234 });
emit!(E3 { data: 9 });
Ok(())
}
}
#[derive(Accounts)]
@ -120,6 +127,9 @@ pub struct TestU16<'info> {
rent: Sysvar<'info, Rent>,
}
#[derive(Accounts)]
pub struct TestSimulate {}
#[associated]
pub struct TestData {
data: u64,
@ -135,3 +145,18 @@ pub struct Data {
pub struct DataU16 {
data: u16,
}
#[event]
pub struct E1 {
data: u32,
}
#[event]
pub struct E2 {
data: u32,
}
#[event]
pub struct E3 {
data: u32,
}

View File

@ -173,4 +173,23 @@ describe("misc", () => {
);
assert.ok(account.data.toNumber() === 1234);
});
it("Can retrieve events when simulating a transaction", async () => {
const resp = await program.simulate.testSimulate(44);
const expectedRaw = [
"Program Z2Ddx1Lcd8CHTV9tkWtNnFQrSz6kxz2H38wrr18zZRZ invoke [1]",
"Program log: NgyCA9omwbMsAAAA",
"Program log: fPhuIELK/k7SBAAA",
"Program log: jvbowsvlmkcJAAAA",
"Program Z2Ddx1Lcd8CHTV9tkWtNnFQrSz6kxz2H38wrr18zZRZ consumed 4819 of 200000 compute units",
"Program Z2Ddx1Lcd8CHTV9tkWtNnFQrSz6kxz2H38wrr18zZRZ success",
];
assert.ok(JSON.stringify(expectedRaw), resp.raw);
assert.ok(resp.events[0].name === "E1");
assert.ok(resp.events[0].data.data === 44);
assert.ok(resp.events[1].name === "E2");
assert.ok(resp.events[1].data.data === 1234);
assert.ok(resp.events[2].name === "E3");
assert.ok(resp.events[2].data.data === 9);
});
});

View File

@ -2,20 +2,33 @@ import { PublicKey } from "@solana/web3.js";
import * as base64 from "base64-js";
import * as assert from "assert";
import Coder, { eventDiscriminator } from "../coder";
import { Idl } from "../idl";
const LOG_START_INDEX = "Program log: ".length;
export class EventParser<T> {
// Deserialized event.
export type Event = {
name: string;
data: Object;
};
export class EventParser {
private coder: Coder;
private programId: PublicKey;
private eventName: string;
private discriminator: Buffer;
// Maps base64 encoded event discriminator to event name.
private discriminators: Map<string, string>;
constructor(coder: Coder, programId: PublicKey, eventName: string) {
constructor(coder: Coder, programId: PublicKey, idl: Idl) {
this.coder = coder;
this.programId = programId;
this.eventName = eventName;
this.discriminator = eventDiscriminator(eventName);
this.discriminators = new Map<string, string>(
idl.events === undefined
? []
: idl.events.map((e) => [
base64.fromByteArray(eventDiscriminator(e.name)),
e.name,
])
);
}
// Each log given, represents an array of messages emitted by
@ -29,10 +42,9 @@ export class EventParser<T> {
// its emission, thereby allowing us to know if a given log event was
// emitted by *this* program. If it was, then we parse the raw string and
// emit the event if the string matches the event being subscribed to.
public parseLogs(logs: string[], callback: (log: T) => void) {
public parseLogs(logs: string[], callback: (log: Event) => void) {
const logScanner = new LogScanner(logs);
const execution = new ExecutionContext(logScanner.next() as string);
let log = logScanner.next();
while (log !== null) {
let [event, newProgram, didPop] = this.handleLog(execution, log);
@ -44,11 +56,57 @@ export class EventParser<T> {
}
if (didPop) {
execution.pop();
// Skip the "success" log, which always follows the consumed log.
logScanner.next();
}
log = logScanner.next();
}
}
// Main log handler. Returns a three element array of the event, the
// next program that was invoked for CPI, and a boolean indicating if
// a program has completed execution (and thus should be popped off the
// execution stack).
private handleLog(
execution: ExecutionContext,
log: string
): [Event | null, string | null, boolean] {
// Executing program is this program.
if (execution.program() === this.programId.toString()) {
return this.handleProgramLog(log);
}
// Executing program is not this program.
else {
return [null, ...this.handleSystemLog(log)];
}
}
// Handles logs from *this* program.
private handleProgramLog(
log: string
): [Event | null, string | null, boolean] {
// This is a `msg!` log.
if (log.startsWith("Program log:")) {
const logStr = log.slice(LOG_START_INDEX);
const logArr = Buffer.from(base64.toByteArray(logStr));
const disc = base64.fromByteArray(logArr.slice(0, 8));
// Only deserialize if the discriminator implies a proper event.
let event = null;
let eventName = this.discriminators.get(disc);
if (eventName !== undefined) {
event = {
name: eventName,
data: this.coder.events.decode(eventName, logArr.slice(8)),
};
}
return [event, null, false];
}
// System log.
else {
return [null, ...this.handleSystemLog(log)];
}
}
// Handles logs when the current program being executing is *not* this.
private handleSystemLog(log: string): [string | null, boolean] {
// System component.
@ -68,44 +126,6 @@ export class EventParser<T> {
return [null, false];
}
}
// Handles logs from *this* program.
private handleProgramLog(log: string): [T | null, string | null, boolean] {
// This is a `msg!` log.
if (log.startsWith("Program log:")) {
const logStr = log.slice(LOG_START_INDEX);
const logArr = Buffer.from(base64.toByteArray(logStr));
const disc = logArr.slice(0, 8);
// Only deserialize if the discriminator implies a proper event.
let event = null;
if (disc.equals(this.discriminator)) {
event = this.coder.events.decode(this.eventName, logArr.slice(8));
}
return [event, null, false];
}
// System log.
else {
return [null, ...this.handleSystemLog(log)];
}
}
// Main log handler. Returns a three element array of the event, the
// next program that was invoked for CPI, and a boolean indicating if
// a program has completed execution (and thus should be popped off the
// execution stack).
private handleLog(
execution: ExecutionContext,
log: string
): [T | null, string | null, boolean] {
// Executing program is this program.
if (execution.program() === this.programId.toString()) {
return this.handleProgramLog(log);
}
// Executing program is not this program.
else {
return [null, ...this.handleSystemLog(log)];
}
}
}
// Stack frame execution context, allowing one to track what program is

View File

@ -3,7 +3,14 @@ import { PublicKey } from "@solana/web3.js";
import Provider from "../provider";
import { Idl, idlAddress, decodeIdlAccount } from "../idl";
import Coder from "../coder";
import NamespaceFactory, { Rpcs, Ixs, Txs, Accounts, State } from "./namespace";
import NamespaceFactory, {
Rpcs,
Ixs,
Txs,
Accounts,
State,
Simulate,
} from "./namespace";
import { getProvider } from "../";
import { decodeUtf8 } from "../utils";
import { EventParser } from "./event";
@ -23,8 +30,7 @@ export class Program {
readonly idl: Idl;
/**
* Async functions to invoke instructions against a Solana priogram running
* on a cluster.
* Async functions to invoke instructions against an Anchor program.
*/
readonly rpc: Rpcs;
@ -43,6 +49,11 @@ export class Program {
*/
readonly transaction: Txs;
/**
* Async functions to simulate instructions against an Anchor program.
*/
readonly simulate: Simulate;
/**
* Coder for serializing rpc requests.
*/
@ -67,7 +78,7 @@ export class Program {
const coder = new Coder(idl);
// Build the dynamic namespaces.
const [rpcs, ixs, txs, accounts, state] = NamespaceFactory.build(
const [rpcs, ixs, txs, accounts, state, simulate] = NamespaceFactory.build(
idl,
coder,
programId,
@ -79,6 +90,7 @@ export class Program {
this.account = accounts;
this.coder = coder;
this.state = state;
this.simulate = simulate;
}
/**
@ -105,22 +117,20 @@ export class Program {
/**
* Invokes the given callback everytime the given event is emitted.
*/
public addEventListener<T>(
public addEventListener(
eventName: string,
callback: (event: T, slot: number) => void
callback: (event: any, slot: number) => void
): number {
const eventParser = new EventParser<T>(
this.coder,
this.programId,
eventName
);
const eventParser = new EventParser(this.coder, this.programId, this.idl);
return this.provider.connection.onLogs(this.programId, (logs, ctx) => {
if (logs.err) {
console.error(logs);
return;
}
eventParser.parseLogs(logs.logs, (event) => {
callback(event, ctx.slot);
if (event.name === eventName) {
callback(event.data, ctx.slot);
}
});
});
}

View File

@ -9,6 +9,7 @@ import InstructionNamespace, { Ixs } from "./instruction";
import TransactionNamespace, { Txs } from "./transaction";
import RpcNamespace, { Rpcs } from "./rpc";
import AccountNamespace, { Accounts } from "./account";
import SimulateNamespace, { Simulate } from "./simulate";
// Re-exports.
export { State } from "./state";
@ -16,6 +17,7 @@ export { Ixs } from "./instruction";
export { Txs, TxFn } from "./transaction";
export { Rpcs, RpcFn } from "./rpc";
export { Accounts, AccountFn, ProgramAccount } from "./account";
export { Simulate } from "./simulate";
export default class NamespaceFactory {
/**
@ -28,12 +30,14 @@ export default class NamespaceFactory {
coder: Coder,
programId: PublicKey,
provider: Provider
): [Rpcs, Ixs, Txs, Accounts, State] {
): [Rpcs, Ixs, Txs, Accounts, State, Simulate] {
const idlErrors = parseIdlErrors(idl);
const rpcs: Rpcs = {};
const ixFns: Ixs = {};
const txFns: Txs = {};
const simulateFns: Simulate = {};
const state = StateNamespace.build(
idl,
coder,
@ -46,18 +50,28 @@ export default class NamespaceFactory {
const ix = InstructionNamespace.build(idlIx, coder, programId);
const tx = TransactionNamespace.build(idlIx, ix);
const rpc = RpcNamespace.build(idlIx, tx, idlErrors, provider);
const simulate = SimulateNamespace.build(
idlIx,
tx,
idlErrors,
provider,
coder,
programId,
idl
);
const name = camelCase(idlIx.name);
ixFns[name] = ix;
txFns[name] = tx;
rpcs[name] = rpc;
simulateFns[name] = simulate;
});
const accountFns = idl.accounts
? AccountNamespace.build(idl, coder, programId, provider)
: {};
return [rpcs, ixFns, txFns, accountFns, state];
return [rpcs, ixFns, txFns, accountFns, state, simulateFns];
}
}

View File

@ -0,0 +1,76 @@
import { PublicKey } from "@solana/web3.js";
import Provider from "../../provider";
import { IdlInstruction } from "../../idl";
import { translateError } from "../common";
import { splitArgsAndCtx } from "../context";
import { TxFn } from "./transaction";
import { EventParser } from "../event";
import Coder from "../../coder";
import { Idl } from "../../idl";
/**
* Dynamically generated simualte namespace.
*/
export interface Simulate {
[key: string]: SimulateFn;
}
/**
* RpcFn is a single rpc method generated from an IDL.
*/
export type SimulateFn = (...args: any[]) => Promise<SimulateResponse>;
type SimulateResponse = {
events: Event[];
raw: string[];
};
export default class SimulateNamespace {
// Builds the rpc namespace.
public static build(
idlIx: IdlInstruction,
txFn: TxFn,
idlErrors: Map<number, string>,
provider: Provider,
coder: Coder,
programId: PublicKey,
idl: Idl
): SimulateFn {
const simulate = async (...args: any[]): Promise<SimulateResponse> => {
const tx = txFn(...args);
const [_, ctx] = splitArgsAndCtx(idlIx, [...args]);
let resp = undefined;
try {
resp = await provider.simulate(tx, ctx.signers, ctx.options);
} catch (err) {
console.log("Translating error", err);
let translatedErr = translateError(idlErrors, err);
if (translatedErr === null) {
throw err;
}
throw translatedErr;
}
if (resp === undefined) {
throw new Error("Unable to simulate transaction");
}
if (resp.value.err) {
throw new Error(`Simulate error: ${resp.value.err.toString()}`);
}
const logs = resp.value.logs;
if (!logs) {
throw new Error("Simulated logs not found");
}
const events = [];
if (idl.events) {
let parser = new EventParser(coder, programId, idl);
parser.parseLogs(logs, (event) => {
events.push(event);
});
}
return { events, raw: logs };
};
return simulate;
}
}

View File

@ -6,6 +6,9 @@ import {
TransactionSignature,
ConfirmOptions,
sendAndConfirmRawTransaction,
RpcResponseAndContext,
SimulatedTransactionResponse,
Commitment,
} from "@solana/web3.js";
export default class Provider {
@ -134,6 +137,35 @@ export default class Provider {
return sigs;
}
async simulate(
tx: Transaction,
signers?: Array<Account | undefined>,
opts?: ConfirmOptions
): Promise<RpcResponseAndContext<SimulatedTransactionResponse>> {
if (signers === undefined) {
signers = [];
}
if (opts === undefined) {
opts = this.opts;
}
const signerKps = signers.filter((s) => s !== undefined) as Array<Account>;
const signerPubkeys = [this.wallet.publicKey].concat(
signerKps.map((s) => s.publicKey)
);
tx.setSigners(...signerPubkeys);
tx.recentBlockhash = (
await this.connection.getRecentBlockhash(opts.preflightCommitment)
).blockhash;
await this.wallet.signTransaction(tx);
signerKps.forEach((kp) => {
tx.partialSign(kp);
});
return await simulateTransaction(this.connection, tx, opts.commitment);
}
}
export type SendTxRequest = {
@ -182,3 +214,30 @@ export class NodeWallet implements Wallet {
return this.payer.publicKey;
}
}
// Copy of Connection.simulateTransaction that takes a commitment parameter.
async function simulateTransaction(
connection: Connection,
transaction: Transaction,
commitment: Commitment
): Promise<RpcResponseAndContext<SimulatedTransactionResponse>> {
// @ts-ignore
transaction.recentBlockhash = await connection._recentBlockhash(
// @ts-ignore
connection._disableBlockhashCaching
);
const signData = transaction.serializeMessage();
// @ts-ignore
const wireTransaction = transaction._serialize(signData);
const encodedTransaction = wireTransaction.toString("base64");
const config: any = { encoding: "base64", commitment };
const args = [encodedTransaction, config];
// @ts-ignore
const res = await connection._rpcRequest("simulateTransaction", args);
if (res.error) {
throw new Error("failed to simulate transaction: " + res.error.message);
}
return res.result;
}

View File

@ -29,9 +29,7 @@ export default new Proxy({} as any, {
}
if (projectRoot === undefined) {
throw new Error(
"Could not find workspace root. Perhaps set the `OASIS_WORKSPACE` env var?"
);
throw new Error("Could not find workspace root.");
}
find

View File

@ -1,5 +1,5 @@
{
"include": ["src"],
"include": ["./src/**/*"],
"compilerOptions": {
"moduleResolution": "node",
"module": "es6",
@ -18,6 +18,7 @@
"noImplicitAny": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"composite": true,
"baseUrl": ".",
"typeRoots": ["types/", "node_modules/@types"],