Repository: 0xSpaceShard/starknet-devnet (language: python)

Issue:
Allow specifying how blocks are constructed
Block construction might add a lot to total execution time of calls and invokes. Yet, it might be that blocks aren't needed by developers that often. Therefore, an option like --no-block  or --no-blocks could be added, which would do everything that Devnet does, but without block generation. Or more generally, something like --block <SIZE> could be provided to indicate how often blocks are generated, i.e. after how many transactions.
Mempool blocks generation
## Usage related changes

- Granular block generation added via `--blocks-on-demand` mode and `create_block_on_demand` endpoint. The genesis block will be added normally.

## Development related changes

- `generate` method in `DevnetBlocks` class was changed to accept a list of transactions

## Checklist:

- [x] Applied formatting - `./scripts/format.sh`
- [x] No linter errors - `./scripts/lint.sh`
- [x] Performed code self-review
- [x] Rebased to the last commit of the target branch (or merged it into my branch)
- [x] Documented the changes
- [x] Linked the issues which this PR resolves
- [x] Updated the tests
- [x] All tests are passing



Discussion / hints:
Highly supportive of that change. In my case it would prevent me from fetching the current block timestamp multiple times in one test. Also I'm running tests that require the block not to change to work, otherwise an `update` function has to be called. This option would totally solve it so **+1**
Another option would be to generate block on request.


Fix patch:
diff --git a/page/docs/guide/blocks.md b/page/docs/guide/blocks.md
index 6e7fe07c2..16edcc578 100644
--- a/page/docs/guide/blocks.md
+++ b/page/docs/guide/blocks.md
@@ -6,7 +6,7 @@ sidebar_position: 8
 
 Devnet starts with a genesis block (with block number equal to `0` and block hash equal to `"0x0"`).
 
