feat: add simulateTransaction API

This commit is contained in:
Justin Starry 2020-08-11 14:35:56 +08:00 committed by Justin Starry
parent 0c97e39675
commit 177c9c3aec
5 changed files with 300 additions and 92 deletions

9
web3.js/module.d.ts vendored
View File

@ -121,6 +121,11 @@ declare module '@solana/web3.js' {
version?: string;
};
export type SimulatedTransactionResponse = {
err: TransactionError | string | null;
logs: Array<string> | null;
};
export type ConfirmedTransactionMeta = {
fee: number;
preBalances: Array<number>;
@ -404,6 +409,10 @@ declare module '@solana/web3.js' {
wireTransaction: Buffer | Uint8Array | Array<number>,
options?: SendOptions,
): Promise<TransactionSignature>;
simulateTransaction(
transaction: Transaction,
signers?: Array<Account>,
): Promise<RpcResponseAndContext<SimulatedTransactionResponse>>;
onAccountChange(
publickey: PublicKey,
callback: AccountChangeCallback,

View File

@ -143,6 +143,11 @@ declare module '@solana/web3.js' {
version: string | null,
};
declare export type SimulatedTransactionResponse = {
err: TransactionError | string | null,
logs: Array<string> | null,
};
declare export type ConfirmedTransactionMeta = {
fee: number,
preBalances: Array<number>,
@ -417,6 +422,10 @@ declare module '@solana/web3.js' {
wireTransaction: Buffer | Uint8Array | Array<number>,
options?: SendOptions,
): Promise<TransactionSignature>;
simulateTransaction(
transaction: Transaction,
signers?: Array<Account>,
): Promise<RpcResponseAndContext<SimulatedTransactionResponse>>;
onAccountChange(
publickey: PublicKey,
callback: AccountChangeCallback,

View File

@ -348,6 +348,18 @@ const Version = struct({
'solana-core': 'string',
});
type SimulatedTransactionResponse = {
err: TransactionError | string | null,
logs: Array<string> | null,
};
const SimulatedTransactionResponseValidator = jsonRpcResultAndContext(
struct.pick({
err: struct.union(['null', 'object', 'string']),
logs: struct.union(['null', struct.array(['string'])]),
}),
);
/**
* Metadata for a confirmed transaction on the ledger
*
@ -1294,6 +1306,7 @@ export class Connection {
_blockhashInfo: {
recentBlockhash: Blockhash | null,
lastFetch: Date,
simulatedSignatures: Array<string>,
transactionSignatures: Array<string>,
};
_disableBlockhashCaching: boolean = false;
@ -1331,6 +1344,7 @@ export class Connection {
recentBlockhash: null,
lastFetch: new Date(0),
transactionSignatures: [],
simulatedSignatures: [],
};
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
@ -2372,6 +2386,99 @@ export class Connection {
return res.result;
}
async _recentBlockhash(disableCache: boolean): Promise<Blockhash> {
if (!disableCache) {
// Attempt to use a recent blockhash for up to 30 seconds
const expired =
Date.now() - this._blockhashInfo.lastFetch >=
BLOCKHASH_CACHE_TIMEOUT_MS;
if (this._blockhashInfo.recentBlockhash !== null && !expired) {
return this._blockhashInfo.recentBlockhash;
}
}
return await this._pollNewBlockhash();
}
async _pollNewBlockhash(): Promise<Blockhash> {
const startTime = Date.now();
for (let i = 0; i < 50; i++) {
const {blockhash} = await this.getRecentBlockhash('max');
if (this._blockhashInfo.recentBlockhash != blockhash) {
this._blockhashInfo = {
recentBlockhash: blockhash,
lastFetch: new Date(),
transactionSignatures: [],
simulatedSignatures: [],
};
return blockhash;
}
// Sleep for approximately half a slot
await sleep(MS_PER_SLOT / 2);
}
throw new Error(
`Unable to obtain a new blockhash after ${Date.now() - startTime}ms`,
);
}
/**
* Simulate a transaction
*/
async simulateTransaction(
transaction: Transaction,
signers?: Array<Account>,
): Promise<RpcResponseAndContext<SimulatedTransactionResponse>> {
if (transaction.nonceInfo && signers) {
transaction.sign(...signers);
} else {
let disableCache = this._disableBlockhashCaching;
for (;;) {
transaction.recentBlockhash = await this._recentBlockhash(disableCache);
if (!signers) break;
transaction.sign(...signers);
if (!transaction.signature) {
throw new Error('!signature'); // should never happen
}
// If the signature of this transaction has not been seen before with the
// current recentBlockhash, all done.
const signature = transaction.signature.toString('base64');
if (
!this._blockhashInfo.simulatedSignatures.includes(signature) &&
!this._blockhashInfo.transactionSignatures.includes(signature)
) {
this._blockhashInfo.simulatedSignatures.push(signature);
break;
} else {
disableCache = true;
}
}
}
const signData = transaction.serializeMessage();
const wireTransaction = transaction._serialize(signData);
const encodedTransaction = bs58.encode(wireTransaction);
const args = [encodedTransaction];
if (signers) {
args.push({sigVerify: true});
}
const unsafeRes = await this._rpcRequest('simulateTransaction', args);
const res = SimulatedTransactionResponseValidator(unsafeRes);
if (res.error) {
throw new Error('failed to simulate transaction: ' + res.error.message);
}
assert(typeof res.result !== 'undefined');
assert(res.result);
return res.result;
}
/**
* Sign and send a transaction
*/
@ -2383,57 +2490,22 @@ export class Connection {
if (transaction.nonceInfo) {
transaction.sign(...signers);
} else {
let disableCache = this._disableBlockhashCaching;
for (;;) {
// Attempt to use a recent blockhash for up to 30 seconds
if (
this._blockhashInfo.recentBlockhash != null &&
Date.now() - this._blockhashInfo.lastFetch <
BLOCKHASH_CACHE_TIMEOUT_MS
) {
transaction.recentBlockhash = this._blockhashInfo.recentBlockhash;
transaction.sign(...signers);
if (!transaction.signature) {
throw new Error('!signature'); // should never happen
}
// If the signature of this transaction has not been seen before with the
// current recentBlockhash, all done.
const signature = transaction.signature.toString();
if (!this._blockhashInfo.transactionSignatures.includes(signature)) {
this._blockhashInfo.transactionSignatures.push(signature);
if (this._disableBlockhashCaching) {
this._blockhashInfo.lastFetch = new Date(0);
}
break;
}
transaction.recentBlockhash = await this._recentBlockhash(disableCache);
transaction.sign(...signers);
if (!transaction.signature) {
throw new Error('!signature'); // should never happen
}
// Fetch a new blockhash
let attempts = 0;
const startTime = Date.now();
for (;;) {
const {blockhash} = await this.getRecentBlockhash('max');
if (this._blockhashInfo.recentBlockhash != blockhash) {
this._blockhashInfo = {
recentBlockhash: blockhash,
lastFetch: new Date(),
transactionSignatures: [],
};
break;
}
if (attempts === 50) {
throw new Error(
`Unable to obtain a new blockhash after ${
Date.now() - startTime
}ms`,
);
}
// Sleep for approximately half a slot
await sleep(MS_PER_SLOT / 2);
++attempts;
// If the signature of this transaction has not been seen before with the
// current recentBlockhash, all done.
const signature = transaction.signature.toString('base64');
if (!this._blockhashInfo.transactionSignatures.includes(signature)) {
this._blockhashInfo.transactionSignatures.push(signature);
break;
} else {
disableCache = true;
}
}
}

View File

@ -433,8 +433,14 @@ export class Transaction {
* Verify signatures of a complete, signed Transaction
*/
verifySignatures(): boolean {
return this._verifySignatures(this.serializeMessage());
}
/**
* @private
*/
_verifySignatures(signData: Buffer): boolean {
let verified = true;
const signData = this.serializeMessage();
for (const {signature, publicKey} of this.signatures) {
if (
!nacl.sign.detached.verify(signData, signature, publicKey.toBuffer())
@ -452,11 +458,23 @@ export class Transaction {
*/
serialize(): Buffer {
const {signatures} = this;
if (!signatures || signatures.length === 0 || !this.verifySignatures()) {
if (!signatures || signatures.length === 0) {
throw new Error('Transaction has not been signed');
}
const signData = this.serializeMessage();
if (!this._verifySignatures(signData)) {
throw new Error('Transaction has not been signed correctly');
}
return this._serialize(signData);
}
/**
* @private
*/
_serialize(signData: Buffer): Buffer {
const {signatures} = this;
const signatureCount = [];
shortvec.encodeLength(signatureCount, signatures.length);
const transactionLength =

View File

@ -51,56 +51,156 @@ test('load BPF C program', async () => {
});
});
test('load BPF Rust program', async () => {
describe('load BPF Rust program', () => {
if (mockRpcEnabled) {
console.log('non-live test skipped');
return;
}
const data = await fs.readFile(
'test/fixtures/noop-rust/solana_bpf_rust_noop.so',
);
const connection = new Connection(url, 'recent');
const {feeCalculator} = await connection.getRecentBlockhash();
const fees =
feeCalculator.lamportsPerSignature *
(BpfLoader.getMinNumSignatures(data.length) + NUM_RETRIES);
const balanceNeeded = await connection.getMinimumBalanceForRentExemption(
data.length,
);
const from = await newAccountWithLamports(connection, fees + balanceNeeded);
const program = new Account();
await BpfLoader.load(connection, from, program, data);
const transaction = new Transaction().add({
keys: [{pubkey: from.publicKey, isSigner: true, isWritable: true}],
programId: program.publicKey,
});
await sendAndConfirmTransaction(connection, transaction, [from], {
skipPreflight: true,
let program: Account;
let signature: string;
let payerAccount: Account;
beforeAll(async () => {
const data = await fs.readFile(
'test/fixtures/noop-rust/solana_bpf_rust_noop.so',
);
const {feeCalculator} = await connection.getRecentBlockhash();
const fees =
feeCalculator.lamportsPerSignature *
(BpfLoader.getMinNumSignatures(data.length) + NUM_RETRIES);
const balanceNeeded = await connection.getMinimumBalanceForRentExemption(
data.length,
);
payerAccount = await newAccountWithLamports(
connection,
fees + balanceNeeded,
);
program = new Account();
await BpfLoader.load(connection, payerAccount, program, data);
const transaction = new Transaction().add({
keys: [
{pubkey: payerAccount.publicKey, isSigner: true, isWritable: true},
],
programId: program.publicKey,
});
await sendAndConfirmTransaction(connection, transaction, [payerAccount], {
skipPreflight: true,
});
if (transaction.signature === null) {
expect(transaction.signature).not.toBeNull();
return;
}
signature = bs58.encode(transaction.signature);
});
if (transaction.signature === null) {
expect(transaction.signature).not.toBeNull();
return;
}
test('get confirmed transaction', async () => {
const parsedTx = await connection.getParsedConfirmedTransaction(signature);
if (parsedTx === null) {
expect(parsedTx).not.toBeNull();
return;
}
const {signatures, message} = parsedTx.transaction;
expect(signatures[0]).toEqual(signature);
const ix = message.instructions[0];
if (ix.parsed) {
expect('parsed' in ix).toBe(false);
} else {
expect(ix.programId.equals(program.publicKey)).toBe(true);
expect(ix.data).toEqual('');
}
});
const confirmedSignature = bs58.encode(transaction.signature);
const parsedTx = await connection.getParsedConfirmedTransaction(
confirmedSignature,
);
if (parsedTx === null) {
expect(parsedTx).not.toBeNull();
return;
}
const {signatures, message} = parsedTx.transaction;
expect(signatures[0]).toEqual(confirmedSignature);
const ix = message.instructions[0];
if (ix.parsed) {
expect('parsed' in ix).toBe(false);
} else {
expect(ix.programId.equals(program.publicKey)).toBe(true);
expect(ix.data).toEqual('');
}
test('simulate transaction', async () => {
const simulatedTransaction = new Transaction().add({
keys: [
{pubkey: payerAccount.publicKey, isSigner: true, isWritable: true},
],
programId: program.publicKey,
});
const {err, logs} = (
await connection.simulateTransaction(simulatedTransaction, [payerAccount])
).value;
expect(err).toBeNull();
if (logs === null) {
expect(logs).not.toBeNull();
return;
}
expect(logs.length).toBeGreaterThanOrEqual(2);
expect(logs[0]).toEqual(`Call BPF program ${program.publicKey.toBase58()}`);
expect(logs[logs.length - 1]).toEqual(
`BPF program ${program.publicKey.toBase58()} success`,
);
});
test('simulate transaction without signature verification', async () => {
const simulatedTransaction = new Transaction().add({
keys: [
{pubkey: payerAccount.publicKey, isSigner: true, isWritable: true},
],
programId: program.publicKey,
});
const {err, logs} = (
await connection.simulateTransaction(simulatedTransaction)
).value;
expect(err).toBeNull();
if (logs === null) {
expect(logs).not.toBeNull();
return;
}
expect(logs.length).toBeGreaterThanOrEqual(2);
expect(logs[0]).toEqual(`Call BPF program ${program.publicKey.toBase58()}`);
expect(logs[logs.length - 1]).toEqual(
`BPF program ${program.publicKey.toBase58()} success`,
);
});
test('simulate transaction with bad programId', async () => {
const simulatedTransaction = new Transaction().add({
keys: [
{pubkey: payerAccount.publicKey, isSigner: true, isWritable: true},
],
programId: new Account().publicKey,
});
const {err, logs} = (
await connection.simulateTransaction(simulatedTransaction)
).value;
expect(err).toEqual('ProgramAccountNotFound');
if (logs === null) {
expect(logs).not.toBeNull();
return;
}
expect(logs.length).toEqual(0);
});
test('simulate transaction with bad signer', async () => {
const simulatedTransaction = new Transaction().add({
keys: [
{pubkey: payerAccount.publicKey, isSigner: true, isWritable: true},
],
programId: program.publicKey,
});
const {err, logs} = (
await connection.simulateTransaction(simulatedTransaction, [program])
).value;
expect(err).toEqual('SignatureFailure');
expect(logs).toBeNull();
});
});