diff --git a/web3.js/module.d.ts b/web3.js/module.d.ts index 51c3ca5928..0c600331ff 100644 --- a/web3.js/module.d.ts +++ b/web3.js/module.d.ts @@ -121,6 +121,11 @@ declare module '@solana/web3.js' { version?: string; }; + export type SimulatedTransactionResponse = { + err: TransactionError | string | null; + logs: Array | null; + }; + export type ConfirmedTransactionMeta = { fee: number; preBalances: Array; @@ -404,6 +409,10 @@ declare module '@solana/web3.js' { wireTransaction: Buffer | Uint8Array | Array, options?: SendOptions, ): Promise; + simulateTransaction( + transaction: Transaction, + signers?: Array, + ): Promise>; onAccountChange( publickey: PublicKey, callback: AccountChangeCallback, diff --git a/web3.js/module.flow.js b/web3.js/module.flow.js index 59fe94f0f7..bd57b1c847 100644 --- a/web3.js/module.flow.js +++ b/web3.js/module.flow.js @@ -143,6 +143,11 @@ declare module '@solana/web3.js' { version: string | null, }; + declare export type SimulatedTransactionResponse = { + err: TransactionError | string | null, + logs: Array | null, + }; + declare export type ConfirmedTransactionMeta = { fee: number, preBalances: Array, @@ -417,6 +422,10 @@ declare module '@solana/web3.js' { wireTransaction: Buffer | Uint8Array | Array, options?: SendOptions, ): Promise; + simulateTransaction( + transaction: Transaction, + signers?: Array, + ): Promise>; onAccountChange( publickey: PublicKey, callback: AccountChangeCallback, diff --git a/web3.js/src/connection.js b/web3.js/src/connection.js index 23886968e2..0a57263a2c 100644 --- a/web3.js/src/connection.js +++ b/web3.js/src/connection.js @@ -348,6 +348,18 @@ const Version = struct({ 'solana-core': 'string', }); +type SimulatedTransactionResponse = { + err: TransactionError | string | null, + logs: Array | 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, transactionSignatures: Array, }; _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 { + 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 { + 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, + ): Promise> { + 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; } } } diff --git a/web3.js/src/transaction.js b/web3.js/src/transaction.js index 1219503f5c..882abf8f35 100644 --- a/web3.js/src/transaction.js +++ b/web3.js/src/transaction.js @@ -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 = diff --git a/web3.js/test/bpf-loader.test.js b/web3.js/test/bpf-loader.test.js index 60997b7ec2..54e023ab7d 100644 --- a/web3.js/test/bpf-loader.test.js +++ b/web3.js/test/bpf-loader.test.js @@ -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(); + }); });