Skip to content

feat: fast execute #1463

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions __tests__/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,25 @@ import {
type InvokeTransactionReceiptResponse,
Deployer,
RPC,
RpcProvider,
BlockTag,
type Call,
} from '../src';
import {
C1v2ClassHash,
contracts,
describeIfDevnet,
describeIfNotDevnet,
erc20ClassHash,
getTestProvider,
} from './config/fixtures';
import {
createTestProvider,
getTestAccount,
devnetFeeTokenAddress,
adaptAccountIfDevnet,
TEST_TX_VERSION,
STRKtokenAddress,
} from './config/fixturesInit';
import { initializeMatcher } from './config/schema';

Expand Down Expand Up @@ -385,6 +390,61 @@ describe('deploy and test Account', () => {
expect(after - before).toStrictEqual(57n);
});

describe('fastExecute()', () => {
test('Only Rpc0.9', async () => {
const provider08 = new RpcProvider({
nodeUrl: 'dummy',
blockIdentifier: BlockTag.PRE_CONFIRMED,
specVersion: '0.8.1',
});
const testAccount = new Account({
provider: provider08,
address: '0x123',
signer: '0x456',
});
const myCall: Call = { contractAddress: '0x036', entrypoint: 'withdraw', calldata: [] };
await expect(testAccount.fastExecute(myCall)).rejects.toThrow(
'Wrong Rpc version in Provider. At least Rpc v0.9 required.'
);
});

test('Only provider with PRE_CONFIRMED blockIdentifier', async () => {
const providerLatest = new RpcProvider({
nodeUrl: 'dummy',
blockIdentifier: BlockTag.LATEST,
specVersion: '0.9.0',
});
const testAccount = new Account({
provider: providerLatest,
address: '0x123',
signer: '0x456',
});
const myCall: Call = { contractAddress: '0x036', entrypoint: 'withdraw', calldata: [] };
await expect(testAccount.fastExecute(myCall)).rejects.toThrow(
'Provider needs to be initialized with `pre_confirmed` blockIdentifier option.'
);
});

test('fast consecutive txs', async () => {
const testProvider = getTestProvider(false, {
blockIdentifier: BlockTag.PRE_CONFIRMED,
});
const testAccount = getTestAccount(testProvider);
const myCall: Call = {
contractAddress: STRKtokenAddress,
entrypoint: 'transfer',
calldata: [testAccount.address, cairo.uint256(100)],
};
const tx1 = await testAccount.fastExecute(myCall);
expect(tx1.isReady).toBe(true);
expect(tx1.txResult.transaction_hash).toMatch(/^0x/);
const tx2 = await testAccount.fastExecute(myCall);
await provider.waitForTransaction(tx2.txResult.transaction_hash); // to be sure to have the right nonce in `provider`, that is set with BlockTag.LATEST (otherwise next tests will fail)
expect(tx2.isReady).toBe(true);
expect(tx2.txResult.transaction_hash).toMatch(/^0x/);
});
});

