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

View File

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

View File

@ -348,6 +348,18 @@ const Version = struct({
'solana-core': 'string', '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 * Metadata for a confirmed transaction on the ledger
* *
@ -1294,6 +1306,7 @@ export class Connection {
_blockhashInfo: { _blockhashInfo: {
recentBlockhash: Blockhash | null, recentBlockhash: Blockhash | null,
lastFetch: Date, lastFetch: Date,
simulatedSignatures: Array<string>,
transactionSignatures: Array<string>, transactionSignatures: Array<string>,
}; };
_disableBlockhashCaching: boolean = false; _disableBlockhashCaching: boolean = false;
@ -1331,6 +1344,7 @@ export class Connection {
recentBlockhash: null, recentBlockhash: null,
lastFetch: new Date(0), lastFetch: new Date(0),
transactionSignatures: [], transactionSignatures: [],
simulatedSignatures: [],
}; };
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
@ -2372,6 +2386,99 @@ export class Connection {
return res.result; 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 * Sign and send a transaction
*/ */
@ -2383,57 +2490,22 @@ export class Connection {
if (transaction.nonceInfo) { if (transaction.nonceInfo) {
transaction.sign(...signers); transaction.sign(...signers);
} else { } else {
let disableCache = this._disableBlockhashCaching;
for (;;) { for (;;) {
// Attempt to use a recent blockhash for up to 30 seconds transaction.recentBlockhash = await this._recentBlockhash(disableCache);
if ( transaction.sign(...signers);
this._blockhashInfo.recentBlockhash != null && if (!transaction.signature) {
Date.now() - this._blockhashInfo.lastFetch < throw new Error('!signature'); // should never happen
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;
}
} }
// Fetch a new blockhash // If the signature of this transaction has not been seen before with the
let attempts = 0; // current recentBlockhash, all done.
const startTime = Date.now(); const signature = transaction.signature.toString('base64');
for (;;) { if (!this._blockhashInfo.transactionSignatures.includes(signature)) {
const {blockhash} = await this.getRecentBlockhash('max'); this._blockhashInfo.transactionSignatures.push(signature);
break;
if (this._blockhashInfo.recentBlockhash != blockhash) { } else {
this._blockhashInfo = { disableCache = true;
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;
} }
} }
} }

View File

@ -433,8 +433,14 @@ export class Transaction {
* Verify signatures of a complete, signed Transaction * Verify signatures of a complete, signed Transaction
*/ */
verifySignatures(): boolean { verifySignatures(): boolean {
return this._verifySignatures(this.serializeMessage());
}
/**
* @private
*/
_verifySignatures(signData: Buffer): boolean {
let verified = true; let verified = true;
const signData = this.serializeMessage();
for (const {signature, publicKey} of this.signatures) { for (const {signature, publicKey} of this.signatures) {
if ( if (
!nacl.sign.detached.verify(signData, signature, publicKey.toBuffer()) !nacl.sign.detached.verify(signData, signature, publicKey.toBuffer())
@ -452,11 +458,23 @@ export class Transaction {
*/ */
serialize(): Buffer { serialize(): Buffer {
const {signatures} = this; const {signatures} = this;
if (!signatures || signatures.length === 0 || !this.verifySignatures()) { if (!signatures || signatures.length === 0) {
throw new Error('Transaction has not been signed'); throw new Error('Transaction has not been signed');
} }
const signData = this.serializeMessage(); 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 = []; const signatureCount = [];
shortvec.encodeLength(signatureCount, signatures.length); shortvec.encodeLength(signatureCount, signatures.length);
const transactionLength = 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) { if (mockRpcEnabled) {
console.log('non-live test skipped'); console.log('non-live test skipped');
return; return;
} }
const data = await fs.readFile(
'test/fixtures/noop-rust/solana_bpf_rust_noop.so',
);
const connection = new Connection(url, 'recent'); 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(); let program: Account;
await BpfLoader.load(connection, from, program, data); let signature: string;
const transaction = new Transaction().add({ let payerAccount: Account;
keys: [{pubkey: from.publicKey, isSigner: true, isWritable: true}],
programId: program.publicKey, beforeAll(async () => {
}); const data = await fs.readFile(
await sendAndConfirmTransaction(connection, transaction, [from], { 'test/fixtures/noop-rust/solana_bpf_rust_noop.so',
skipPreflight: true, );
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) { test('get confirmed transaction', async () => {
expect(transaction.signature).not.toBeNull(); const parsedTx = await connection.getParsedConfirmedTransaction(signature);
return; 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); test('simulate transaction', async () => {
const parsedTx = await connection.getParsedConfirmedTransaction( const simulatedTransaction = new Transaction().add({
confirmedSignature, keys: [
); {pubkey: payerAccount.publicKey, isSigner: true, isWritable: true},
if (parsedTx === null) { ],
expect(parsedTx).not.toBeNull(); programId: program.publicKey,
return; });
}
const {signatures, message} = parsedTx.transaction; const {err, logs} = (
expect(signatures[0]).toEqual(confirmedSignature); await connection.simulateTransaction(simulatedTransaction, [payerAccount])
const ix = message.instructions[0]; ).value;
if (ix.parsed) { expect(err).toBeNull();
expect('parsed' in ix).toBe(false);
} else { if (logs === null) {
expect(ix.programId.equals(program.publicKey)).toBe(true); expect(logs).not.toBeNull();
expect(ix.data).toEqual(''); 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();
});
}); });