Selaa lähdekoodia

ts: Add program simulate namespace (#266)

Armani Ferrante 4 vuotta sitten
vanhempi
sitoutus
9b446dbae1

+ 1 - 0
CHANGELOG.md

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

+ 25 - 0
examples/misc/programs/misc/src/lib.rs

@@ -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,
+}

+ 19 - 0
examples/misc/tests/misc.js

@@ -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);
+  });
 });

+ 62 - 42
ts/src/program/event.ts

@@ -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,42 +56,48 @@ export class EventParser<T> {
       }
       if (didPop) {
         execution.pop();
+        // Skip the "success" log, which always follows the consumed log.
+        logScanner.next();
       }
       log = logScanner.next();
     }
   }
 
-  // Handles logs when the current program being executing is *not* this.
-  private handleSystemLog(log: string): [string | null, boolean] {
-    // System component.
-    const logStart = log.split(":")[0];
-    // Recursive call.
-    if (logStart.startsWith(`Program ${this.programId.toString()} invoke`)) {
-      return [this.programId.toString(), false];
+  // 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);
     }
-    // Cpi call.
-    else if (logStart.includes("invoke")) {
-      return ["cpi", false]; // Any string will do.
-    } else {
-      // Did the program finish executing?
-      if (logStart.match(/^Program (.*) consumed .*$/g) !== null) {
-        return [null, true];
-      }
-      return [null, false];
+    // Executing program is not this program.
+    else {
+      return [null, ...this.handleSystemLog(log)];
     }
   }
 
   // Handles logs from *this* program.
-  private handleProgramLog(log: string): [T | null, string | null, boolean] {
+  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 = logArr.slice(0, 8);
+      const disc = base64.fromByteArray(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));
+      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];
     }
@@ -89,21 +107,23 @@ export class EventParser<T> {
     }
   }
 
-  // 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);
+  // Handles logs when the current program being executing is *not* this.
+  private handleSystemLog(log: string): [string | null, boolean] {
+    // System component.
+    const logStart = log.split(":")[0];
+    // Recursive call.
+    if (logStart.startsWith(`Program ${this.programId.toString()} invoke`)) {
+      return [this.programId.toString(), false];
     }
-    // Executing program is not this program.
-    else {
-      return [null, ...this.handleSystemLog(log)];
+    // Cpi call.
+    else if (logStart.includes("invoke")) {
+      return ["cpi", false]; // Any string will do.
+    } else {
+      // Did the program finish executing?
+      if (logStart.match(/^Program (.*) consumed .*$/g) !== null) {
+        return [null, true];
+      }
+      return [null, false];
     }
   }
 }

+ 22 - 12
ts/src/program/index.ts

@@ -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);
+        }
       });
     });
   }

+ 16 - 2
ts/src/program/namespace/index.ts

@@ -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];
   }
 }

+ 76 - 0
ts/src/program/namespace/simulate.ts

@@ -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;
+  }
+}

+ 59 - 0
ts/src/provider.ts

@@ -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;
+}

+ 1 - 3
ts/src/workspace.ts

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

+ 2 - 1
ts/tsconfig.json

@@ -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"],