-A new block is generated with each new transaction. There is no pending block.
+A new block is generated with each new transaction unless you opt for [creating blocks on demand](#create-a-block-on-demand).
 
 ### Create an empty block
 
@@ -33,3 +33,22 @@ Response:
     "status": "ACCEPTED_ON_L2"
 }
 ```
+
+### Create a block on demand
+
+If you start Devnet with `--blocks-on-demand` CLI option, all transactions will be pending and stored in a pending block (targetable via block ID `"pending"`).
+To create a block on demand, send a `POST` request to `/create_block_on_demand`. This will convert the pending block to the latest block (targetable via block ID `"latest"`), giving it a block hash and a block number. All subsequent transactions will be stored to a new pending block.
+
+In case of demanding block creation with no pending transactions, a new empty block will be generated.
+
+The creation of the genesis block is not affected by this feature.
+
+```
+POST /create_block_on_demand
+```
+
+Response:
+
+```
+{'block_hash': '0x115e1b390cafa7942b6ab141ab85040defe7dee9bef3bc31d8b5b3d01cc9c67'}
+```
diff --git a/page/docs/guide/run.md b/page/docs/guide/run.md
index 5bcc05261..2ffa18bd2 100644
--- a/page/docs/guide/run.md
+++ b/page/docs/guide/run.md
@@ -10,7 +10,7 @@ Installing the package adds the `starknet-devnet` command.
 usage: starknet-devnet [-h] [-v] [--host HOST] [--port PORT] [--load-path LOAD_PATH] [--dump-path DUMP_PATH] [--dump-on DUMP_ON] [--lite-mode] [--accounts ACCOUNTS]
                        [--initial-balance INITIAL_BALANCE] [--seed SEED] [--hide-predeployed-accounts] [--start-time START_TIME] [--gas-price GAS_PRICE] [--timeout TIMEOUT]
                        [--account-class ACCOUNT_CLASS] [--fork-network FORK_NETWORK] [--fork-block FORK_BLOCK]
-                       [--chain-id CHAIN_ID]
+                       [--chain-id CHAIN_ID] [--blocks-on-demand]
 
 Run a local instance of StarkNet Devnet
 
@@ -25,6 +25,7 @@ optional arguments:
                         Specify the path to dump to
   --dump-on DUMP_ON     Specify when to dump; can dump on: exit, transaction
   --lite-mode           Introduces speed-up by skipping block hash calculation - applies sequential numbering instead (0x0, 0x1, 0x2, ...).
+  --blocks-on-demand    Introduces block generation on demand via /create_block_on_demand endpoint
   --accounts ACCOUNTS   Specify the number of accounts to be predeployed; defaults to 10
   --initial-balance INITIAL_BALANCE, -e INITIAL_BALANCE
                         Specify the initial balance of accounts to be predeployed; defaults to 1e+21
diff --git a/page/docs/intro.md b/page/docs/intro.md
index 03aad192f..c19db7529 100644
--- a/page/docs/intro.md
+++ b/page/docs/intro.md
@@ -12,8 +12,7 @@ Let's discover **[starknet-devnet](https://github.com/Shard-Labs/starknet-devnet
 :::
 
 - Devnet should not be used as a replacement for Alpha testnet. After testing on Devnet, be sure to test on testnet (alpha-goerli)!
-- Specifying a block by its hash/number is not supported for contract calls. All interaction is done with the latest block.
-- There is no pending block. A new block is generated with each transaction.
+- Specifying a block by its hash/number is not supported for state querying. You can only choose between the pending or the latest block.
 - Sending transactions with max_fee set to 0 is supported (not supported on alpha-mainnet or alpha-goerli).
 - Devnet is currently being adapted to StarkNet and Cairo v0.10.0, if you spot any issues, please [report them](https://github.com/Shard-Labs/starknet-devnet/issues/new/choose).
 
diff --git a/pyproject.toml b/pyproject.toml
index 36f1b9809..b9b315c8e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,4 +73,5 @@ filterwarnings=[
     "ignore::DeprecationWarning:lark.*:",
     "ignore::DeprecationWarning:frozendict.*:",
     "ignore::DeprecationWarning:eth_abi.codec.*:",
+    "ignore::marshmallow.warnings.RemovedInMarshmallow4Warning",
 ]
diff --git a/starknet_devnet/blocks.py b/starknet_devnet/blocks.py
index a3f501bca..d830bedcc 100644
--- a/starknet_devnet/blocks.py
+++ b/starknet_devnet/blocks.py
@@ -2,11 +2,16 @@
 Class for generating and handling blocks
 """
 
-from typing import Dict
+from typing import Any, Dict, List, Optional, Sequence, Union
 
-from starkware.starknet.core.os.block_hash.block_hash import calculate_block_hash
+from starkware.starknet.core.os.block_hash.block_hash import (
+    calculate_block_hash,
+    calculate_event_hash,
+)
 from starkware.starknet.definitions.error_codes import StarknetErrorCode
 from starkware.starknet.services.api.feeder_gateway.response_objects import (
+    LATEST_BLOCK_ID,
+    PENDING_BLOCK_ID,
     BlockIdentifier,
     BlockStateUpdate,
     BlockStatus,
@@ -22,6 +27,41 @@
 from .util import StarknetDevnetException
 
 
+def _parse_block_hash(raw: Optional[str]):
+    if raw is None:
+        return raw
+
+    try:
+        return int(raw, 16)
+    except ValueError as error:
+        raise StarknetDevnetException(
+            code=StarkErrorCode.MALFORMED_REQUEST,
+            message=f"Invalid block hash: '{raw}'",
+        ) from error
+
+
+def _parse_block_number(raw: Optional[Union[int, str]]) -> BlockIdentifier:
+    if raw is None:  # no ID provided
+        return LATEST_BLOCK_ID
+
+    if raw in [PENDING_BLOCK_ID, LATEST_BLOCK_ID]:
+        return raw
+
+    if isinstance(raw, int):  # already a parsed number
+        return raw
+
+    if raw.isdigit():  # string that contains a numeric ID
+        try:
+            return int(raw, 10)
+        except ValueError:
+            pass
+
+    raise StarknetDevnetException(
+        code=StarkErrorCode.MALFORMED_REQUEST, message=f"Invalid block number: '{raw}'"
+    )
+
+
+# pylint: disable=too-many-instance-attributes
 class DevnetBlocks:
     """This class is used to store the generated blocks of the devnet."""
 
@@ -31,6 +71,9 @@ def __init__(self, origin: Origin, lite=False) -> None:
         self.__num2block: Dict[int, StarknetBlock] = {}
         self.__state_updates: Dict[int, BlockStateUpdate] = {}
         self.__hash2num: Dict[str, int] = {}
+        self.__pending_block: StarknetBlock = None
+        self.__pending_state_update: BlockStateUpdate = None
+        self.__pending_signatures: Sequence[List[int]] = None
 
     async def get_last_block(self) -> StarknetBlock:
         """Returns the last block stored so far."""
@@ -56,9 +99,17 @@ def __assert_block_number_in_range(self, block_number: BlockIdentifier):
                 code=StarknetErrorCode.BLOCK_NOT_FOUND, message=message
             )
 
-    async def get_by_number(self, block_number: BlockIdentifier) -> StarknetBlock:
+    async def get_by_number(self, block_number: Optional[str]) -> StarknetBlock:
         """Returns the block whose block_number is provided"""
-        if block_number is None:
+        block_number = _parse_block_number(block_number)
+
+        if block_number == PENDING_BLOCK_ID:
+            if self.__pending_block:
+                return self.__pending_block
+            # if no pending, default to latest
+            block_number = LATEST_BLOCK_ID
+
+        if block_number == LATEST_BLOCK_ID:
             if self.__num2block:
                 return await self.get_last_block()
             return await self.origin.get_block_by_number(block_number)
@@ -73,7 +124,7 @@ async def get_by_hash(self, block_hash: str) -> StarknetBlock:
         """
         Returns the block with the given block hash.
         """
-        numeric_hash = int(block_hash, 16)
+        numeric_hash = _parse_block_hash(block_hash)
 
         if numeric_hash in self.__hash2num:
             block_number = self.__hash2num[int(block_hash, 16)]
@@ -82,100 +133,161 @@ async def get_by_hash(self, block_hash: str) -> StarknetBlock:
         return await self.origin.get_block_by_hash(block_hash)
 
     async def get_state_update(
-        self, block_hash=None, block_number=None
+        self, block_hash: str = None, block_number: Any = None
     ) -> BlockStateUpdate:
         """
         Returns state update for the provided block hash or block number.
         It will return the last state update if block is not provided.
         """
         if block_hash:
-            numeric_hash = int(block_hash, 16)
+            numeric_hash = _parse_block_hash(block_hash)
 
             if numeric_hash not in self.__hash2num:
                 return await self.origin.get_state_update(block_hash=block_hash)
 
             block_number = self.__hash2num[numeric_hash]
 
-        if block_number is not None:
+        block_number = _parse_block_number(block_number)
+
+        if block_number == PENDING_BLOCK_ID:
+            if self.__pending_state_update:
+                return self.__pending_state_update
+            # if no pending, default to latest
+            block_number = LATEST_BLOCK_ID
+
+        # now either an int or "latest"
+        if block_number != LATEST_BLOCK_ID:
             self.__assert_block_number_in_range(block_number)
             if block_number in self.__state_updates:
                 return self.__state_updates[block_number]
 
             return await self.origin.get_state_update(block_number=block_number)
 
+        # now we know the block ID is "latest"
         return (
             self.__state_updates.get(self.get_number_of_blocks() - 1)
             or await self.origin.get_state_update()
         )
 
-    async def generate(
+    async def generate_pending(
         self,
-        transaction: DevnetTransaction,
+        transactions: List[DevnetTransaction],
         state: StarknetState,
         state_update=None,
-        is_empty_block=False,
-    ) -> StarknetBlock:
+    ):
         """
-        Generates a block and stores it to blocks and hash2block. The block contains just the passed transaction.
-        The `tx_wrapper.transaction` dict should contain a key `transaction`.
-        Returns (block_hash, block_number).
+        Generates pending objects (block, updates) and stores them as private properties.
+        The method `store_pending` can be used after this method.
         """
-        state_root = DUMMY_STATE_ROOT
-        block_number = self.get_number_of_blocks()
         timestamp = state.state.block_info.block_timestamp
+        signatures = [tx.get_signature() for tx in transactions or []]
+        internal_transactions = [tx.internal_tx for tx in transactions or []]
+        transaction_receipts = tuple(tx.get_execution() for tx in transactions or ())
+
+        block_number = self.get_number_of_blocks()
         if block_number == 0:
             parent_block_hash = 0
         else:
             last_block = await self.get_last_block()
             parent_block_hash = last_block.block_hash
 
-        if is_empty_block:
-            transaction_receipts = ()
-            transactions = []
-        else:
-            transaction_receipts = (transaction.get_execution(),)
-            transactions = [transaction.internal_tx]
-
-        if self.lite or is_empty_block:
-            block_hash = block_number
-        else:
-            signature = transaction.get_signature()
-            block_hash = await calculate_block_hash(
-                general_config=state.general_config,
-                parent_hash=parent_block_hash,
-                block_number=block_number,
-                global_state_root=state_root,
-                block_timestamp=timestamp,
-                tx_hashes=[transaction.internal_tx.hash_value],
-                tx_signatures=[signature],
-                event_hashes=[],
-                sequencer_address=state.general_config.sequencer_address,
-            )
-
-        block = StarknetBlock.create(
-            block_hash=block_hash,
-            block_number=block_number,
-            state_root=state_root,
-            transactions=transactions,
+        self.__pending_block = StarknetBlock.create(
+            block_hash=None,
+            block_number=None,
+            state_root=None,
+            transactions=internal_transactions,
             timestamp=timestamp,
             transaction_receipts=transaction_receipts,
-            status=BlockStatus.ACCEPTED_ON_L2,
+            status=BlockStatus.PENDING,
             gas_price=state.state.block_info.gas_price,
             sequencer_address=state.general_config.sequencer_address,
             parent_block_hash=parent_block_hash,
             starknet_version=CAIRO_LANG_VERSION,
         )
 
-        self.__num2block[block_number] = block
+        self.__pending_state_update = state_update
+        self.__pending_signatures = signatures
+
+    async def generate_empty_block(
+        self, state: StarknetState, state_update: BlockStateUpdate
+    ) -> StarknetBlock:
+        """Generate an empty block"""
+        await self.generate_pending(
+            transactions=[], state=state, state_update=state_update
+        )
+        return await self.store_pending(state, is_empty_block=True)
+
+    async def __calculate_pending_block_hash(
+        self, state: StarknetState, block_number: int, state_root: bytes
+    ):
+        event_hashes: List[int] = []
+        for receipt in self.__pending_block.transaction_receipts:
+            for event in receipt.events:
+                event_hashes.append(
+                    calculate_event_hash(
+                        from_address=event.from_address,
+                        keys=event.keys,
+                        data=event.data,
+                    )
+                )
+
+        return await calculate_block_hash(
+            general_config=state.general_config,
+            parent_hash=self.__pending_block.parent_block_hash,
+            block_number=block_number,
+            global_state_root=state_root,
+            block_timestamp=self.__pending_block.timestamp,
+            tx_hashes=[tx.transaction_hash for tx in self.__pending_block.transactions],
+            tx_signatures=self.__pending_signatures,
+            event_hashes=event_hashes,
+            sequencer_address=self.__pending_block.sequencer_address,
+        )
+
+    def is_block_pending(self) -> bool:
+        """Return `True` if there is a pending block, oterhwise return `False`"""
+        return self.__pending_block is not None
+
+    async def store_pending(
+        self, state: StarknetState, is_empty_block=False
+    ) -> StarknetBlock:
+        """
+        Store pending block, assign a block hash to it, effecitvely making it the latest.
+        Set pending properties to None.
+        """
+        assert self.__pending_block
+
+        block_dict = self.__pending_block.dump()
+
+        block_dict["status"] = BlockStatus.ACCEPTED_ON_L2.name
+        state_root = DUMMY_STATE_ROOT
+        block_dict["state_root"] = state_root.hex()
+
+        block_number = self.get_number_of_blocks()
+        block_dict["block_number"] = block_number
+
+        if self.lite or is_empty_block:
+            block_hash = block_number
+        else:
+            block_hash = await self.__calculate_pending_block_hash(
+                state, block_number, state_root
+            )
+
+        block_dict["block_hash"] = hex(block_hash)
         self.__hash2num[block_hash] = block_number
 
-        if state_update is not None:
-            state_update = BlockStateUpdate(
+        if self.__pending_state_update is not None:
+            self.__pending_state_update = BlockStateUpdate(
                 block_hash=block_hash,
-                old_root=state_update.old_root,
-                new_root=state_update.new_root,
-                state_diff=state_update.state_diff,
+                old_root=self.__pending_state_update.old_root,
+                new_root=self.__pending_state_update.new_root,
+                state_diff=self.__pending_state_update.state_diff,
             )
+        self.__state_updates[block_number] = self.__pending_state_update
+        self.__pending_state_update = None
+
+        block = StarknetBlock.load(block_dict)
+        self.__num2block[block_number] = block
 
-        self.__state_updates[block_number] = state_update
+        self.__pending_block = None
+        self.__pending_signatures = None
         return block
diff --git a/starknet_devnet/blueprints/base.py b/starknet_devnet/blueprints/base.py
index 623f0c123..89d49536c 100644
--- a/starknet_devnet/blueprints/base.py
+++ b/starknet_devnet/blueprints/base.py
@@ -195,6 +195,14 @@ async def create_block():
     return Response(block.dumps(), status=200, mimetype="application/json")
 
 
+@base.route("/create_block_on_demand", methods=["POST"])
+async def create_block_on_demand():
+    """Create block on demand"""
+    block = await state.starknet_wrapper.generate_latest_block()
+
+    return jsonify({"block_hash": hex(block.block_hash)})
+
+
 @base.route("/fork_status", methods=["GET"])
 async def fork_status():
     """Get fork status"""
diff --git a/starknet_devnet/blueprints/feeder_gateway.py b/starknet_devnet/blueprints/feeder_gateway.py
index 085a976e9..609e2fed3 100644
--- a/starknet_devnet/blueprints/feeder_gateway.py
+++ b/starknet_devnet/blueprints/feeder_gateway.py
@@ -51,26 +51,22 @@ def validate_int(request_args: MultiDict, attribute: str):
         ) from err
 
 
-def _check_block_hash(request_args: MultiDict):
-    block_hash = request_args.get("blockHash", type=custom_int)
-    if block_hash is not None:
-        print(
-            "Specifying a block by its hash is not supported. All interaction is done with the latest block."
-        )
-
+def _extract_raw_block_arguments(args: MultiDict):
+    block_hash = args.get("blockHash")
+    block_number = args.get("blockNumber")
 
-def _check_block_arguments(block_hash, block_number):
     if block_hash is not None and block_number is not None:
         message = "Ambiguous criteria: only one of (block number, block hash) can be provided."
         raise StarknetDevnetException(
             code=StarkErrorCode.MALFORMED_REQUEST, message=message
         )
 
+    return block_hash, block_number
 
-async def _get_block_object(block_hash: str, block_number: int):
-    """Returns the block object"""
 
-    _check_block_arguments(block_hash, block_number)
+async def _get_block_object(args: MultiDict) -> StarknetBlock:
+    """Returns the block object"""
+    block_hash, block_number = _extract_raw_block_arguments(args)
 
     if block_hash is not None:
         return await state.starknet_wrapper.blocks.get_by_hash(block_hash)
@@ -96,6 +92,13 @@ async def _get_block_transaction_traces(block: StarknetBlock):
     return BlockTransactionTraces.load({"traces": traces})
 
 
+def _get_block_id(args: MultiDict):
+    if "blockHash" in args:
+        raise StarknetDevnetException("Cannot handle block hashes", status_code=400)
+
+    return args.get("blockNumber", "latest")
+
+
 @feeder_gateway.route("/get_contract_addresses", methods=["GET"])
 def get_contract_addresses():
     """Endpoint that returns an object containing the addresses of key system components."""
@@ -108,6 +111,8 @@ async def call_contract():
     Endpoint for receiving calls (not invokes) of contract functions.
     """
 
+    block_id = _get_block_id(request.args)
+
     try:
         call_specifications = validate_request(request.data, CallFunction)  # version 1
     except StarknetDevnetException:
@@ -115,7 +120,7 @@ async def call_contract():
             request.data, InvokeFunction
         )  # version 0
 
-    result_dict = await state.starknet_wrapper.call(call_specifications)
+    result_dict = await state.starknet_wrapper.call(call_specifications, block_id)
     return jsonify(result_dict)
 
 
@@ -123,24 +128,16 @@ async def call_contract():
 async def get_block():
     """Endpoint for retrieving a block identified by its hash or number."""
 
-    block_hash = request.args.get("blockHash")
-    block_number = request.args.get("blockNumber", type=custom_int)
-
-    block = await _get_block_object(block_hash=block_hash, block_number=block_number)
-
-    return Response(block.dumps(), status=200, mimetype="application/json")
+    block = await _get_block_object(request.args)
+    return jsonify(block.dump())
 
 
 @feeder_gateway.route("/get_block_traces", methods=["GET"])
 async def get_block_traces():
     """Returns the traces of the transactions in the specified block."""
 
-    block_hash = request.args.get("blockHash")
-    block_number = request.args.get("blockNumber", type=custom_int)
-
-    block = await _get_block_object(block_hash=block_hash, block_number=block_number)
+    block = await _get_block_object(request.args)
     block_transaction_traces = await _get_block_transaction_traces(block)
-
     return jsonify(block_transaction_traces.dump())
 
 
@@ -150,10 +147,10 @@ async def get_code():
     Returns the ABI and bytecode of the contract whose contractAddress is provided.
     """
 
-    _check_block_hash(request.args)
+    block_id = _get_block_id(request.args)
 
     contract_address = request.args.get("contractAddress", type=custom_int)
-    code_dict = await state.starknet_wrapper.get_code(contract_address)
+    code_dict = await state.starknet_wrapper.get_code(contract_address, block_id)
     return jsonify(code_dict)
 
 
@@ -162,11 +159,13 @@ async def get_full_contract():
     """
     Returns the contract class of the contract whose contractAddress is provided.
     """
-    _check_block_hash(request.args)
+    block_id = _get_block_id(request.args)
 
     contract_address = request.args.get("contractAddress", type=custom_int)
 
-    contract_class = await state.starknet_wrapper.get_class_by_address(contract_address)
+    contract_class = await state.starknet_wrapper.get_class_by_address(
+        contract_address, block_id
+    )
     return jsonify(contract_class.remove_debug_info().dump())
 
 
@@ -191,12 +190,14 @@ async def get_class_by_hash():
 @feeder_gateway.route("/get_storage_at", methods=["GET"])
 async def get_storage_at():
     """Endpoint for returning the storage identified by `key` from the contract at"""
-    _check_block_hash(request.args)
+    block_id = _get_block_id(request.args)
 
     contract_address = request.args.get("contractAddress", type=custom_int)
     key = validate_int(request.args, "key")
 
-    storage = await state.starknet_wrapper.get_storage_at(contract_address, key)
+    storage = await state.starknet_wrapper.get_storage_at(
+        contract_address, key, block_id
+    )
     return jsonify(storage)
 
 
@@ -266,19 +267,14 @@ async def get_state_update():
     If no block hash was provided it will default to the last block.
     """
 
-    block_hash = request.args.get("blockHash")
-    block_number = request.args.get("blockNumber", type=custom_int)
+    block_hash, block_number = _extract_raw_block_arguments(request.args)
 
     state_update = await state.starknet_wrapper.blocks.get_state_update(
         block_hash=block_hash, block_number=block_number
     )
 
-    if state_update is not None:
-        return Response(
-            response=state_update.dumps(), status=200, mimetype="application/json"
-        )
-
-    return jsonify(state_update)
+    assert state_update is not None
+    return jsonify(state_update.dump())
 
 
 @feeder_gateway.route("/estimate_fee", methods=["POST"])
@@ -290,7 +286,11 @@ async def estimate_fee():
     except StarknetDevnetException:
         transaction = validate_request(request.data, InvokeFunction)  # version 0
 
-    _, fee_response = await state.starknet_wrapper.calculate_trace_and_fee(transaction)
+    block_id = _get_block_id(request.args)
+
+    _, fee_response = await state.starknet_wrapper.calculate_trace_and_fee(
+        transaction, block_id
+    )
     return jsonify(fee_response)
 
 
@@ -305,8 +305,10 @@ async def estimate_fee_bulk():
         # version 0
         transactions = validate_request(request.data, InvokeFunction, many=True)
 
+    block_id = _get_block_id(request.args)
+
     _, fee_responses = await state.starknet_wrapper.calculate_traces_and_fees(
-        transactions
+        transactions, block_id
     )
     return jsonify(fee_responses)
 
@@ -315,8 +317,10 @@ async def estimate_fee_bulk():
 async def simulate_transaction():
     """Returns the estimated fee for a transaction."""
     transaction = validate_request(request.data, AccountTransaction)
+    block_id = _get_block_id(request.args)
+
     trace, fee_response = await state.starknet_wrapper.calculate_trace_and_fee(
-        transaction
+        transaction, block_id
     )
 
     simulation_info = TransactionSimulationInfo(
@@ -330,8 +334,9 @@ async def simulate_transaction():
 async def get_nonce():
     """Returns the nonce of the contract whose contractAddress is provided"""
 
+    block_id = _get_block_id(request.args)
     contract_address = request.args.get("contractAddress", type=custom_int)
-    nonce = await state.starknet_wrapper.get_nonce(contract_address)
+    nonce = await state.starknet_wrapper.get_nonce(contract_address, block_id)
 
     return jsonify(hex(nonce))
 
@@ -340,8 +345,8 @@ async def get_nonce():
 async def estimate_message_fee():
     """Message fee estimation endpoint"""
 
-    _check_block_hash(request.args)
+    block_id = _get_block_id(request.args)
 
     call = validate_request(request.data, CallL1Handler)
-    fee_estimation = await state.starknet_wrapper.estimate_message_fee(call)
+    fee_estimation = await state.starknet_wrapper.estimate_message_fee(call, block_id)
     return jsonify(fee_estimation)
diff --git a/starknet_devnet/blueprints/rpc/classes.py b/starknet_devnet/blueprints/rpc/classes.py
index e5a9b1141..3ae5f5445 100644
--- a/starknet_devnet/blueprints/rpc/classes.py
+++ b/starknet_devnet/blueprints/rpc/classes.py
@@ -46,7 +46,7 @@ async def get_class_hash_at(block_id: BlockId, contract_address: Address) -> Fel
 
     try:
         result = await state.starknet_wrapper.get_class_hash_at(
-            int(contract_address, 16)
+            int(contract_address, 16), block_id
         )
     except StarkException as ex:
         raise RpcError(code=28, message="Class hash not found") from ex
@@ -63,7 +63,7 @@ async def get_class_at(block_id: BlockId, contract_address: Address) -> dict:
 
     try:
         result = await state.starknet_wrapper.get_class_by_address(
-            int(contract_address, 16)
+            int(contract_address, 16), block_id
         )
     except StarkException as ex:
         raise RpcError(code=20, message="Contract not found") from ex
diff --git a/starknet_devnet/blueprints/rpc/misc.py b/starknet_devnet/blueprints/rpc/misc.py
index f36ab91e3..b29258d96 100644
--- a/starknet_devnet/blueprints/rpc/misc.py
+++ b/starknet_devnet/blueprints/rpc/misc.py
@@ -6,6 +6,11 @@
 
 from typing import Union
 
+from starkware.starknet.services.api.feeder_gateway.response_objects import (
+    LATEST_BLOCK_ID,
+    PENDING_BLOCK_ID,
+)
+
 from starknet_devnet.blueprints.rpc.schema import validate_schema
 from starknet_devnet.blueprints.rpc.structures.responses import RpcEventsResult
 from starknet_devnet.blueprints.rpc.structures.types import (
@@ -101,12 +106,19 @@ async def get_events(filter) -> RpcEventsResult:
 
     events = []
     keys = [] if keys is None else [int(k, 0) for k in keys]
+
+    include_pending = to_block == PENDING_BLOCK_ID
     to_block = (
         int(state.starknet_wrapper.blocks.get_number_of_blocks())
-        if to_block in ["latest", "pending"]
+        if to_block in [LATEST_BLOCK_ID, PENDING_BLOCK_ID]
         else int(to_block) + 1
     )
-    for block_number in range(int(from_block), to_block):
+    block_range = list(range(int(from_block), to_block))
+    if include_pending:
+        # pending needs to be included separately as it is not reachable through a number
+        block_range.append(PENDING_BLOCK_ID)
+
+    for block_number in block_range:
         block = await state.starknet_wrapper.blocks.get_by_number(block_number)
         if block.transaction_receipts:
             events.extend(get_events_from_block(block, address, keys))
@@ -134,7 +146,7 @@ async def get_nonce(block_id: BlockId, contract_address: Address) -> Felt:
         raise RpcError(code=20, message="Contract not found")
 
     result = await state.starknet_wrapper.get_nonce(
-        contract_address=int(contract_address, 16)
+        contract_address=int(contract_address, 16), block_id=block_id
     )
 
     return rpc_felt(result)
diff --git a/starknet_devnet/blueprints/rpc/storage.py b/starknet_devnet/blueprints/rpc/storage.py
index 5c198a17a..91d733a70 100644
--- a/starknet_devnet/blueprints/rpc/storage.py
+++ b/starknet_devnet/blueprints/rpc/storage.py
@@ -28,6 +28,8 @@ async def get_storage_at(
         raise RpcError(code=20, message="Contract not found")
 
     storage = await state.starknet_wrapper.get_storage_at(
-        contract_address=int(contract_address, 16), key=int(key, 16)
+        contract_address=int(contract_address, 16),
+        key=int(key, 16),
+        block_id=block_id,
     )
     return rpc_felt(storage)
diff --git a/starknet_devnet/blueprints/rpc/transactions.py b/starknet_devnet/blueprints/rpc/transactions.py
index aec232982..708959293 100644
--- a/starknet_devnet/blueprints/rpc/transactions.py
+++ b/starknet_devnet/blueprints/rpc/transactions.py
@@ -204,7 +204,7 @@ async def estimate_fee(request: RpcBroadcastedTxn, block_id: BlockId) -> dict:
     transaction = make_transaction(request)
     try:
         _, fee_response = await state.starknet_wrapper.calculate_trace_and_fee(
-            transaction
+            transaction, block_id
         )
     except StarkException as ex:
         if "entry_point_selector" in request and (
diff --git a/starknet_devnet/devnet_config.py b/starknet_devnet/devnet_config.py
index 5dfff97cc..08f7daa02 100644
--- a/starknet_devnet/devnet_config.py
+++ b/starknet_devnet/devnet_config.py
@@ -228,6 +228,11 @@ def parse_args(raw_args: List[str]):
         help="Introduces speed-up by skipping block hash calculation"
         " - applies sequential numbering instead (0x0, 0x1, 0x2, ...).",
     )
+    parser.add_argument(
+        "--blocks-on-demand",
+        action="store_true",
+        help="Block generation on demand via an endpoint.",
+    )
     parser.add_argument(
         "--accounts",
         action=NonNegativeAction,
@@ -337,6 +342,7 @@ def __init__(self, args: argparse.Namespace = None):
         self.start_time = self.args.start_time
         self.gas_price = self.args.gas_price
         self.lite_mode = self.args.lite_mode
+        self.blocks_on_demand = self.args.blocks_on_demand
         self.account_class = self.args.account_class
         self.hide_predeployed_accounts = self.args.hide_predeployed_accounts
         self.fork_network = self.args.fork_network
diff --git a/starknet_devnet/starknet_wrapper.py b/starknet_devnet/starknet_wrapper.py
index 6bef12a48..e03e6dd18 100644
--- a/starknet_devnet/starknet_wrapper.py
+++ b/starknet_devnet/starknet_wrapper.py
@@ -32,8 +32,12 @@
     CallL1Handler,
 )
 from starkware.starknet.services.api.feeder_gateway.response_objects import (
+    LATEST_BLOCK_ID,
+    PENDING_BLOCK_ID,
+    BlockIdentifier,
     BlockStateUpdate,
     DeployedContract,
+    StarknetBlock,
     StateDiff,
     StorageEntry,
     TransactionStatus,
@@ -80,6 +84,8 @@
 
 enable_pickling()
 
+DEFAULT_BLOCK_ID = LATEST_BLOCK_ID
+
 # pylint: disable=too-many-instance-attributes
 # pylint: disable=too-many-public-methods
 class StarknetWrapper:
@@ -107,6 +113,8 @@ def __init__(self, config: DevnetConfig):
         self.fee_token = FeeToken(self)
         self.accounts = Accounts(self)
         self.__udc = UDC(self)
+        self.pending_txs: List[DevnetTransaction] = []
+        self.__latest_state = None
 
         if config.start_time is not None:
             self.set_block_time(config.start_time)
@@ -134,14 +142,12 @@ async def initialize(self):
             await self.create_empty_block()
             self.__initialized = True
 
-    async def create_empty_block(self):
-        """create empty block"""
+    async def create_empty_block(self) -> StarknetBlock:
+        """Create empty block."""
         self._update_block_number()
-        state_update = await self._update_state()
-        state = self.get_state()
-        return await self.blocks.generate(
-            None, state, state_update, is_empty_block=True
-        )
+        state_update = await self._update_pending_state()
+        self.__latest_state = self.get_state().copy()
+        return await self.blocks.generate_empty_block(self.get_state(), state_update)
 
     async def __preserve_current_state(self, state: CachedState):
         self.__current_cached_state = deepcopy(state)
@@ -178,7 +184,7 @@ def get_state(self):
         """
         return self.starknet.state
 
-    async def _update_state(
+    async def _update_pending_state(
         self,
         deployed_contracts: List[DeployedContract] = None,
         explicitly_declared_contracts: List[int] = None,
@@ -221,31 +227,19 @@ async def _update_state(
             state_diff=state_diff,
         )
 
-    async def _store_transaction(
+    def _store_transaction(
         self,
         transaction: DevnetTransaction,
-        tx_hash: int,
-        state_update: Dict,
         error_message: str = None,
-    ) -> None:
+    ) -> StarknetBlock:
         """
-        Stores the provided data as a deploy transaction in `self.transactions`.
-        Generates a new block
+        Stores the provided transaction in the transaction storage.
         """
         if transaction.status == TransactionStatus.REJECTED:
             assert error_message, "error_message must be present if tx rejected"
             transaction.set_failure_reason(error_message)
-        else:
-            state = self.get_state()
-
-            block = await self.blocks.generate(
-                transaction,
-                state,
-                state_update=state_update,
-            )
-            transaction.set_block(block=block)
 
-        self.transactions.store(tx_hash, transaction)
+        self.transactions.store(transaction.transaction_hash, transaction)
 
     async def declare(self, external_tx: Declare) -> Tuple[int, int]:
         """
@@ -253,22 +247,21 @@ async def declare(self, external_tx: Declare) -> Tuple[int, int]:
         Returns (class_hash, transaction_hash)
         """
 
+        state = self.get_state()
         async with self.__get_transaction_handler() as tx_handler:
             tx_handler.internal_tx = InternalDeclare.from_external(
-                external_tx, self.get_state().general_config
+                external_tx, state.general_config
             )
             # calculate class hash here if execution fails
             class_hash_int = int.from_bytes(tx_handler.internal_tx.class_hash, "big")
 
-            tx_handler.execution_info = await self.starknet.state.execute_tx(
-                tx_handler.internal_tx
-            )
+            tx_handler.execution_info = await state.execute_tx(tx_handler.internal_tx)
 
             tx_handler.explicitly_declared.append(class_hash_int)
 
             # alpha-goerli allows multiple declarations of the same class.
             # Even though execute_tx is performed, class needs to be set explicitly
-            await self.get_state().state.set_contract_class(
+            await state.state.set_contract_class(
                 class_hash=tx_handler.internal_tx.class_hash,
                 contract_class=external_tx.contract_class,
             )
@@ -293,7 +286,7 @@ class TransactionHandler:
             """Class for with-blocks in transactions"""
 
             internal_tx: InternalTransaction
-            execution_info: TransactionExecutionInfo
+            execution_info: TransactionExecutionInfo = TransactionExecutionInfo.empty()
             internal_calls: List[CallInfo] = []
             deployed_contracts: List[DeployedContract] = []
             explicitly_declared: List[int] = []
@@ -317,18 +310,24 @@ async def __aexit__(
 
                 if exc_type:
                     assert isinstance(exc, StarkException)
-                    error_message = exc.message
                     status = TransactionStatus.REJECTED
-                    self.execution_info = TransactionExecutionInfo.empty()
-                    state_update = None
 
                     # restore block info
                     self.starknet_wrapper.get_state().state.block_info = (
                         self.preserved_block_info
                     )
+
+                    transaction = DevnetTransaction(
+                        internal_tx=self.internal_tx,
+                        status=status,
+                        execution_info=self.execution_info,
+                        transaction_hash=tx_hash,
+                    )
+                    self.starknet_wrapper._store_transaction(
+                        transaction, error_message=exc.message
+                    )
                 else:
-                    error_message = None
-                    status = TransactionStatus.ACCEPTED_ON_L2
+                    status = TransactionStatus.PENDING
 
                     assert self.execution_info is not None
                     if self.execution_info.call_info:
@@ -338,27 +337,27 @@ async def __aexit__(
                             self.deployed_contracts,
                         )
 
-                    state_update = await self.starknet_wrapper._update_state(
+                    state_update = await self.starknet_wrapper._update_pending_state(
                         deployed_contracts=self.deployed_contracts,
                         visited_storage_entries=self.visited_storage_entries,
                         explicitly_declared_contracts=self.explicitly_declared,
                     )
 
-                transaction = DevnetTransaction(
-                    internal_tx=self.internal_tx,
-                    status=status,
-                    execution_info=self.execution_info,
-                    transaction_hash=tx_hash,
-                )
+                    transaction = DevnetTransaction(
+                        internal_tx=self.internal_tx,
+                        status=status,
+                        execution_info=self.execution_info,
+                        transaction_hash=tx_hash,
+                    )
+                    self.starknet_wrapper.pending_txs.append(transaction)
+                    self.starknet_wrapper._store_transaction(transaction)
 
-                await self.starknet_wrapper._store_transaction(
-                    transaction=transaction,
-                    state_update=state_update,
-                    error_message=error_message,
-                    tx_hash=tx_hash,
-                )
+                    await self.starknet_wrapper.update_pending_block(state_update)
+
+                    if not self.starknet_wrapper.config.blocks_on_demand:
+                        await self.starknet_wrapper.generate_latest_block()
 
-                return True
+                return True  # indicates the caught exception was handled successfully
 
         return TransactionHandler(self)
 
@@ -450,11 +449,28 @@ async def invoke(self, external_tx: InvokeFunction):
 
         return external_tx.contract_address, tx_handler.internal_tx.hash_value
 
-    async def call(self, transaction: CallFunction):
+    async def __get_query_state(self, block_id: BlockIdentifier = DEFAULT_BLOCK_ID):
+        if block_id == PENDING_BLOCK_ID:
+            return self.get_state()
+        if block_id == LATEST_BLOCK_ID:
+            return self.__latest_state
+        if block_id.isdigit():
+            latest_number = (await self.blocks.get_last_block()).block_number
+            if latest_number == int(block_id):
+                return self.__latest_state
+
+        raise StarknetDevnetException(
+            code=StarknetErrorCode.INVALID_BLOCK_NUMBER,
+            message=f"Invalid block id: {block_id}. Must specify pending or latest block. Reported from instance with port {self.config.args.port}",
+        )
+
+    async def call(
+        self, transaction: CallFunction, block_id: BlockIdentifier = DEFAULT_BLOCK_ID
+    ):
         """Perform call according to specifications in `transaction`."""
+        state = await self.__get_query_state(block_id)
 
-        state_copy = self.get_state().copy()
-        call_info = await state_copy.execute_entry_point_raw(
+        call_info = await state.copy().execute_entry_point_raw(
             contract_address=transaction.contract_address,
             selector=transaction.entry_point_selector,
             calldata=transaction.calldata,
@@ -500,9 +516,12 @@ async def get_class_by_hash(self, class_hash: int) -> ContractClass:
         class_hash_bytes = to_bytes(class_hash)
         return await cached_state.get_contract_class(class_hash_bytes)
 
-    async def get_class_hash_at(self, contract_address: int) -> int:
+    async def get_class_hash_at(
+        self, contract_address: int, block_id: BlockIdentifier = DEFAULT_BLOCK_ID
+    ) -> int:
         """Return class hash give the contract address"""
-        cached_state = self.get_state().state
+        state = await self.__get_query_state(block_id)
+        cached_state = state.state
         class_hash_bytes = await cached_state.get_class_hash_at(contract_address)
         class_hash_int = int.from_bytes(class_hash_bytes, "big")
 
@@ -513,17 +532,22 @@ async def get_class_hash_at(self, contract_address: int) -> int:
             )
         return class_hash_int
 
-    async def get_class_by_address(self, contract_address: int) -> ContractClass:
+    async def get_class_by_address(
+        self, contract_address: int, block_id: BlockIdentifier = DEFAULT_BLOCK_ID
+    ) -> ContractClass:
         """Return contract class given the contract address"""
-        cached_state = self.get_state().state
+        state = await self.__get_query_state(block_id)
+        cached_state = state.state
         class_hash_int = await self.get_class_hash_at(contract_address)
         class_hash_bytes = to_bytes(class_hash_int)
         return await cached_state.get_contract_class(class_hash_bytes)
 
-    async def get_code(self, contract_address: int) -> dict:
+    async def get_code(
+        self, contract_address: int, block_id: BlockIdentifier = DEFAULT_BLOCK_ID
+    ) -> dict:
         """Return code dict given the contract address"""
         try:
-            contract_class = await self.get_class_by_address(contract_address)
+            contract_class = await self.get_class_by_address(contract_address, block_id)
             result_dict = {
                 "abi": contract_class.abi,
                 "bytecode": contract_class.dump()["program"]["data"],
@@ -535,13 +559,18 @@ async def get_code(self, contract_address: int) -> dict:
 
         return result_dict
 
-    async def get_storage_at(self, contract_address: int, key: int) -> Felt:
+    async def get_storage_at(
+        self,
+        contract_address: int,
+        key: int,
+        block_id: BlockIdentifier = DEFAULT_BLOCK_ID,
+    ) -> Felt:
         """
         Returns the storage identified by `key`
         from the contract at `contract_address`.
         """
-        state = self.get_state().state
-        return hex(await state.get_storage_at(contract_address, key))
+        state = await self.__get_query_state(block_id)
+        return hex(await state.state.get_storage_at(contract_address, key))
 
     async def load_messaging_contract_in_l1(
         self, network_url: str, contract_address: str, network_id: str
@@ -603,16 +632,53 @@ async def postman_flush(self) -> dict:
 
         return parsed_l1_l2_messages
 
-    async def calculate_trace_and_fee(self, external_tx: InvokeFunction):
+    async def update_pending_block(self, state_update: BlockStateUpdate = None):
+        """Update pending block"""
+        await self.blocks.generate_pending(
+            transactions=self.pending_txs,
+            state=self.get_state(),
+            state_update=state_update,
+        )
+
+    async def generate_latest_block(self) -> StarknetBlock:
+        """Generate new block with pending transactions in --blocks-on-demand mode."""
+
+        # Store transactions and clear pending txs
+        state = self.get_state()
+        if self.blocks.is_block_pending():
+            block = await self.blocks.store_pending(state)
+        else:
+            # if no pending, default to creating an empty block
+            assert not self.pending_txs
+            block = await self.create_empty_block()
+
+        for transaction in self.pending_txs:
+            transaction.status = TransactionStatus.ACCEPTED_ON_L2
+            transaction.set_block(block=block)
+
+        # Update latest state before block generation
+        self.__latest_state = state.copy()
+
+        self.pending_txs = []
+
+        return block
+
+    async def calculate_trace_and_fee(
+        self, external_tx: InvokeFunction, block_id: BlockIdentifier = DEFAULT_BLOCK_ID
+    ):
         """Calculates trace and fee by simulating tx on state copy."""
-        traces, fees = await self.calculate_traces_and_fees([external_tx])
+        traces, fees = await self.calculate_traces_and_fees([external_tx], block_id)
         assert len(traces) == len(fees) == 1
         return traces[0], fees[0]
 
-    async def calculate_traces_and_fees(self, external_txs: List[InvokeFunction]):
+    async def calculate_traces_and_fees(
+        self,
+        external_txs: List[InvokeFunction],
+        block_id: BlockIdentifier = DEFAULT_BLOCK_ID,
+    ):
         """Calculates traces and fees by simulating tx on state copy.
         Uses the resulting state for each consecutive estimation"""
-        state = self.get_state()
+        state = await self.__get_query_state(block_id)
         cached_state_copy = state.state
 
         traces = []
@@ -659,9 +725,11 @@ async def calculate_traces_and_fees(self, external_txs: List[InvokeFunction]):
         assert len(traces) == len(fee_estimation_infos) == len(external_txs)
         return traces, fee_estimation_infos
 
-    async def estimate_message_fee(self, call: CallL1Handler):
+    async def estimate_message_fee(
+        self, call: CallL1Handler, block_id: BlockIdentifier = DEFAULT_BLOCK_ID
+    ):
         """Estimate fee of message from L1 to L2"""
-        state = self.get_state()
+        state = await self.__get_query_state(block_id)
         internal_call: InternalL1Handler = call.to_internal(
             state.general_config.chain_id.value
         )
@@ -695,9 +763,12 @@ def __set_gas_price(self, gas_price: int):
         """Sets gas price to `gas_price`."""
         self.block_info_generator.set_gas_price(gas_price)
 
-    async def get_nonce(self, contract_address: int):
+    async def get_nonce(
+        self, contract_address: int, block_id: BlockIdentifier = DEFAULT_BLOCK_ID
+    ):
         """Returns nonce of contract with `contract_address`"""
-        return await self.get_state().state.get_nonce_at(contract_address)
+        state = await self.__get_query_state(block_id)
+        return await state.state.get_nonce_at(contract_address)
 
     async def __predeclare_oz_account(self):
         """Predeclares the account class used by Starknet CLI"""


Test patch:
diff --git a/test/account.py b/test/account.py
index cdf7b8ece..181d822c2 100644
--- a/test/account.py
+++ b/test/account.py
@@ -99,6 +99,7 @@ def get_estimated_fee(
     private_key: str,
     nonce=None,
     feeder_gateway_url=APP_URL,
+    block_number=None,
     chain_id=StarknetChainId.TESTNET,
 ):
     """Get estimated fee through account."""
@@ -123,6 +124,7 @@ def get_estimated_fee(
         abi_path=ACCOUNT_ABI_PATH,
         signature=signature,
         nonce=nonce,
+        block_number=block_number,
         feeder_gateway_url=feeder_gateway_url,
     )
 
diff --git a/test/test_blocks_on_demand.py b/test/test_blocks_on_demand.py
new file mode 100644
index 000000000..668a45e52
--- /dev/null
+++ b/test/test_blocks_on_demand.py
@@ -0,0 +1,356 @@
+"""
+Test blocks on demand mode.
+"""
+
+import pytest
+import requests
+from starkware.starknet.definitions.error_codes import StarknetErrorCode
+
+from .account import get_estimated_fee, invoke
+from .rpc.rpc_utils import rpc_call
+from .settings import APP_URL
+from .shared import (
+    ABI_PATH,
+    CONTRACT_PATH,
+    EVENTS_CONTRACT_PATH,
+    PREDEPLOY_ACCOUNT_CLI_ARGS,
+    PREDEPLOYED_ACCOUNT_ADDRESS,
+    PREDEPLOYED_ACCOUNT_PRIVATE_KEY,
+)
+from .test_state_update import get_state_update
+from .test_transaction_trace import get_block_traces
+from .util import (
+    ReturnCodeAssertionError,
+    assert_equal,
+    assert_hex_equal,
+    assert_tx_status,
+    call,
+    deploy,
+    devnet_in_background,
+    get_block,
+)
+
+
+def _demand_block_creation():
+    return requests.post(f"{APP_URL}/create_block_on_demand")
+
+
+def _get_block_resp(block_number):
+    return requests.get(
+        f"{APP_URL}/feeder_gateway/get_block", {"blockNumber": block_number}
+    )
+
+
+def _assert_block_is_pending(block: dict):
+    assert block["status"] == "PENDING"
+    for prop in ["block_hash", "block_number", "state_root"]:
+        assert prop not in block
+
+
+@devnet_in_background(*PREDEPLOY_ACCOUNT_CLI_ARGS, "--blocks-on-demand")
+def test_invokable_on_pending_block():
+    """Test deploy+invoke+call in blocks-on-demand mode"""
+    latest_block = get_block(block_number="latest", parse=True)
+    genesis_block_number = latest_block["block_number"]
+    assert genesis_block_number == 0
+
+    deploy_info = deploy(CONTRACT_PATH, inputs=["0"])
+    assert_tx_status(deploy_info["tx_hash"], "PENDING")
+
+    def get_contract_balance():
+        return call(
+            function="get_balance",
+            address=deploy_info["address"],
+            abi_path=ABI_PATH,
+        )
+
+    try:
+        get_contract_balance()
+        pytest.fail("Should have failed")
+    except ReturnCodeAssertionError as error:
+        assert str(StarknetErrorCode.UNINITIALIZED_CONTRACT) in str(error)
+
+    invoke_hash = invoke(
+        calls=[(deploy_info["address"], "increase_balance", [10, 20])],
+        account_address=PREDEPLOYED_ACCOUNT_ADDRESS,
+        private_key=PREDEPLOYED_ACCOUNT_PRIVATE_KEY,
+    )
+    assert_tx_status(invoke_hash, "PENDING")
+
+    latest_block = get_block(block_number="latest", parse=True)
+    block_number_after_deploy_and_invoke = latest_block["block_number"]
+    assert block_number_after_deploy_and_invoke == 0
+
+    _demand_block_creation()
+    assert_tx_status(invoke_hash, "ACCEPTED_ON_L2")
+
+    balance_after_create_block_on_demand = get_contract_balance()
+    assert int(balance_after_create_block_on_demand) == 30
+
+    latest_block = get_block(block_number="latest", parse=True)
+    block_number_after_block_on_demand_call = latest_block["block_number"]
+    assert block_number_after_block_on_demand_call == 1
+    assert len(latest_block["transactions"]) == 2
+
+
+@devnet_in_background(*PREDEPLOY_ACCOUNT_CLI_ARGS, "--blocks-on-demand")
+def test_estimation_works_after_block_creation():
+    """Test estimation works only after demanding block creation."""
+    deploy_info = deploy(CONTRACT_PATH, inputs=["0"])
+    assert_tx_status(deploy_info["tx_hash"], "PENDING")
+
+    def estimate_invoke_fee():
+        return get_estimated_fee(
+            calls=[(deploy_info["address"], "increase_balance", [10, 20])],
+            account_address=PREDEPLOYED_ACCOUNT_ADDRESS,
+            private_key=PREDEPLOYED_ACCOUNT_PRIVATE_KEY,
+            block_number="latest",
+        )
+
+    try:
+        estimate_invoke_fee()
+        pytest.fail("Should have failed")
+    except ReturnCodeAssertionError as error:
+        assert str(StarknetErrorCode.UNINITIALIZED_CONTRACT) in str(error)
+
+    _demand_block_creation()
+    assert_tx_status(deploy_info["tx_hash"], "ACCEPTED_ON_L2")
+    estimated_fee = estimate_invoke_fee()
+    assert estimated_fee > 0
+
+
+@devnet_in_background(*PREDEPLOY_ACCOUNT_CLI_ARGS, "--blocks-on-demand")
+def test_calling_works_after_block_creation():
+    """
+    Test deploy in blocks-on-demand mode for invoke and contract call.
+    Balance after invoke should be 0 even when we increased it.
+    Only after calling create_block_on_demand balance should be increased in this mode.
+    """
+    # Deploy and invoke
+    deploy_info = deploy(CONTRACT_PATH, inputs=["0"])
+    _demand_block_creation()
+
+    def get_contract_balance():
+        return call(
+            function="get_balance",
+            address=deploy_info["address"],
+            abi_path=ABI_PATH,
+        )
+
+    balance_after_deploy = get_contract_balance()
+    assert int(balance_after_deploy) == 0
+
+    invoke(
+        calls=[(deploy_info["address"], "increase_balance", [10, 20])],
+        account_address=PREDEPLOYED_ACCOUNT_ADDRESS,
+        private_key=PREDEPLOYED_ACCOUNT_PRIVATE_KEY,
+    )
+    balance_after_invoke = get_contract_balance()
+    assert int(balance_after_invoke) == 0
+
+    _demand_block_creation()
+    balance_after_create_block_on_demand = get_contract_balance()
+    assert int(balance_after_create_block_on_demand) == 30
+
+
+@devnet_in_background(*PREDEPLOY_ACCOUNT_CLI_ARGS, "--blocks-on-demand")
+def test_getting_next_block():
+    """Test that artifacts related to block 1 are available only after creating on demand"""
+
+    # some transaction, could be anything
+    deploy(CONTRACT_PATH, inputs=["0"])
+
+    # expect failure on block retrieval
+    next_block_number = 1
+    block_resp = _get_block_resp(block_number=next_block_number)
+    assert block_resp.status_code == 500
+    assert block_resp.json()["code"] == str(StarknetErrorCode.BLOCK_NOT_FOUND)
+
+    _demand_block_creation()
+
+    # expect success on block retrieval
+    block_resp = _get_block_resp(block_number=next_block_number)
+    assert block_resp.status_code == 200
+
+
+@devnet_in_background("--blocks-on-demand")
+def test_getting_pending_defaults_to_latest():
+    """Test that specifying "pending" defaults to using "latest" if no there is no pending block"""
+
+    pending_block = get_block(block_number="pending", parse=True)
+    latest_block = get_block(block_number="latest", parse=True)
+    assert_equal(pending_block, latest_block)
+
+    pending_block_traces = get_block_traces({"blockNumber": "pending"})
+    latest_block_traces = get_block_traces({"blockNumber": "latest"})
+    assert_equal(pending_block_traces, latest_block_traces)
+
+    pending_state_update = get_state_update(block_number="pending")
+    latest_state_update = get_state_update(block_number="latest")
+    assert_equal(pending_state_update, latest_state_update)
+
+
+@devnet_in_background("--blocks-on-demand")
+def test_pending_block():
+    """Test that pending block contains pending data"""
+
+    # get state of latest before the tx
+    latest_block_before = get_block(block_number="latest", parse=True)
+    assert latest_block_before["status"] == "ACCEPTED_ON_L2"
+
+    # some tx to generate a pending block, could be anything
+    deploy_info = deploy(CONTRACT_PATH, inputs=["0"])
+
+    # assert correct pending block
+    pending_block = get_block(block_number="pending", parse=True)
+    _assert_block_is_pending(pending_block)
+    pending_tx_hashes = [tx["transaction_hash"] for tx in pending_block["transactions"]]
+    assert deploy_info["tx_hash"] in pending_tx_hashes
+
+    # assert latest unchanged
+    latest_block = get_block(block_number="latest", parse=True)
+    assert_equal(latest_block_before, latest_block)
+
+    _demand_block_creation()
+    latest_block_after = get_block(block_number="latest", parse=True)
+    assert pending_block["transactions"] == latest_block_after["transactions"]
+
+
+@devnet_in_background("--blocks-on-demand")
+def test_pending_block_traces():
+    """Test that pending block traces contain pending data"""
+
+    latest_block_traces_before = get_block_traces({"blockNumber": "latest"})
+
+    # some tx to generate a pending block, could be anything
+    deploy_info = deploy(CONTRACT_PATH, inputs=["0"])
+
+    pending_block_traces = get_block_traces({"blockNumber": "pending"})
+    assert_hex_equal(
+        hex(pending_block_traces.traces[0].transaction_hash),
+        deploy_info["tx_hash"],
+    )
+
+    # assert latest unchanged
+    latest_block_traces = get_block_traces({"blockNumber": "latest"})
+    assert_equal(latest_block_traces_before, latest_block_traces)
+
+
+@devnet_in_background("--blocks-on-demand")
+def test_pending_state_update():
+    """Test that pending state update contains pending data"""
+
+    latest_state_update_before = get_state_update(block_number="latest")
+
+    # some tx to generate a pending block, could be anything
+    deploy_info = deploy(CONTRACT_PATH, inputs=["0"])
+
+    pending_state_update = get_state_update(block_number="pending")
+    pending_deployed = pending_state_update["state_diff"]["deployed_contracts"]
+    assert_hex_equal(pending_deployed[0]["address"], deploy_info["address"])
+
+    # assert latest unchanged
+    latest_state_update = get_state_update(block_number="latest")
+    assert_equal(latest_state_update_before, latest_state_update)
+
+
+@devnet_in_background(*PREDEPLOY_ACCOUNT_CLI_ARGS, "--blocks-on-demand")
+def test_events():
+    """Test that events are stored and returned correctly in blocks-on-demand mode"""
+
+    def get_events(to_block: str):
+        return rpc_call(
+            "starknet_getEvents",
+            params={
+                "filter": {
+                    "from_block": "0",
+                    "to_block": to_block,
+                    "chunk_size": 10,
+                }
+            },
+        )
+
+    deploy_info = deploy(contract=EVENTS_CONTRACT_PATH)
+
+    increase_arg = 123
+    invoke(
+        calls=[(deploy_info["address"], "increase_balance", [increase_arg])],
+        account_address=PREDEPLOYED_ACCOUNT_ADDRESS,
+        private_key=PREDEPLOYED_ACCOUNT_PRIVATE_KEY,
+    )
+
+    # genesis block is the latest at this point, and it is not expected to contain events
+    assert get_events(to_block="latest")["result"]["events"] == []
+    assert get_events(to_block="1")["code"] == str(StarknetErrorCode.BLOCK_NOT_FOUND)
+
+    # the pending block should contain the event emitted by events contract
+    pending_resp = get_events(to_block="pending")
+    for event in pending_resp["result"]["events"]:
+        # check if the event was emitted by events contract
+        source_address = event["from_address"]
+        if int(deploy_info["address"], 16) == source_address:
+            assert increase_arg in event["data"]
+            break
+    else:
+        # if event_contract address not found
+        pytest.fail("Expected pending event not found")
+
+    _demand_block_creation()
+
+    # newly created block should contain the same events as the pending block before it
+    assert get_events(to_block="1") == pending_resp
+    assert get_events(to_block="latest") == pending_resp
+
+    # only one block should have been created
+    assert get_events(to_block="2")["code"] == str(StarknetErrorCode.BLOCK_NOT_FOUND)
+
+
+def _assert_correct_block_creation_response(resp: requests.Response):
+    assert resp.status_code == 200
+    resp_block_hash = resp.json()["block_hash"]
+    latest_block = get_block(block_number="latest", parse=True)
+    assert_hex_equal(resp_block_hash, latest_block["block_hash"])
+
+
+@devnet_in_background("--blocks-on-demand")
+def test_endpoint_if_no_pending():
+    """Test block creation if no pending txs with on-demand flag set on"""
+    resp = _demand_block_creation()
+    _assert_correct_block_creation_response(resp)
+
+
+@devnet_in_background("--blocks-on-demand")
+def test_endpoint_if_no_pending_after_creation():
+    """
+    Test block creation if no pending txs with on-demand flag set on
+    and after one block has already been created
+    """
+    deploy(CONTRACT_PATH, inputs=["10"])
+    resp = _demand_block_creation()
+    _assert_correct_block_creation_response(resp)
+
+    resp2 = _demand_block_creation()
+    _assert_correct_block_creation_response(resp2)
+
+
+@devnet_in_background("--blocks-on-demand")
+def test_endpoint_for_successive_requests():
+    """Send block creation request multiple times"""
+    for _ in range(3):
+        resp = _demand_block_creation()
+        _assert_correct_block_creation_response(resp)
+
+
+@devnet_in_background()
+def test_endpoint_without_on_demand_flag():
+    """Test block creation with on-demand flag set off"""
+    resp = _demand_block_creation()
+    _assert_correct_block_creation_response(resp)
+
+
+@devnet_in_background("--blocks-on-demand")
+def test_endpoint_if_some_pending():
+    """Test block creation with some pending txs"""
+    deploy(CONTRACT_PATH, inputs=["10"])
+    resp = _demand_block_creation()
+    _assert_correct_block_creation_response(resp)
diff --git a/test/test_endpoints.py b/test/test_endpoints.py
index 977236e8e..e863c1300 100644
--- a/test/test_endpoints.py
+++ b/test/test_endpoints.py
@@ -124,7 +124,7 @@ def test_call_without_calldata():
 
 @pytest.mark.call
 def test_call_with_complete_request_data():
-    """Call with complete request data"""
+    """Call with complete request data. Previous tests (deploy+invoke) required to pass."""
     req_dict = json.loads(CALL_CONTENT)
     resp = send_call(req_dict)
     resp_dict = json.loads(resp.data.decode("utf-8"))
diff --git a/test/test_fork.py b/test/test_fork.py
index 5a569474c..2e47c92a8 100644
--- a/test/test_fork.py
+++ b/test/test_fork.py
@@ -28,6 +28,7 @@
     TESTNET_URL,
 )
 from .util import (
+    DevnetBackgroundProc,
     assert_address_has_no_class_hash,
     assert_tx_status,
     call,
@@ -39,6 +40,18 @@
 ORIGIN_PORT, ORIGIN_URL = bind_free_port(HOST)
 FORK_PORT, FORK_URL = bind_free_port(HOST)
 
+FORKING_DEVNET = DevnetBackgroundProc()
+
+
+@pytest.fixture(autouse=True)
+def run_before_and_after_test():
+    """Cleanup after tests finish."""
+    # before test
+    FORKING_DEVNET.stop()
+    yield
+    # after test
+    FORKING_DEVNET.stop()
+
 
 def _invoke_on_fork_and_assert_only_fork_changed(
     contract_address: str,
@@ -51,11 +64,9 @@ def _invoke_on_fork_and_assert_only_fork_changed(
     origin_nonce_before = get_nonce(
         account_address=PREDEPLOYED_ACCOUNT_ADDRESS, feeder_gateway_url=origin_url
     )
-    assert origin_nonce_before == 0
     fork_nonce_before = get_nonce(
         account_address=PREDEPLOYED_ACCOUNT_ADDRESS, feeder_gateway_url=fork_url
     )
-    assert fork_nonce_before == 0
 
     # do the invoke and implicitly estimate fee before that
     increase_args = [1, 2]
@@ -90,44 +101,34 @@ def _invoke_on_fork_and_assert_only_fork_changed(
     origin_nonce_after = get_nonce(
         account_address=PREDEPLOYED_ACCOUNT_ADDRESS, feeder_gateway_url=origin_url
     )
-    assert origin_nonce_after == 0
+    assert origin_nonce_after == origin_nonce_before
     fork_nonce_after = get_nonce(
         account_address=PREDEPLOYED_ACCOUNT_ADDRESS, feeder_gateway_url=fork_url
     )
-    assert fork_nonce_after == 1
+    assert fork_nonce_after == fork_nonce_before + 1
 
 
-def _deploy_on_origin_invoke_on_fork_assert_only_fork_changed(
-    fork_url: str,
-    origin_url: str,
-    initial_balance="10",
-):
+@devnet_in_background("--port", ORIGIN_PORT, *PREDEPLOY_ACCOUNT_CLI_ARGS)
+def test_forking_devnet_with_account_on_origin():
+    """
+    Deploy contract on origin, invoke on fork, rely on account on origin.
+    Assert only fork changed
+    """
 
+    # deploy on origin
+    initial_balance = "10"
     deploy_info = deploy(
         contract=CONTRACT_PATH,
         inputs=[initial_balance],
-        gateway_url=origin_url,
+        gateway_url=ORIGIN_URL,
     )
 
-    _invoke_on_fork_and_assert_only_fork_changed(
-        contract_address=deploy_info["address"],
-        initial_balance=initial_balance,
-        fork_url=fork_url,
-        origin_url=origin_url,
+    # fork
+    FORKING_DEVNET.start(
+        "--port", FORK_PORT, "--fork-network", ORIGIN_URL, "--accounts", "0"
     )
 
-
-@devnet_in_background("--port", ORIGIN_PORT, *PREDEPLOY_ACCOUNT_CLI_ARGS)
-@devnet_in_background(
-    "--port", FORK_PORT, "--fork-network", ORIGIN_URL, "--accounts", "0"
-)
-def test_forking_devnet_with_account_on_origin():
-    """
-    Deploy contract on origin, invoke on fork, rely on account on origin.
-    Assert only fork changed
-    """
-
-    # account balance
+    # account balance - before
     origin_balance_before = get_account_balance(
         address=PREDEPLOYED_ACCOUNT_ADDRESS, server_url=ORIGIN_URL
     )
@@ -140,13 +141,14 @@ def test_forking_devnet_with_account_on_origin():
     )
     assert fork_balance_before == DEFAULT_INITIAL_BALANCE
 
-    # with goerli, forking would be done here, but having it done beforehand is ok with devnet
-    _deploy_on_origin_invoke_on_fork_assert_only_fork_changed(
+    _invoke_on_fork_and_assert_only_fork_changed(
+        contract_address=deploy_info["address"],
+        initial_balance=initial_balance,
         fork_url=FORK_URL,
         origin_url=ORIGIN_URL,
     )
 
-    # account balance
+    # account balance - after
     origin_balance_after = get_account_balance(
         address=PREDEPLOYED_ACCOUNT_ADDRESS, server_url=ORIGIN_URL
     )
@@ -159,16 +161,26 @@ def test_forking_devnet_with_account_on_origin():
 
 
 @devnet_in_background("--port", ORIGIN_PORT, "--accounts", "0")
-@devnet_in_background(
-    "--port", FORK_PORT, "--fork-network", ORIGIN_URL, *PREDEPLOY_ACCOUNT_CLI_ARGS
-)
 def test_forking_devnet_with_account_on_fork():
     """
     Deploy contract on origin, invoke on fork, rely on account on fork.
     Assert only fork changed
     """
 
-    # account balance
+    # deploy on origin
+    initial_balance = "10"
+    deploy_info = deploy(
+        contract=CONTRACT_PATH,
+        inputs=[initial_balance],
+        gateway_url=ORIGIN_URL,
+    )
+
+    # fork
+    FORKING_DEVNET.start(
+        "--port", FORK_PORT, "--fork-network", ORIGIN_URL, *PREDEPLOY_ACCOUNT_CLI_ARGS
+    )
+
+    # account balance - before
     origin_balance_before = get_account_balance(
         address=PREDEPLOYED_ACCOUNT_ADDRESS, server_url=ORIGIN_URL
     )
@@ -179,13 +191,14 @@ def test_forking_devnet_with_account_on_fork():
     )
     assert fork_balance_before == DEFAULT_INITIAL_BALANCE
 
-    # with goerli, forking would be done here, but having it done beforehand is ok with devnet
-    _deploy_on_origin_invoke_on_fork_assert_only_fork_changed(
+    _invoke_on_fork_and_assert_only_fork_changed(
+        contract_address=deploy_info["address"],
+        initial_balance=initial_balance,
         fork_url=FORK_URL,
         origin_url=ORIGIN_URL,
     )
 
-    # account balance
+    # account balance - after
     origin_balance_after = get_account_balance(
         address=PREDEPLOYED_ACCOUNT_ADDRESS, server_url=ORIGIN_URL
     )
diff --git a/test/util.py b/test/util.py
index 46c51e583..8b64e8765 100644
--- a/test/util.py
+++ b/test/util.py
@@ -310,6 +310,7 @@ def estimate_fee(
     abi_path,
     signature=None,
     nonce=None,
+    block_number=None,
     feeder_gateway_url=APP_URL,
 ):
     """Wrapper around starknet estimate_fee. Returns fee in wei."""
@@ -332,6 +333,9 @@ def estimate_fee(
     if nonce is not None:
         args.extend(["--nonce", str(nonce)])
 
+    if block_number is not None:
+        args.extend(["--block_number", str(block_number)])
+
     output = run_starknet(args, gateway_url=feeder_gateway_url)
 
     print("Estimate fee successful!")
@@ -339,7 +343,12 @@ def estimate_fee(
 
 
 def call(
-    function: str, address: str, abi_path: str, inputs=None, feeder_gateway_url=APP_URL
+    function: str,
+    address: str,
+    abi_path: str,
+    inputs=None,
+    block_number="latest",  # Starknet CLI defaults to pending
+    feeder_gateway_url=APP_URL,
 ):
     """Wrapper around starknet call"""
     args = [
@@ -350,6 +359,8 @@ def call(
         address,
         "--abi",
         abi_path,
+        "--block_number",
+        block_number,
     ]
     if inputs:
         args.extend(["--inputs", *inputs])