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 ## 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)). * cli: Add yarn flag to test command ([#267](https://github.com/project-serum/anchor/pull/267)).
## [0.5.0] - 2021-05-07 ## [0.5.0] - 2021-05-07

View File

@ -57,6 +57,13 @@ pub mod misc {
ctx.accounts.my_account.data = data; ctx.accounts.my_account.data = data;
Ok(()) 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)] #[derive(Accounts)]
@ -120,6 +127,9 @@ pub struct TestU16<'info> {
rent: Sysvar<'info, Rent>, rent: Sysvar<'info, Rent>,
} }
#[derive(Accounts)]
pub struct TestSimulate {}
#[associated] #[associated]
pub struct TestData { pub struct TestData {
data: u64, data: u64,
@ -135,3 +145,18 @@ pub struct Data {
pub struct DataU16 { pub struct DataU16 {
data: u16, 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); 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 base64 from "base64-js";
import * as assert from "assert"; import * as assert from "assert";
import Coder, { eventDiscriminator } from "../coder"; import Coder, { eventDiscriminator } from "../coder";
import { Idl } from "../idl";
const LOG_START_INDEX = "Program log: ".length; 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 coder: Coder;
private programId: PublicKey; private programId: PublicKey;
private eventName: string; // Maps base64 encoded event discriminator to event name.
private discriminator: Buffer; private discriminators: Map<string, string>;
constructor(coder: Coder, programId: PublicKey, eventName: string) { constructor(coder: Coder, programId: PublicKey, idl: Idl) {
this.coder = coder; this.coder = coder;
this.programId = programId; this.programId = programId;
this.eventName = eventName; this.discriminators = new Map<string, string>(
this.discriminator = eventDiscriminator(eventName); idl.events === undefined
? []
: idl.events.map((e) => [
base64.fromByteArray(eventDiscriminator(e.name)),
e.name,
])
);
} }
// Each log given, represents an array of messages emitted by // 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 // 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 // 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. // 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 logScanner = new LogScanner(logs);
const execution = new ExecutionContext(logScanner.next() as string); const execution = new ExecutionContext(logScanner.next() as string);
let log = logScanner.next(); let log = logScanner.next();
while (log !== null) { while (log !== null) {
let [event, newProgram, didPop] = this.handleLog(execution, log); let [event, newProgram, didPop] = this.handleLog(execution, log);
@ -44,11 +56,57 @@ export class EventParser<T> {
} }
if (didPop) { if (didPop) {
execution.pop(); execution.pop();
// Skip the "success" log, which always follows the consumed log.
logScanner.next();
} }
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. // Handles logs when the current program being executing is *not* this.
private handleSystemLog(log: string): [string | null, boolean] { private handleSystemLog(log: string): [string | null, boolean] {
// System component. // System component.
@ -68,44 +126,6 @@ export class EventParser<T> {
return [null, false]; 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 // 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 Provider from "../provider";
import { Idl, idlAddress, decodeIdlAccount } from "../idl"; import { Idl, idlAddress, decodeIdlAccount } from "../idl";
import Coder from "../coder"; 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 { getProvider } from "../";
import { decodeUtf8 } from "../utils"; import { decodeUtf8 } from "../utils";
import { EventParser } from "./event"; import { EventParser } from "./event";
@ -23,8 +30,7 @@ export class Program {
readonly idl: Idl; readonly idl: Idl;
/** /**
* Async functions to invoke instructions against a Solana priogram running * Async functions to invoke instructions against an Anchor program.
* on a cluster.
*/ */
readonly rpc: Rpcs; readonly rpc: Rpcs;
@ -43,6 +49,11 @@ export class Program {
*/ */
readonly transaction: Txs; readonly transaction: Txs;
/**
* Async functions to simulate instructions against an Anchor program.
*/
readonly simulate: Simulate;
/** /**
* Coder for serializing rpc requests. * Coder for serializing rpc requests.
*/ */
@ -67,7 +78,7 @@ export class Program {
const coder = new Coder(idl); const coder = new Coder(idl);
// Build the dynamic namespaces. // Build the dynamic namespaces.
const [rpcs, ixs, txs, accounts, state] = NamespaceFactory.build( const [rpcs, ixs, txs, accounts, state, simulate] = NamespaceFactory.build(
idl, idl,
coder, coder,
programId, programId,
@ -79,6 +90,7 @@ export class Program {
this.account = accounts; this.account = accounts;
this.coder = coder; this.coder = coder;
this.state = state; this.state = state;
this.simulate = simulate;
} }
/** /**
@ -105,22 +117,20 @@ export class Program {
/** /**
* Invokes the given callback everytime the given event is emitted. * Invokes the given callback everytime the given event is emitted.
*/ */
public addEventListener<T>( public addEventListener(
eventName: string, eventName: string,
callback: (event: T, slot: number) => void callback: (event: any, slot: number) => void
): number { ): number {
const eventParser = new EventParser<T>( const eventParser = new EventParser(this.coder, this.programId, this.idl);
this.coder,
this.programId,
eventName
);
return this.provider.connection.onLogs(this.programId, (logs, ctx) => { return this.provider.connection.onLogs(this.programId, (logs, ctx) => {
if (logs.err) { if (logs.err) {
console.error(logs); console.error(logs);
return; return;
} }
eventParser.parseLogs(logs.logs, (event) => { 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 TransactionNamespace, { Txs } from "./transaction";
import RpcNamespace, { Rpcs } from "./rpc"; import RpcNamespace, { Rpcs } from "./rpc";
import AccountNamespace, { Accounts } from "./account"; import AccountNamespace, { Accounts } from "./account";
import SimulateNamespace, { Simulate } from "./simulate";
// Re-exports. // Re-exports.
export { State } from "./state"; export { State } from "./state";
@ -16,6 +17,7 @@ export { Ixs } from "./instruction";
export { Txs, TxFn } from "./transaction"; export { Txs, TxFn } from "./transaction";
export { Rpcs, RpcFn } from "./rpc"; export { Rpcs, RpcFn } from "./rpc";
export { Accounts, AccountFn, ProgramAccount } from "./account"; export { Accounts, AccountFn, ProgramAccount } from "./account";
export { Simulate } from "./simulate";
export default class NamespaceFactory { export default class NamespaceFactory {
/** /**
@ -28,12 +30,14 @@ export default class NamespaceFactory {
coder: Coder, coder: Coder,
programId: PublicKey, programId: PublicKey,
provider: Provider provider: Provider
): [Rpcs, Ixs, Txs, Accounts, State] { ): [Rpcs, Ixs, Txs, Accounts, State, Simulate] {
const idlErrors = parseIdlErrors(idl); const idlErrors = parseIdlErrors(idl);
const rpcs: Rpcs = {}; const rpcs: Rpcs = {};
const ixFns: Ixs = {}; const ixFns: Ixs = {};
const txFns: Txs = {}; const txFns: Txs = {};
const simulateFns: Simulate = {};
const state = StateNamespace.build( const state = StateNamespace.build(
idl, idl,
coder, coder,
@ -46,18 +50,28 @@ export default class NamespaceFactory {
const ix = InstructionNamespace.build(idlIx, coder, programId); const ix = InstructionNamespace.build(idlIx, coder, programId);
const tx = TransactionNamespace.build(idlIx, ix); const tx = TransactionNamespace.build(idlIx, ix);
const rpc = RpcNamespace.build(idlIx, tx, idlErrors, provider); const rpc = RpcNamespace.build(idlIx, tx, idlErrors, provider);
const simulate = SimulateNamespace.build(
idlIx,
tx,
idlErrors,
provider,
coder,
programId,
idl
);
const name = camelCase(idlIx.name); const name = camelCase(idlIx.name);
ixFns[name] = ix; ixFns[name] = ix;
txFns[name] = tx; txFns[name] = tx;
rpcs[name] = rpc; rpcs[name] = rpc;
simulateFns[name] = simulate;
}); });
const accountFns = idl.accounts const accountFns = idl.accounts
? AccountNamespace.build(idl, coder, programId, provider) ? 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, TransactionSignature,
ConfirmOptions, ConfirmOptions,
sendAndConfirmRawTransaction, sendAndConfirmRawTransaction,
RpcResponseAndContext,
SimulatedTransactionResponse,
Commitment,
} from "@solana/web3.js"; } from "@solana/web3.js";
export default class Provider { export default class Provider {
@ -134,6 +137,35 @@ export default class Provider {
return sigs; 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 = { export type SendTxRequest = {
@ -182,3 +214,30 @@ export class NodeWallet implements Wallet {
return this.payer.publicKey; 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) { if (projectRoot === undefined) {
throw new Error( throw new Error("Could not find workspace root.");
"Could not find workspace root. Perhaps set the `OASIS_WORKSPACE` env var?"
);
} }
find find

View File

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