describe('EIP712 verification', () => {
// currently only in Starknet-Devnet, because can fail in Sepolia.
test('sign and verify EIP712 message fail', async () => {
Expand Down
71 changes: 71 additions & 0 deletions __tests__/rpcProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,77 @@ describeIfRpc('RPCProvider', () => {
});
});

describe('fastWaitForTransaction()', () => {
test('timeout due to low tip', async () => {
const spyProvider = jest
.spyOn(rpcProvider.channel, 'getTransactionStatus')
.mockImplementation(async () => {
return { finality_status: 'RECEIVED' };
});
const resp = await rpcProvider.fastWaitForTransaction('0x123', '0x456', 10, {
retries: 2,
retryInterval: 100,
});
spyProvider.mockRestore();
expect(resp).toBe(false);
});

test('timeout due to missing new nonce', async () => {
const spyProvider = jest
.spyOn(rpcProvider.channel, 'getTransactionStatus')
.mockImplementation(async () => {
return { finality_status: 'PRE_CONFIRMED', execution_status: 'SUCCEEDED' };
});
const spyChannel = jest
.spyOn(rpcProvider.channel, 'getNonceForAddress')
.mockImplementation(async () => {
return '0x8';
});
const resp = await rpcProvider.fastWaitForTransaction('0x123', '0x456', 8, {
retries: 2,
retryInterval: 100,
});
spyProvider.mockRestore();
spyChannel.mockRestore();
expect(resp).toBe(false);
});

test('transaction reverted', async () => {
const spyProvider = jest
.spyOn(rpcProvider.channel, 'getTransactionStatus')
.mockImplementation(async () => {
return { finality_status: 'PRE_CONFIRMED', execution_status: 'REVERTED' };
});
await expect(
rpcProvider.fastWaitForTransaction('0x123', '0x456', 10, {
retries: 2,
retryInterval: 100,
})
).rejects.toThrow('REVERTED: PRE_CONFIRMED');
spyProvider.mockRestore();
});

test('Normal behavior', async () => {
const spyProvider = jest
.spyOn(rpcProvider.channel, 'getTransactionStatus')
.mockImplementation(async () => {
return { finality_status: 'ACCEPTED_ON_L2', execution_status: 'SUCCEEDED' };
});
const spyChannel = jest
.spyOn(rpcProvider.channel, 'getNonceForAddress')
.mockImplementation(async () => {
return '0x9';
});
const resp = await rpcProvider.fastWaitForTransaction('0x123', '0x456', 8, {
retries: 2,
retryInterval: 100,
});
spyProvider.mockRestore();
spyChannel.mockRestore();
expect(resp).toBe(true);
});
});

describe('RPC methods', () => {
let latestBlock: Block;

Expand Down
55 changes: 54 additions & 1 deletion src/account/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '../global/constants';
import { logger } from '../global/logger';
import { LibraryError, Provider } from '../provider';
import { ETransactionVersion, ETransactionVersion3 } from '../provider/types/spec.type';
import { BlockTag, ETransactionVersion, ETransactionVersion3 } from '../provider/types/spec.type';
import { Signer, type SignerInterface } from '../signer';
import {
// Runtime values
Expand Down Expand Up @@ -58,6 +58,8 @@ import type {
UniversalDetails,
UserTransaction,
waitForTransactionOptions,
fastWaitForTransactionOptions,
fastExecuteResponse,
} from '../types';
import { ETransactionType } from '../types/api';
import { CallData } from '../utils/calldata';
Expand Down Expand Up @@ -88,6 +90,7 @@ import { assertPaymasterTransactionSafety } from '../utils/paymaster';
import assert from '../utils/assert';
import { defaultDeployer, Deployer } from '../deployer';
import type { TipType } from '../provider/modules/tip';
import { RPC09 } from '../channel';

export class Account extends Provider implements AccountInterface {
public signer: SignerInterface;
Expand Down Expand Up @@ -332,6 +335,56 @@ export class Account extends Provider implements AccountInterface {
);
}

/**
* Execute one or multiple calls through the account contract,
* responding as soon as a new transaction is possible with the same account.
* Useful for gaming usage.
* - This method requires the provider to be initialized with `pre_confirmed` blockIdentifier option.
* - Rpc 0.9 minimum.
* - In a normal myAccount.execute() call, followed by myProvider.waitForTransaction(), you have an immediate access to the events and to the transaction report. Here, we are processing consecutive transactions faster, but events & transaction reports are not available immediately.
* - As a consequence of the previous point, do not use contract/account deployment with this method.
* @param {AllowArray<Call>} transactions - Single call or array of calls to execute
* @param {UniversalDetails} [transactionsDetail] - Transaction execution options
* @param {fastWaitForTransactionOptions} [waitDetail={retries: 50, retryInterval: 500}] - options to scan the network for the next possible transaction. `retries` is the number of times to retry, `retryInterval` is the time in ms between retries.
* @returns {Promise<fastExecuteResponse>} Response containing the transaction result and status for the next transaction. If `isReady` is true, you can execute the next transaction. If false, timeout has been reached before the next transaction was possible.
* @example
* ```typescript
* const myProvider = new RpcProvider({ nodeUrl: url, blockIdentifier: BlockTag.PRE_CONFIRMED });
* const myAccount = new Account({ provider: myProvider, address: accountAddress0, signer: privateKey0 });
* const resp = await myAccount.fastExecute(
* call, { tip: recommendedTip},
* { retries: 30, retryInterval: 500 });
* // if resp.isReady is true, you can launch immediately a new tx.
* ```
*/
public async fastExecute(
transactions: AllowArray<Call>,
transactionsDetail: UniversalDetails = {},
waitDetail: fastWaitForTransactionOptions = {}
): Promise<fastExecuteResponse> {
assert(
this.channel instanceof RPC09.RpcChannel,
'Wrong Rpc version in Provider. At least Rpc v0.9 required.'
);
assert(
this.channel.blockIdentifier === BlockTag.PRE_CONFIRMED,
'Provider needs to be initialized with `pre_confirmed` blockIdentifier option.'
);
const initNonce = BigInt(
transactionsDetail.nonce ??
(await this.getNonceForAddress(this.address, BlockTag.PRE_CONFIRMED))
);
const details = { ...transactionsDetail, nonce: initNonce };
const resultTx: InvokeFunctionResponse = await this.execute(transactions, details);
const resultWait = await this.fastWaitForTransaction(
resultTx.transaction_hash,
this.address,
initNonce,
waitDetail
);
return { txResult: resultTx, isReady: resultWait } as fastExecuteResponse;
}

/**
* First check if contract is already declared, if not declare it
* If contract already declared returned transaction_hash is ''.
Expand Down
6 changes: 6 additions & 0 deletions src/account/types/index.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import type {
DeclareTransactionReceiptResponse,
EstimateFeeResponseOverhead,
InvokeFunctionResponse,
ProviderOptions,
} from '../../provider/types/index.type';
import type { ResourceBoundsBN } from '../../provider/types/spec.type';
Expand Down Expand Up @@ -110,3 +111,8 @@ export type StarkProfile = {
github?: string;
proofOfPersonhood?: boolean;
};

export type fastExecuteResponse = {
txResult: InvokeFunctionResponse;
isReady: boolean;
};
55 changes: 55 additions & 0 deletions src/channel/rpc_0_9_0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
RPC_ERROR,
RpcProviderOptions,
waitForTransactionOptions,
type fastWaitForTransactionOptions,
} from '../types';
import assert from '../utils/assert';
import { ETransactionType, JRPC, RPCSPEC09 as RPC } from '../types/api';
Expand Down Expand Up @@ -477,6 +478,60 @@ export class RpcChannel {
return txReceipt as RPC.TXN_RECEIPT;
}

public async fastWaitForTransaction(
txHash: BigNumberish,
address: string,
initNonceBN: BigNumberish,
options?: fastWaitForTransactionOptions
): Promise<boolean> {
const initNonce = BigInt(initNonceBN);
let retries = options?.retries ?? 50;
const retryInterval = options?.retryInterval ?? 500; // 0.5s
const errorStates: string[] = [RPC.ETransactionExecutionStatus.REVERTED];
const successStates: string[] = [
RPC.ETransactionFinalityStatus.ACCEPTED_ON_L2,
RPC.ETransactionFinalityStatus.ACCEPTED_ON_L1,
RPC.ETransactionFinalityStatus.PRE_CONFIRMED,
];
let txStatus: RPC.TransactionStatus;
const start = new Date().getTime();
while (retries > 0) {
// eslint-disable-next-line no-await-in-loop
await wait(retryInterval);

// eslint-disable-next-line no-await-in-loop
txStatus = await this.getTransactionStatus(txHash);
logger.info(
`${retries} ${JSON.stringify(txStatus)} ${(new Date().getTime() - start) / 1000}s.`
);
const executionStatus = txStatus.execution_status ?? '';
const finalityStatus = txStatus.finality_status;
if (errorStates.includes(executionStatus)) {
const message = `${executionStatus}: ${finalityStatus}`;
const error = new Error(message) as Error & { response: RPC.TransactionStatus };
error.response = txStatus;
throw error;
} else if (successStates.includes(finalityStatus)) {
let currentNonce = initNonce;
while (currentNonce === initNonce && retries > 0) {
// eslint-disable-next-line no-await-in-loop
currentNonce = BigInt(await this.getNonceForAddress(address, BlockTag.PRE_CONFIRMED));
logger.info(
`${retries} Checking new nonce ${currentNonce} ${(new Date().getTime() - start) / 1000}s.`
);
if (currentNonce !== initNonce) return true;
// eslint-disable-next-line no-await-in-loop
await wait(retryInterval);
retries -= 1;
}
return false;
}

retries -= 1;
}
return false;
}

public getStorageAt(
contractAddress: BigNumberish,
key: BigNumberish,
Expand Down
32 changes: 32 additions & 0 deletions src/provider/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ContractVersion,
DeclareContractTransaction,
DeployAccountContractTransaction,
type fastWaitForTransactionOptions,
type GasPrices,
GetBlockResponse,
getContractVersionOptions,
Expand Down Expand Up @@ -323,6 +324,37 @@ export class RpcProvider implements ProviderInterface {
return new ReceiptTx(receiptWoHelper) as GetTransactionReceiptResponse;
}

/**
* Wait up until a new transaction is possible with same the account.
* This method is fast, but Events and transaction report are not yet
* available. Useful for gaming activity.
* - only rpc 0.9 and onwards.
* @param {BigNumberish} txHash - transaction hash
* @param {string} address - address of the account
* @param {BigNumberish} initNonce - initial nonce of the account (before the transaction).
* @param {fastWaitForTransactionOptions} [options={retries: 50, retryInterval: 500}] - options to scan the network for the next possible transaction. `retries` is the number of times to retry.
* @returns {Promise<boolean>} Returns true if the next transaction is possible,
* false if the timeout has been reached,
* throw an error in case of provider communication.
*/
public async fastWaitForTransaction(
txHash: BigNumberish,
address: string,
initNonce: BigNumberish,
options?: fastWaitForTransactionOptions
): Promise<boolean> {
if (this.channel instanceof RPC09.RpcChannel) {
const isSuccess = await this.channel.fastWaitForTransaction(
txHash,
address,
initNonce,
options
);
return isSuccess;
}
throw new Error('Unsupported channel type');
}

public async getStorageAt(
contractAddress: BigNumberish,
key: BigNumberish,
Expand Down
5 changes: 5 additions & 0 deletions src/types/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ export type waitForTransactionOptions = {
errorStates?: Array<TransactionFinalityStatus | TransactionExecutionStatus>;
};

export type fastWaitForTransactionOptions = {
retries?: number;
retryInterval?: number;
};

export type getSimulateTransactionOptions = {
blockIdentifier?: BlockIdentifier;
skipValidate?: boolean;
Expand Down
Loading