Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Fix simulation gas issue#67

Open
xmariachi wants to merge 1 commit into
devfrom
diego/fix-simulation-gas-issue
Open

Fix simulation gas issue#67
xmariachi wants to merge 1 commit into
devfrom
diego/fix-simulation-gas-issue

Conversation

@xmariachi

@xmariachi xmariachi commented May 17, 2026

Copy link
Copy Markdown
Contributor

Clement Issue 3: TX Simulation Catch-All Hides Guaranteed Failures

Verdict

Clement's issue 3 is still valid in the current tree.

The current TxManager.submit_transaction() treats every simulation-stage exception as if it were merely a gas-estimation failure. That is too broad. Some simulation failures mean "we could not estimate gas", but others mean "the transaction message is invalid and should not be broadcast." The current code does not distinguish those cases.

There is also a second, smaller bug in the same block: fee preview is wrapped in the same try as simulation, so a fee-preview failure can discard a successful gas simulation and force a fallback to default gas.

Current Problem Code

Current code in src/allora_sdk/rpc_client/tx_manager.py:

async def submit_transaction(
    self,
    type_url: str,
    msgs: list[Any],
    fee_tier: FeeTier = FeeTier.STANDARD,
    max_retries: int = 2,
    timeout: Optional[timedelta] = None,
    account_seq: Optional[int] = None,
) -> "PendingTx":
    if self.wallet is None:
        raise Exception('No wallet configured. Initialize client with private key or mnemonic.')

    estimated_gas_limit: Optional[int] = None
    try:
        estimated_gas_limit = await self.simulate_transaction(type_url, msgs)
        logger.debug(f"Simulated gas requirement for {type_url}: {estimated_gas_limit}")
        fee_preview = await self._calculate_optimal_fee(
            estimated_gas_limit,
            self._fee_multipliers[fee_tier],
        )
        logger.debug(f"Estimated fee for {type_url}: {fee_preview.amount} {fee_preview.denom}")
    except Exception as e:
        logger.debug(f"Unable to simulate transaction for gas estimate, falling back to defaults: {e}")
        estimated_gas_limit = None

    async with self._parent_tx_id_lock:
        next_parent_tx_id = self.parent_tx_id
        self.parent_tx_id += 1

    pending = PendingTx(
        manager=self,
        parent_tx_id=next_parent_tx_id,
        type_url=type_url,
        msgs=msgs,
        fee_tier=fee_tier,
        max_retries=max_retries,
        timeout=timeout,
    )

    asyncio.create_task(
        self._attempt_submissions(pending, estimated_gas_limit, account_seq=account_seq)
    )

    return pending

The problematic part is:

except Exception as e:
    logger.debug(f"Unable to simulate transaction for gas estimate, falling back to defaults: {e}")
    estimated_gas_limit = None

This catches all of the following as equivalent:

  • Deterministic chain rejection, represented as TxError.
  • Sequence errors, represented as AccountSequenceMismatchError.
  • Simulation out-of-gas after retry exhaustion, represented as OutOfGasError.
  • Fee errors, represented as InsufficientFeesError.
  • Real simulation infrastructure failures, such as a transient RPC problem.
  • Fee-preview failures from _calculate_optimal_fee(), even after simulation succeeded.

Those cases should not have the same behavior.

Why This Is A Real Bug

simulate_transaction() already classifies errors into meaningful exception types:

def _exception_from_simulation_error(self, error: grpc.RpcError) -> Exception:
    error_details = error.details() if hasattr(error, 'details') else str(error)
    error_msg = error_details or str(error)
    error_class = self._classify_error_from_message(error_msg)

    if error_class == OutOfGasError:
        return OutOfGasError(f"Simulation ran out of gas: {error_msg}")
    elif error_class == AccountSequenceMismatchError:
        return AccountSequenceMismatchError(f"Sequence mismatch during simulation: {error_msg}")
    elif error_class == InsufficientFeesError:
        return InsufficientFeesError(f"Insufficient fees during simulation: {error_msg}")
    else:
        code = error.code() if hasattr(error, 'code') else None
        try:
            code_value = code.value[0] if isinstance(code.value, tuple) else int(code.value)
        except (TypeError, IndexError, AttributeError):
            code_value = 1

        return TxError(
            codespace="simulation",
            code=code_value,
            message=error_msg,
            tx_hash=None
        )

But submit_transaction() immediately flattens those classified errors back into estimated_gas_limit = None.

That defeats the purpose of simulation classification. A simulation TxError such as "unauthorized", "already submitted", "account not found", or a malformed message is not a gas estimation problem. Broadcasting after that is at best noisy and at worst fee-wasting.

Current Test Codifies The Bad Behavior

The current test suite explicitly asserts the problematic fallback:

