fix: improve event trigger simulation accuracy and response schema#507
Conversation
- 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.
There was a problem hiding this comment.
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) andvalueFormatted(decimal-adjusted) and warn whenapplyDecimalsTotargets Transfer fields. - Propagate
ExecutionContextfrom 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.
| @@ -270,7 +270,11 @@ func enrichTransferEventShared(eventLog *types.Log, parsedData map[string]interf | |||
| transferResponse.TokenSymbol = tokenMetadata.Symbol | |||
| transferResponse.TokenDecimals = tokenMetadata.Decimals | |||
There was a problem hiding this comment.
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.
| // 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), | ||
| ) |
There was a problem hiding this comment.
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).
| // 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), | |
| ) | |
| } |
| // 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
- 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.
763a2bd to
3ac89b3
Compare
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).
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