Skip to content

fix: improve event trigger simulation accuracy and response schema#507

Merged
chrisli30 merged 2 commits into
stagingfrom
will-address_simulate_issue
Apr 8, 2026
Merged

fix: improve event trigger simulation accuracy and response schema#507
chrisli30 merged 2 commits into
stagingfrom
will-address_simulate_issue

Conversation

@chrisli30

Copy link
Copy Markdown
Member

Summary

Fixes a chain of issues that surfaced when simulating workflows with ERC20 Transfer triggers feeding on-chain runner nodes (e.g. USDC revenue splitter → loop → contractWrite transfers).

  • Synthetic Transfer amount now scales by token decimals. The hardcoded `1.5e18` default became 1.5T units for 6-decimal tokens like USDC, causing downstream Tenderly transfer simulations to revert with "ERC20: transfer amount exceeds balance." Now produces ~1.5 tokens regardless of decimal precision.
  • Transfer event responses now return both `value` (raw uint256 base units) and `valueFormatted` (decimal-adjusted). Matches the Moralis/Etherscan/subgraph convention. Downstream `BigInt(value)` math works directly without crashing on formatted floats.
  • `applyDecimalsTo` deprecated for Transfer fields (still honored for non-Transfer fields like Uniswap `Swap.amountIn`). The enricher now produces both formats unconditionally, so the hint is redundant on Transfer triggers. Logged as a warning when present.
  • Loop step `ExecutionContext` now propagates from inner iteration steps. A loop wrapping Tenderly-simulated contract writes was mislabeling itself as `is_simulated: false, provider: chain_rpc` even though the iterations went through Tenderly correctly.

Breaking change

Clients that relied on `applyDecimalsTo: "Transfer.value"` returning the human-readable string in `value` must migrate to reading `valueFormatted`. The aggregator logs a deprecation warning whenever the hint is present on a Transfer field.

Test plan

  • All existing taskengine tests pass (`make test/package PKG=./core/taskengine`)
  • Tenderly Transfer simulation tests pass
  • Manual: simulate USDC revenue splitter workflow against Sepolia and confirm `value: "1500000"` + `valueFormatted: "1.5"` in trigger response
  • Manual: confirm loop wrapping contract writes reports `provider: tenderly` in ExecutionContext

- Scale synthetic Transfer amount by token decimals so it represents
  ~1.5 tokens regardless of precision (was hardcoded 1.5e18, which
  became 1.5T units for 6-decimal tokens like USDC and caused
  downstream Tenderly transfer simulations to revert with
  "exceeds balance").
- Emit both `value` (raw uint256 base units) and `valueFormatted`
  (decimal-adjusted) on Transfer event responses, matching the
  Moralis/Etherscan/subgraph convention. Downstream BigInt math now
  consumes `value` directly without crashing on formatted floats.
- Deprecate `applyDecimalsTo` for Transfer fields — the enricher
  unconditionally produces both formats, so the hint is redundant.
  Logged as a warning; still honored for non-Transfer fields.
- Propagate ExecutionContext from inner loop iteration steps up to
  the parent loop step so loops wrapping Tenderly-simulated writes
  correctly report `is_simulated: true, provider: tenderly` instead
  of the misleading `chain_rpc` default.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR improves ERC20 Transfer trigger simulation and standardizes the Transfer trigger response schema to better support downstream on-chain math and Tenderly-based workflow simulation, and also fixes loop step execution context reporting.

Changes:

  • Make Tenderly Transfer trigger simulation decimals-aware (new SimulateEventTriggerWithDecimals) so synthetic amounts represent ~1.5 tokens across different decimal precisions.
  • Update Transfer event enrichment to return both value (raw base units) and valueFormatted (decimal-adjusted) and warn when applyDecimalsTo targets Transfer fields.
  • Propagate ExecutionContext from loop iterations to the parent loop step so simulation/provider flags are reported accurately.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
core/taskengine/vm.go Propagates ExecutionContext from iteration steps to loop step.
core/taskengine/tenderly_client.go Adds decimals-aware event trigger simulation for Transfer events.
core/taskengine/shared_event_enrichment.go Adjusts shared Transfer enrichment to emit raw + formatted value fields.
core/taskengine/run_node_immediately.go Deprecation warning for Transfer applyDecimalsTo, pre-resolves decimals for simulation, and ensures raw/ formatted value handling for Transfer parsing.
core/taskengine/event_trigger_constants.go Updates TransferEventResponse schema/docs to include valueFormatted alongside raw value.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 267 to 271
@@ -270,7 +270,11 @@ func enrichTransferEventShared(eventLog *types.Log, parsedData map[string]interf
transferResponse.TokenSymbol = tokenMetadata.Symbol
transferResponse.TokenDecimals = tokenMetadata.Decimals

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

In this shared Transfer enrichment path, transferResponse.Value is only set inside the if tokenMetadata != nil block. If token metadata lookup fails/returns nil (e.g., token not in whitelist, RPC error), the response will keep the default value of "0" even though the raw uint256 amount is available from the parsed event. Since value is now defined as the raw base-units string, it should be populated from transferEventData["value"] regardless of whether token metadata is available; only valueFormatted should depend on knowing decimals.

Copilot uses AI. Check for mistakes.
Comment thread core/taskengine/tenderly_client.go Outdated
Comment on lines +337 to +349
// Default synthetic amount: 1.5 tokens, scaled to the token's actual decimals.
// Without scaling, an 18-decimal default (1.5e18) becomes 1.5 trillion units for
// a 6-decimal token like USDC, which then dwarfs any realistic wallet balance and
// causes downstream Tenderly transfer simulations to revert with "exceeds balance".
decimalsForScale := tokenDecimals
if decimalsForScale == 0 {
decimalsForScale = 18
}
// transferAmount = 15 * 10^(decimalsForScale-1) → represents 1.5 tokens
transferAmount := new(big.Int).Mul(
big.NewInt(15),
new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimalsForScale)-1), nil),
)

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