@pytest.mark.asyncio
async def test_submit_transaction_falls_back_when_simulation_raises_tx_error(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    manager = _make_manager()
    manager.simulate_transaction = AsyncMock(
        side_effect=TxError(codespace="emissions", code=13, message="unauthorized")
    )
    manager._attempt_submissions = AsyncMock(return_value=None)

    # ...

    pending = await manager.submit_transaction(
        type_url="/emissions.v9.InsertWorkerPayloadRequest",
        msgs=[Mock()],
        fee_tier=FeeTier.STANDARD,
    )

    # Current behavior: simulation errors are swallowed and gas falls back to defaults.
    assert pending.type_url == "/emissions.v9.InsertWorkerPayloadRequest"
    manager._attempt_submissions.assert_called_once()
    args, kwargs = manager._attempt_submissions.call_args
    assert args[1] is None  # gas_limit
    assert kwargs["account_seq"] is None

That test should be inverted. A deterministic simulation TxError should stop submission before _attempt_submissions() is scheduled.

Concrete Failure Mode

Example flow with current code:

  1. Reputer/inferer calls an emissions transaction method.
  2. TxManager.submit_transaction() calls simulate_transaction().
  3. The chain simulation rejects the message as unauthorized.
  4. simulate_transaction() raises TxError(codespace="simulation", code=..., message="unauthorized").
  5. submit_transaction() catches it with except Exception.
  6. The SDK logs only at debug level.
  7. The SDK creates a PendingTx.
  8. The SDK schedules _attempt_submissions(..., gas_limit=None).
  9. _build_and_broadcast() falls back to _estimate_gas(type_url).
  10. The SDK broadcasts a transaction that simulation had already rejected.

The user now sees a later broadcast-time or inclusion-time failure, not the original simulation failure. In production, this makes debugging harder and can waste fees.

Extra Problem: Fee Preview Can Discard A Good Simulation

This block also wraps _calculate_optimal_fee():

estimated_gas_limit = await self.simulate_transaction(type_url, msgs)
logger.debug(f"Simulated gas requirement for {type_url}: {estimated_gas_limit}")
fee_preview = await self._calculate_optimal_fee(
    estimated_gas_limit,
    self._fee_multipliers[fee_tier],
)
logger.debug(f"Estimated fee for {type_url}: {fee_preview.amount} {fee_preview.denom}")

If simulation succeeds but fee preview raises, the catch-all sets:

estimated_gas_limit = None

That throws away a valid simulated gas estimate. Fee preview is only used for logging. It should not be able to force a default-gas fallback after simulation succeeded.

Integration Perspective

This differs from allora-offchain-node semantics.

In allora-offchain-node, transaction construction calls simulation during BuildAndSignTransaction(). If simulation fails, transaction construction returns an error. Retry handling then happens in the transaction send path, where sequence, fees, and gas are handled as classified cases.

The current SDK behavior is weaker:

  • It collapses simulation rejection into "no gas estimate".
  • It proceeds to broadcast after deterministic simulation errors.
  • It logs the original simulation failure at debug level only.
  • It lets a fee-preview/logging failure destroy a successful gas estimate.

For a tool intended to replace allora-offchain-node, this should be tightened.

Suggested Fix

Design Goal

Do not treat every simulation failure as recoverable.

Recommended behavior:

  • If simulation returns a deterministic TxError, fail fast and do not broadcast.
  • If simulation exhausts out-of-gas retries, fail fast. Falling back to the default gas limit is likely worse than the already-failed larger simulation limit.
  • If simulation hits account-sequence mismatch, fail fast or retry simulation with fresh account info. Do not blindly broadcast.
  • If simulation hits a fee-specific artifact that is known to be caused by simulation fee settings, either retry simulation with a realistic fee or fall back with a warning.
  • If simulation infrastructure is unavailable, fallback can be acceptable, but it should be logged at warning level.
  • Fee-preview failure should never erase a successful simulation result.

Minimal Code Change

This is the smallest useful change:

estimated_gas_limit: Optional[int] = None

try:
    estimated_gas_limit = await self.simulate_transaction(type_url, msgs)
except TxError:
    logger.error(
        "Transaction simulation rejected %s; not broadcasting",
        type_url,
        exc_info=True,
    )
    raise
except (OutOfGasError, AccountSequenceMismatchError) as err:
    logger.error(
        "Transaction simulation failed for %s; not broadcasting: %s",
        type_url,
        err,
        exc_info=True,
    )
    raise
except InsufficientFeesError as err:
    logger.warning(
        "Transaction simulation failed fee check for %s; falling back to default gas: %s",
        type_url,
        err,
        exc_info=True,
    )
    estimated_gas_limit = None
except Exception as err:
    logger.warning(
        "Unable to simulate transaction for %s; falling back to default gas: %s",
        type_url,
        err,
        exc_info=True,
    )
    estimated_gas_limit = None

if estimated_gas_limit is not None:
    logger.debug("Simulated gas requirement for %s: %s", type_url, estimated_gas_limit)
    try:
        fee_preview = await self._calculate_optimal_fee(
            estimated_gas_limit,
            self._fee_multipliers[fee_tier],
        )
        logger.debug(
            "Estimated fee for %s: %s %s",
            type_url,
            fee_preview.amount,
            fee_preview.denom,
        )
    except Exception as err:
        logger.debug("Unable to preview fee for %s: %s", type_url, err)

This preserves fallback for truly unclassified simulation infrastructure failures, but prevents known deterministic transaction failures from being broadcast.

Better Long-Term Fix

The cleaner version is to introduce a dedicated exception for simulation availability failures and keep classified chain rejections separate:

class GasSimulationUnavailableError(Exception):
    """Raised when gas simulation could not run due to transport or service availability."""

Then simulate_transaction() should only raise GasSimulationUnavailableError for cases where the SDK genuinely could not ask the chain for an estimate. It should continue raising TxError, OutOfGasError, AccountSequenceMismatchError, and InsufficientFeesError for chain-evaluated transaction failures.

Then submit_transaction() becomes simpler:

try:
    estimated_gas_limit = await self.simulate_transaction(type_url, msgs)
except GasSimulationUnavailableError as err:
    logger.warning(
        "Gas simulation unavailable for %s; falling back to default gas: %s",
        type_url,
        err,
        exc_info=True,
    )
    estimated_gas_limit = None
except Exception:
    logger.error(
        "Transaction simulation failed for %s; not broadcasting",
        type_url,
        exc_info=True,
    )
    raise

That makes the policy explicit:

  • "Could not simulate" can fallback.
  • "Simulated and failed" does not broadcast.

Suggested Test Changes

Replace The Current TxError Fallback Test

Current test name:

test_submit_transaction_falls_back_when_simulation_raises_tx_error

Suggested replacement:

@pytest.mark.asyncio
async def test_submit_transaction_does_not_broadcast_when_simulation_rejects_tx(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    manager = _make_manager()
    manager.simulate_transaction = AsyncMock(
        side_effect=TxError(codespace="emissions", code=13, message="unauthorized")
    )
    manager._attempt_submissions = AsyncMock(return_value=None)

    with pytest.raises(TxError, match="unauthorized"):
        await manager.submit_transaction(
            type_url="/emissions.v9.InsertWorkerPayloadRequest",
            msgs=[Mock()],
            fee_tier=FeeTier.STANDARD,
        )

    manager._attempt_submissions.assert_not_called()

Add A Test For Fee Preview Failure

This captures the second bug:

@pytest.mark.asyncio
async def test_submit_transaction_keeps_simulated_gas_when_fee_preview_fails(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    manager = _make_manager()
    manager.simulate_transaction = AsyncMock(return_value=345678)
    manager._calculate_optimal_fee = AsyncMock(side_effect=RuntimeError("fee preview failed"))
    manager._attempt_submissions = AsyncMock(return_value=None)

    scheduled: list[asyncio.Task] = []

    def _capture_task(coro):
        task = asyncio.get_running_loop().create_task(coro)
        scheduled.append(task)
        return task

    monkeypatch.setattr(asyncio, "create_task", _capture_task)

    await manager.submit_transaction(
        type_url="/emissions.v9.InsertWorkerPayloadRequest",
        msgs=[Mock()],
        fee_tier=FeeTier.STANDARD,
    )

    manager._attempt_submissions.assert_called_once()
    args, kwargs = manager._attempt_submissions.call_args
    assert args[1] == 345678

    for task in scheduled:
        await task

Optional Fallback Test

If the SDK intentionally keeps fallback for simulation infrastructure outages, add a narrow test for only that class of error. If no dedicated exception exists yet, this test should wait until the exception is introduced; otherwise it risks keeping the current broad fallback alive.

Risk Of The Suggested Change

This is a behavior change. Some callers may currently rely on submit_transaction() returning a PendingTx even when simulation fails. After the minimal fix, deterministic simulation failures would raise before a PendingTx is created.

That behavior is still preferable for production transaction safety, but if API compatibility is important, there is an alternative:

  1. Create the PendingTx before simulation.
  2. If simulation returns deterministic failure, set pending._final_future.set_exception(err).
  3. Return the failed PendingTx without scheduling _attempt_submissions().

That preserves the "caller awaits pending.wait()" shape while still preventing broadcast.

Suggested compatibility variant:

pending = PendingTx(...)

try:
    estimated_gas_limit = await self.simulate_transaction(type_url, msgs)
except TxError as err:
    logger.error("Transaction simulation rejected %s; not broadcasting", type_url, exc_info=True)
    pending._final_future.set_exception(err)
    return pending

I would only choose this variant if existing SDK users strongly depend on submit_transaction() never raising simulation errors directly.

Final Recommendation

Fix this before positioning the SDK as the replacement runtime for production offchain-node users.

The minimum acceptable fix is:

  1. Stop swallowing TxError, OutOfGasError, and AccountSequenceMismatchError from simulation.
  2. Separate fee-preview logging from simulation so preview failures do not discard valid gas estimates.
  3. Change the current simulation test so deterministic simulation rejection prevents _attempt_submissions().
  4. Keep any fallback path narrow and explicit, ideally behind a dedicated "simulation unavailable" exception.

Summary by cubic

Hardened transaction simulation, gas, and fee handling to stop broadcasting guaranteed failures, keep valid gas estimates, and make fallbacks explicit. Also added first‑class worker modules (Inferer, Forecaster, Reputer), default loss methods, autostake, staking queries, examples, and better logging.

  • Bug Fixes

    • TxManager.submit_transaction: fail fast on TxError, OutOfGasError, AccountSequenceMismatchError, and InsufficientFeesError; fallback only when RPC is unavailable via new GasSimulationUnavailableError; keep simulated gas when fee preview fails.
    • Simulation now accepts and uses account_seq so the same sequence is used for simulation and broadcast; keeps parent tx ID safety and serialized submissions.
    • Error classification: map gRPC UNAVAILABLE/DEADLINE_EXCEEDED to GasSimulationUnavailableError; raise WalletNotConfiguredError instead of a generic exception.
    • Forecaster submissions: allow forecast‑only payloads and require at least one of inference or forecast.
    • REST template: pass x-cosmos-block-height, ignore unknown fields, and avoid per‑request session churn.
    • Cache effective dynamic gas price with multiplier.
  • Migration

    • Deterministic simulation failures now raise (including fee errors); callers should handle exceptions instead of expecting a PendingTx on guaranteed failures. Fallback to default gas happens only when simulation is unavailable.
    • Gas simulation is required for all transactions; fee‑preview failures no longer discard simulated gas.
    • Worker callbacks now receive RunContext; update run functions to async def run(ctx: RunContext) -> .... New helpers: make_reputer_function, get_network_inference, get_block_time.
    • Reputer loss safety: non‑finite inputs raise; ztae/zptae require explicit loss configuration. Default loss selection available via allora_sdk.loss_methods.
    • Dependencies: add runtime grpcio and protobuf. AlloraNetworkConfig adds dynamic_gas_price_default_multiplier and query_timeout_secs; testnet faucet URL updated.

Written for commit 7b8f197. Summary will update on new commits. Review in cubic

@xmariachi xmariachi changed the title Diego/fix simulation gas issue Fix simulation gas issue May 17, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

29 issues found across 52 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/allora_sdk/logging_config.py">

<violation number="1" location="src/allora_sdk/logging_config.py:76">
P2: `basicConfig(..., force=force)` becomes a no-op when the host app already set up logging, so the SDK can mark itself configured without ever installing the new formatter/handler.</violation>
</file>

<file name="src/allora_sdk/loss_methods/binary_cross_entropy.py">

<violation number="1" location="src/allora_sdk/loss_methods/binary_cross_entropy.py:38">
P2: Reject non-finite inputs explicitly; `NaN` currently bypasses the range checks and propagates a silent `nan` loss.</violation>

<violation number="2" location="src/allora_sdk/loss_methods/binary_cross_entropy.py:43">
P2: Validate `epsilon` before clipping; values outside `(0, 0.5)` can make the loss ignore `predicted` entirely or fail with a math-domain error.</violation>
</file>

<file name="src/allora_sdk/loss_methods/ztae.py">

<violation number="1" location="src/allora_sdk/loss_methods/ztae.py:61">
P2: Reject non-finite `std` values here; `nan`/`inf` currently pass validation and make the loss return invalid results.</violation>
</file>

<file name="src/allora_sdk/rpc_client/tx_manager.py">

<violation number="1" location="src/allora_sdk/rpc_client/tx_manager.py:194">
P1: `InsufficientFeesError` is still treated as a fatal simulation failure, so nodes that enforce min fees during simulation can block otherwise valid broadcasts.</violation>

<violation number="2" location="src/allora_sdk/rpc_client/tx_manager.py:317">
P2: The simulation fallback is incomplete: non-`grpc.RpcError` failures still escape and abort submission instead of using the default gas path.</violation>
</file>

<file name="scripts/templates/rest_client.py.jinja">

<violation number="1" location="scripts/templates/rest_client.py.jinja:46">
P1: Convert `height` to a string before sending it as an HTTP header; passing the raw `int` here can make every `height=` query fail before the request is sent.</violation>
</file>

<file name="src/allora_sdk/worker/inferer.py">

<violation number="1" location="src/allora_sdk/worker/inferer.py:127">
P2: This registration call ignores the configured `fee_tier` and always pays `PRIORITY` fees.</violation>
</file>

<file name="src/allora_sdk/worker/__init__.py">

<violation number="1" location="src/allora_sdk/worker/__init__.py:9">
P2: `PredictFnResultType` was dropped from the module’s public exports, introducing a backward-incompatible API break for existing callers.</violation>
</file>

<file name="README.md">

<violation number="1" location="README.md:130">
P2: The advanced configuration example references an undefined callback (`my_model`) after the quick-start example was renamed to `run_model`.</violation>
</file>

<file name="example_forecaster.py">

<violation number="1" location="example_forecaster.py:11">
P2: Guard the previous-epoch nonce from going negative before querying network inference.</violation>
</file>

<file name="tests/test_worker_review_fixes.py">

<violation number="1" location="tests/test_worker_review_fixes.py:68">
P2: Assert that the API key header is omitted when `api_key=None`; checking for the string "None" makes this test validate the wrong behavior.</violation>
</file>

<file name="src/allora_sdk/loss_methods/huber.py">

<violation number="1" location="src/allora_sdk/loss_methods/huber.py:41">
P2: Reject `NaN` for `delta` explicitly; it bypasses this guard and turns every Huber loss result into `nan`.</violation>
</file>

<file name="src/allora_sdk/utils/format.py">

<violation number="1" location="src/allora_sdk/utils/format.py:7">
P2: Renaming this exported helper without a compatibility alias introduces a breaking API change for existing SDK users.</violation>
</file>

<file name="src/allora_sdk/rpc_client/config.py">

<violation number="1" location="src/allora_sdk/rpc_client/config.py:126">
P2: This new local gRPC default is shadowed by `AlloraRPCClient.local()`, which still passes `26657`, so the two local helpers now resolve to different ports.</violation>
</file>

<file name="src/allora_sdk/loss_methods/zptae.py">

<violation number="1" location="src/allora_sdk/loss_methods/zptae.py:99">
P2: Validate `mse_norm` and `gamma` here. Without those checks, valid caller input can make `mse_term` raise on exact matches or return a complex value instead of a float.</violation>
</file>

<file name="src/allora_sdk/rpc_client/client_staking.py">

<violation number="1" location="src/allora_sdk/rpc_client/client_staking.py:62">
P1: Keep `staking.query` as `None` when no gRPC staking client is available. Wrapping `None` in `StakingQueries` breaks the existing REST-mode feature check and makes validator autostake configuration fail instead of skipping validation.</violation>
</file>

<file name="src/allora_sdk/loss_methods/defaults.py">

<violation number="1" location="src/allora_sdk/loss_methods/defaults.py:25">
P2: This error message points users to a nonexistent `loss_fn` argument on `AlloraWorker.reputer()`; tell them to provide a custom `reputer_fn` instead.</violation>

<violation number="2" location="src/allora_sdk/loss_methods/defaults.py:37">
P1: Don't auto-register `ztae`/`zptae` as default loss methods; their `std=1.0` placeholders will silently compute the wrong loss for topics that require caller-supplied standardization.</violation>
</file>

<file name="src/allora_sdk/worker/reputer.py">

<violation number="1" location="src/allora_sdk/worker/reputer.py:121">
P2: Returning an empty set here removes the polling catch-up path, so reputer windows are missed whenever the open event was not observed live.</violation>

<violation number="2" location="src/allora_sdk/worker/reputer.py:194">
P1: Post-submit staking here can consume the next account sequence and break the following nonce submission in the same polling cycle.</violation>
</file>

<file name="src/allora_sdk/rpc_client/client_websocket_events.py">

<violation number="1" location="src/allora_sdk/rpc_client/client_websocket_events.py:169">
P2: The new empty `TBetterproto2Message` bound removes the protobuf-type constraint from the typed subscription API, so unrelated classes can now slip through the name-based event lookup.</violation>
</file>

<file name="src/allora_sdk/worker/worker.py">

<violation number="1" location="src/allora_sdk/worker/worker.py:760">
P1: Don't advance `account_seq` by loop index here; some submit paths return before broadcasting, so later nonces can use a future sequence and fail with account-sequence mismatch.</violation>
</file>

<file name="src/allora_sdk/worker/autostake.py">

<violation number="1" location="src/allora_sdk/worker/autostake.py:315">
P2: The balance precheck ignores `fee_reserve_uallo`, so autostake can consume the configured fee reserve and still leave the wallet without gas.</violation>
</file>

<file name="src/allora_sdk/worker/utils.py">

<violation number="1" location="src/allora_sdk/worker/utils.py:22">
P1: Return `wallet.wallet` here; otherwise `AlloraWalletConfig(wallet=...)` is ignored and the worker may use a different account.</violation>

<violation number="2" location="src/allora_sdk/worker/utils.py:29">
P1: Fail when an explicit `wallet.mnemonic_file` is missing; silently generating a new wallet here can switch the worker to an unintended account.</violation>

<violation number="3" location="src/allora_sdk/worker/utils.py:127">
P2: Include the client/network in this cache key; topic ID and nonce alone are not enough for a `gt_fn` that depends on `RunContext.client`.</violation>
</file>

<file name="src/allora_sdk/rpc_client/client_emissions.py">

<violation number="1" location="src/allora_sdk/rpc_client/client_emissions.py:239">
P2: Add default gas fallbacks for the new tx types. Without entries in `TxManager._default_gas_limits`, simulation-unavailable paths drop to the generic 240k estimate for `AddStakeRequest` and `InsertReputerPayloadRequest`.</violation>

<violation number="2" location="src/allora_sdk/rpc_client/client_emissions.py:272">
P2: Populate/validate the signed bundle's `topic_id` and `reputer` before hashing. Right now this helper signs whatever is already inside `value_bundle`, so a stale bundle can be submitted for the wrong topic or actor.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Client Code (Inferer/Forecaster)
    participant Worker as AlloraWorker
    participant TxMgr as TxManager.submit_transaction()
    participant Sim as simulate_transaction()
    participant Fee as _calculate_optimal_fee()
    participant RPC as gRPC / REST (Chain)
    participant Broadcast as _attempt_submissions()
    
    Note over Client,Broadcast: Transaction Submission Flow
    
    Client->>Worker: submit_transaction() call
    Worker->>TxMgr: submit_transaction(type_url, msgs, account_seq)
    
    TxMgr->>Sim: simulate_transaction(type_url, msgs, account_seq)
    Sim->>RPC: SimulateRequest (tx_bytes)
    
    alt Simulation succeeds
        RPC-->>Sim: GasInfo (gas_used)
        Sim-->>TxMgr: estimated_gas (int)
        TxMgr->>Fee: _calculate_optimal_fee(gas, multiplier)
        Fee-->>TxMgr: fee_preview (for logging only)
        
        alt Fee preview fails
            TxMgr->>TxMgr: Log debug, keep estimated_gas
        end
        
        TxMgr->>Broadcast: _attempt_submissions(pending, gas_limit=estimated_gas, account_seq)
        
    else Deterministic rejection (TxError, OutOfGasError, AccountSequenceMismatchError)
        RPC-->>Sim: grpc.RpcError (classified)
        Sim-->>TxMgr: Raise classified error
        
        Note over TxMgr: NEW: Fail-fast, do not broadcast
        TxMgr-->>Worker: Raise exception (TxError / OutOfGasError / AccountSequenceMismatchError)
        
    else Simulation infrastructure unavailable (GasSimulationUnavailableError)
        RPC-->>Sim: grpc.RpcError (UNAVAILABLE)
        Sim-->>TxMgr: Raise GasSimulationUnavailableError
        
        Note over TxMgr: NEW: Only fallback for infrastructure issues
        TxMgr->>Broadcast: _attempt_submissions(pending, gas_limit=None, account_seq)
        
    else Fee-specific simulation failure (InsufficientFeesError)
        RPC-->>Sim: grpc.RpcError (classified)
        Sim-->>TxMgr: Raise InsufficientFeesError
        
        Note over TxMgr: NEW: Fallback with warning for fee issues
        TxMgr->>TxMgr: Log warning
        TxMgr->>Broadcast: _attempt_submissions(pending, gas_limit=None, account_seq)
    end
    
    Broadcast->>Broadcast: Build & sign tx with estimated_gas or default
    Broadcast->>RPC: BroadcastTx (signed)
    RPC-->>Broadcast: TxResponse
    Broadcast-->>Worker: PendingTx result
    
    Note over Client,Broadcast: Key Change: Deterministic simulation failures now raise before broadcast
Loading

Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic

Comment on lines +194 to +196
except (TxError, OutOfGasError, AccountSequenceMismatchError, InsufficientFeesError):
logger.error(f"Transaction simulation failed for {type_url}; not broadcasting", exc_info=True)
raise

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: InsufficientFeesError is still treated as a fatal simulation failure, so nodes that enforce min fees during simulation can block otherwise valid broadcasts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/rpc_client/tx_manager.py, line 194:

<comment>`InsufficientFeesError` is still treated as a fatal simulation failure, so nodes that enforce min fees during simulation can block otherwise valid broadcasts.</comment>

<file context>
@@ -156,34 +171,67 @@ async def submit_transaction(
+                f"Unable to simulate transaction for {type_url}; "
+                f"falling back to default gas estimate: {err}"
+            )
+        except (TxError, OutOfGasError, AccountSequenceMismatchError, InsufficientFeesError):
+            logger.error(f"Transaction simulation failed for {type_url}; not broadcasting", exc_info=True)
+            raise
</file context>
Suggested change
except (TxError, OutOfGasError, AccountSequenceMismatchError, InsufficientFeesError):
logger.error(f"Transaction simulation failed for {type_url}; not broadcasting", exc_info=True)
raise
except InsufficientFeesError as err:
logger.warning(
f"Simulation reported insufficient fees for {type_url}; "
f"falling back to default gas estimate: {err}"
)
except (TxError, OutOfGasError, AccountSequenceMismatchError):
logger.error(f"Transaction simulation failed for {type_url}; not broadcasting", exc_info=True)
raise
Fix with Cubic

url = self.base_url + f"{{ method.url_path }}"
headers = {"x-cosmos-block-height": height} if height else None
response = await self.session.get(url, params=params)
response = await self.session.get(url, params=params, headers=headers)

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Convert height to a string before sending it as an HTTP header; passing the raw int here can make every height= query fail before the request is sent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/templates/rest_client.py.jinja, line 46:

<comment>Convert `height` to a string before sending it as an HTTP header; passing the raw `int` here can make every `height=` query fail before the request is sent.</comment>

<file context>
@@ -47,9 +43,9 @@ class {{ service.class_name }}({{ service.protocol_name }}):
         url = self.base_url + f"{{ method.url_path }}"
         headers = {"x-cosmos-block-height": height} if height else None
-        response = await self.session.get(url, params=params)
+        response = await self.session.get(url, params=params, headers=headers)
         response.raise_for_status()
-        return {{ method.response_type_name }}().from_json(response.text)
</file context>
Suggested change
response = await self.session.get(url, params=params, headers=headers)
if headers is not None:
headers["x-cosmos-block-height"] = str(headers["x-cosmos-block-height"])
response = await self.session.get(url, params=params, headers=headers)
Fix with Cubic

"""

def __init__(self, query_client: StakingQueryClientLike | None = None, tx_manager: TxManager | None = None):
self.query = StakingQueries(query_client)

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Keep staking.query as None when no gRPC staking client is available. Wrapping None in StakingQueries breaks the existing REST-mode feature check and makes validator autostake configuration fail instead of skipping validation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/rpc_client/client_staking.py, line 62:

<comment>Keep `staking.query` as `None` when no gRPC staking client is available. Wrapping `None` in `StakingQueries` breaks the existing REST-mode feature check and makes validator autostake configuration fail instead of skipping validation.</comment>

<file context>
@@ -0,0 +1,112 @@
+    """
+
+    def __init__(self, query_client: StakingQueryClientLike | None = None, tx_manager: TxManager | None = None):
+        self.query = StakingQueries(query_client)
+        self.tx = StakingTxs(txs=tx_manager)
+
</file context>
Suggested change
self.query = StakingQueries(query_client)
self.query = StakingQueries(query_client) if query_client is not None else None
Fix with Cubic

"logcosh": log_cosh_loss,
"bce": binary_cross_entropy_loss,
"poisson": poisson_loss,
"ztae": ztae_loss,

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Don't auto-register ztae/zptae as default loss methods; their std=1.0 placeholders will silently compute the wrong loss for topics that require caller-supplied standardization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/loss_methods/defaults.py, line 37:

<comment>Don't auto-register `ztae`/`zptae` as default loss methods; their `std=1.0` placeholders will silently compute the wrong loss for topics that require caller-supplied standardization.</comment>

<file context>
@@ -0,0 +1,144 @@
+    "logcosh": log_cosh_loss,
+    "bce": binary_cross_entropy_loss,
+    "poisson": poisson_loss,
+    "ztae": ztae_loss,
+    "zptae": zptae_loss,
+}
</file context>
Fix with Cubic

logger.error(f"❌ Unexpected error submitting reputer payload: {err.__class__.__name__} {err}")
return err

# Optional stake top-up after successful payload; runs with fresh context

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Post-submit staking here can consume the next account sequence and break the following nonce submission in the same polling cycle.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/worker/reputer.py, line 194:

<comment>Post-submit staking here can consume the next account sequence and break the following nonce submission in the same polling cycle.</comment>

<file context>
@@ -0,0 +1,373 @@
+            logger.error(f"❌ Unexpected error submitting reputer payload: {err.__class__.__name__} {err}")
+            return err
+
+        # Optional stake top-up after successful payload; runs with fresh context
+        # so it does not consume sequence before core submission.
+        try:
</file context>
Fix with Cubic



GenericSyncCallbackFn = Callable[[Dict[str, Any], int], None]
TEvent = TypeVar("TEvent", bound="TBetterproto2Message")

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new empty TBetterproto2Message bound removes the protobuf-type constraint from the typed subscription API, so unrelated classes can now slip through the name-based event lookup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/rpc_client/client_websocket_events.py, line 169:

<comment>The new empty `TBetterproto2Message` bound removes the protobuf-type constraint from the typed subscription API, so unrelated classes can now slip through the name-based event lookup.</comment>

<file context>
@@ -167,13 +166,13 @@ def transactions():
 
 
-GenericSyncCallbackFn  = Callable[[Dict[str, Any], int], None]
+TEvent = TypeVar("TEvent", bound="TBetterproto2Message")
+GenericSyncCallbackFn = Callable[[Dict[str, Any], int], None]
 GenericAsyncCallbackFn = Callable[[Dict[str, Any], int], Awaitable[None]]
</file context>
Suggested change
TEvent = TypeVar("TEvent", bound="TBetterproto2Message")
TEvent = TypeVar("TEvent", bound=betterproto2.Message)
Fix with Cubic

QueryBalanceRequest(address=wallet_addr, denom=client.network.fee_denom)
)
available = int(bal_resp.balance.amount) if bal_resp.balance else 0
if available < stake_amount_uallo:

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The balance precheck ignores fee_reserve_uallo, so autostake can consume the configured fee reserve and still leave the wallet without gas.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/worker/autostake.py, line 315:

<comment>The balance precheck ignores `fee_reserve_uallo`, so autostake can consume the configured fee reserve and still leave the wallet without gas.</comment>

<file context>
@@ -0,0 +1,386 @@
+            QueryBalanceRequest(address=wallet_addr, denom=client.network.fee_denom)
+        )
+        available = int(bal_resp.balance.amount) if bal_resp.balance else 0
+        if available < stake_amount_uallo:
+            logger.warning(
+                "[AUTO-STAKE] Insufficient balance: have %d uallo, need %d uallo. Skipping.",
</file context>
Fix with Cubic

async def rep_func(context: RunContext, inference: float) -> float:
nonlocal gt_cache

cache_key = (context.topic_id, context.nonce)

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Include the client/network in this cache key; topic ID and nonce alone are not enough for a gt_fn that depends on RunContext.client.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/worker/utils.py, line 127:

<comment>Include the client/network in this cache key; topic ID and nonce alone are not enough for a `gt_fn` that depends on `RunContext.client`.</comment>

<file context>
@@ -0,0 +1,143 @@
+    async def rep_func(context: RunContext, inference: float) -> float:
+        nonlocal gt_cache
+
+        cache_key = (context.topic_id, context.nonce)
+        if gt_cache is not None and gt_cache[0] == cache_key:
+            gt = gt_cache[1]
</file context>
Fix with Cubic

@@ -1,7 +1,9 @@
import hashlib

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Add default gas fallbacks for the new tx types. Without entries in TxManager._default_gas_limits, simulation-unavailable paths drop to the generic 240k estimate for AddStakeRequest and InsertReputerPayloadRequest.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/rpc_client/client_emissions.py, line 239:

<comment>Add default gas fallbacks for the new tx types. Without entries in `TxManager._default_gas_limits`, simulation-unavailable paths drop to the generic 240k estimate for `AddStakeRequest` and `InsertReputerPayloadRequest`.</comment>

<file context>
@@ -193,29 +178,123 @@ async def delegate_stake(
+        logger.debug(f"Adding stake of {amount} uallo to topic {topic_id}")
+
+        return await self._txs.submit_transaction(
+            type_url="/emissions.v9.AddStakeRequest",
+            msgs=[msg],
+            fee_tier=fee_tier,
</file context>
Fix with Cubic


reputer_address = str(self._txs.wallet.address())

value_bundle.reputer_request_nonce = reputer_request_nonce

@cubic-dev-ai cubic-dev-ai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Populate/validate the signed bundle's topic_id and reputer before hashing. Right now this helper signs whatever is already inside value_bundle, so a stale bundle can be submitted for the wrong topic or actor.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/allora_sdk/rpc_client/client_emissions.py, line 272:

<comment>Populate/validate the signed bundle's `topic_id` and `reputer` before hashing. Right now this helper signs whatever is already inside `value_bundle`, so a stale bundle can be submitted for the wrong topic or actor.</comment>

<file context>
@@ -193,29 +178,123 @@ async def delegate_stake(
+
+        reputer_address = str(self._txs.wallet.address())
+
+        value_bundle.reputer_request_nonce = reputer_request_nonce
+
+        # Sign the value bundle
</file context>
Suggested change
value_bundle.reputer_request_nonce = reputer_request_nonce
if value_bundle.topic_id and value_bundle.topic_id != topic_id:
raise ValueError(f"value_bundle.topic_id ({value_bundle.topic_id}) must match topic_id ({topic_id})")
value_bundle.topic_id = topic_id
value_bundle.reputer = reputer_address
value_bundle.reputer_request_nonce = reputer_request_nonce
Fix with Cubic

@fstecker fstecker force-pushed the diego/fix-simulation-gas-issue branch from 51afeb1 to 7b8f197 Compare May 17, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant