feat: add simulateTransaction API
This commit is contained in:
committed by
Justin Starry
parent
0c97e39675
commit
177c9c3aec
9
web3.js/module.d.ts
vendored
9
web3.js/module.d.ts
vendored
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 =
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user