tokenDecimals == 0 is used as a sentinel for "unknown → default to 18", but ERC20 tokens can legitimately have 0 decimals. If GetTokenMetadata() returns Decimals=0, this will incorrectly scale the synthetic amount as if it were 18 decimals. Consider changing the API to disambiguate "unknown" vs "0" (e.g., *uint32/(uint32, bool)), or treat 0 as a real decimals value (and update the default path to pass 18 explicitly).

Suggested change
// Default synthetic amount: 1.5 tokens, scaled to the token's actual decimals.
// Without scaling, an 18-decimal default (1.5e18) becomes 1.5 trillion units for
// a 6-decimal token like USDC, which then dwarfs any realistic wallet balance and
// causes downstream Tenderly transfer simulations to revert with "exceeds balance".
decimalsForScale := tokenDecimals
if decimalsForScale == 0 {
decimalsForScale = 18
}
// transferAmount = 15 * 10^(decimalsForScale-1) → represents 1.5 tokens
transferAmount := new(big.Int).Mul(
big.NewInt(15),
new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimalsForScale)-1), nil),
)
// Default synthetic amount: use the token's actual decimals.
// For tokens with decimals > 0, represent 1.5 tokens as:
// transferAmount = 15 * 10^(tokenDecimals-1)
// For 0-decimal tokens, fractional amounts are not representable, so use 1 token.
// This avoids treating 0 as an "unknown" sentinel, since ERC20 tokens can
// legitimately have 0 decimals.
transferAmount := big.NewInt(1)
if tokenDecimals > 0 {
transferAmount = new(big.Int).Mul(
big.NewInt(15),
new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)-1), nil),
)
}

Copilot uses AI. Check for mistakes.
Comment thread core/taskengine/vm.go Outdated
Comment on lines +4803 to +4813
// Propagate ExecutionContext from inner iteration steps up to the parent loop step.
// CreateNodeExecutionStep can't infer this for loops because the simulation flag lives
// on the inner runner (contractWrite/ethTransfer), not the loop itself. Without this,
// a loop wrapping Tenderly-simulated contract writes would mislabel itself as
// is_simulated=false, provider=chain_rpc.
for _, iterStep := range iterationSteps {
if iterStep != nil && iterStep.ExecutionContext != nil {
s.ExecutionContext = iterStep.ExecutionContext
break
}
}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

This new behavior (propagating ExecutionContext from inner iteration steps to the parent loop step) isn’t covered by an automated test. Given ExecutionContext affects downstream UX/observability (e.g., showing provider=tenderly vs chain_rpc), please add a test that runs a loop with a simulated on-chain runner and asserts the loop step’s ExecutionContext matches the iteration step’s context.

Copilot uses AI. Check for mistakes.
- Populate raw value unconditionally from parsed event data, even when
  token metadata lookup fails. The new schema contract is that value
  is always the raw uint256 base-units string; only valueFormatted
  depends on knowing decimals. Fixes a regression where tokens not in
  the whitelist would silently keep the default value of "0".
- Disambiguate "unknown decimals" from "0 decimals" in the synthetic
  Transfer amount path by switching the API to *uint32. Some ERC20
  tokens legitimately have 0 decimals; treating 0 as a sentinel for
  "unknown, assume 18" would scale them incorrectly. nil now means
  unknown (fall back to 18), 0 is honored as a real value (use 1 unit
  since fractional amounts are not representable).
- Extract loop ExecutionContext propagation into a testable helper and
  add unit tests covering propagation from first iteration with
  context, skipping nil/empty iterations, leaving the parent untouched
  when no iteration provides context, and graceful handling of nil
  parent / empty iteration slice.
@chrisli30 chrisli30 force-pushed the will-address_simulate_issue branch from 763a2bd to 3ac89b3 Compare April 8, 2026 06:11
@chrisli30 chrisli30 merged commit ceb690e into staging Apr 8, 2026
18 checks passed
@chrisli30 chrisli30 deleted the will-address_simulate_issue branch April 8, 2026 06:17
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.

3 participants