Status: Draft for review Β· Written: 2026-08-04
Scope: core chain only. Contracts (push-chain-core-contracts@feat-read-state) and universal
validators (push-chain-node#296) are owned by other teams; both are already written, which means
our interface is pinned from both ends.
Every claim below is cited to a file:line or a live query. Open questions are collected in Β§9.
ββ contracts (done, branch) ββ UV (done, draft PR #296)
β UniversalCallback.sol β externalchains/{evm,svm,web2}/read_executor.go
β emits ReadRequested β pushwatcher/ β polls us
β exposes fulfillExternalCallback β pushcore.GetAllPendingReadRequests() β STUB
β expireExternalRead β
ββββββββββββββ¬ββββββββββββββββββββββββββββββββ΄βββββββββββ¬βββββββββββββββββ
β β
ββββββΌβββββββββββββββββββββββββββββββββββββββββββΌβββββ
β x/ucallback β US β
β ingest ReadRequested β serve pending β tally β
β ballot β call fulfill/expire β record outcome β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The UV's stub states our deliverable verbatim (universalClient/pushcore/pushCore.go:377):
TODO(core): blocked on x/uexecutor Query/PendingReadRequests β¦ mirror GetAllPendingOutbounds
Note it says x/uexecutor. We are choosing x/ucallback instead β see Β§2.1 for why that is fine
and Β§9 Q1 for the one thing it forces.
UniversalCallback.sol:25 hardcodes an immutable:
address public immutable UNIVERSAL_EXECUTOR_MODULE = 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7;
modifier onlyUEModule() { if (msg.sender != UNIVERSAL_EXECUTOR_MODULE) revert CallerIsNotUEModule(); }Verified against live donut:
uexecutor module push1zsv3af2tfstklnux75dsltruk8n3ma7hnxp8ew
β hex 0x14191ea54b4c176fcf86f51b0fac7cb1e71df7d7 β identical
A new module gets authtypes.NewModuleAddress("ucallback"), a different address. Every
fulfillExternalCallback would revert.
Decision taken: x/ucallback owns state and lifecycle; the EVM call is executed as uexecutor
by calling into the uexecutor keeper. No contract change, no redeploy coupling.
Consequence: we inherit uexecutor's module-nonce path, including the known drift bug where module-sender calls skip
ModuleAccountNonceincrement. Fix that before adding a fourth caller.
x/vm/keeper/keeper.go:254:
func (k *Keeper) SetHooks(eh types.EvmHooks) *Keeper {
if k.hooks != nil { panic("cannot set evm hooks twice") }and app/app.go:794 already does app.EVMKeeper.SetHooks(uexecutorkeeper.NewEVMHooks(...)).
Solution: evmkeeper.NewMultiEvmHooks(uexecutorHooks, ucallbackHooks) (x/vm/keeper/hooks.go:27).
β οΈ MultiEvmHooks.PostTxProcessingaborts the whole chain of hooks on the first error (hooks.go:40-42). A bug in our hook therefore fails unrelated EVM transactions. Our hook must never return a non-nil error for anything short of a genuine consensus fault β log and continue instead.
| defect | effect on us |
|---|---|
expireExternalRead transfers nothing (verified: 0 value-transfer statements) |
our sweeper "expires" a request but the funder is never repaid; fees accrue in the contract |
refund uses call{value} + revert on failure |
an app without receive() makes fulfillExternalCallback revert forever; our submit will never succeed |
fulfilledRequests[requestId] = true set before dispatch |
whatever we submit is final; a quorum on ERROR is permanent, no retry |
_localContext leaks in the app on the failure/expiry paths |
not ours, but it's what users will report to us |
These are raised with the contracts team (Β§10). Our design must not depend on them being fixed.
Named as the read-side sibling of UniversalTx (proto/uexecutor/v1/types.proto:186). Deliberately
not a clone: a read is triggered by a Push-chain event, performs no external write, has no
external tx hash, and produces exactly one Push-chain fulfilment.
// proto/ucallback/v1/types.proto
message UniversalRead {
option (amino.name) = "ucallback/universal_read";
string id = 1; // requestId, 0x-hex uint256
ReadRequest request = 2;
ReadResult result = 3; // set when the ballot finalises
repeated PCTx pc_tx = 4; // fulfil / expire attempts (reuse uexecutor's PCTx)
UniversalReadStatus status = 5;
string ballot_key = 6;
}pc_tx is repeated (fulfil, then possibly expire); request/result are singular. There is no
top-level revert_error β failures live in PCTx.error_msg, which is the field that made a stuck
UniversalTx debuggable in production.
message ReadRequest {
string request_id = 1;
string destination_chain = 2; // CAIP-2, composed by us
bytes owner = 3;
bytes query = 4;
uint32 min_confirmations = 5; // uint16 on the wire
uint64 destination_block_height = 6;
uint64 expiry_block_height = 7;
uint64 created_at_height = 8; // derived β NOT in the event
// core-only, never consumed by the UV
string callback_target = 9;
string original_funder = 10;
string fees_deposited = 11; // uint256 as string
string max_fee = 12;
string requested_tx_hash = 13; // provenance / dedup / debugging
uint64 requested_log_index = 14;
}Field-for-field this must satisfy uread.ReadRequest (universalClient/uread/types.go:9), the
temporary struct we are meant to delete.
message ReadResult {
ReadStatus status = 1;
bytes result_data = 2;
uint64 observed_block_height = 3;
bytes observed_block_hash = 4;
}error_msg is deliberately absent from the proto, not merely unused. In uread it is excluded
by a comment β "local diagnostic only β never part of the ballot". Once it is a generated type
that convention will be forgotten; make it structurally impossible. If error text ever enters the
ballot key, no two validators ever agree.
enum ReadStatus { READ_STATUS_UNSPECIFIED = 0; READ_STATUS_SUCCESS = 1; READ_STATUS_ERROR = 2; }
enum UniversalReadStatus {
UNIVERSAL_READ_STATUS_UNSPECIFIED = 0;
UNIVERSAL_READ_STATUS_PENDING = 1; // ingested, awaiting votes
UNIVERSAL_READ_STATUS_VOTING = 2; // β₯1 vote, no quorum
UNIVERSAL_READ_STATUS_FULFILLED = 3; // callback dispatched OK
UNIVERSAL_READ_STATUS_EXPIRED = 4; // expireExternalRead submitted
UNIVERSAL_READ_STATUS_FAILED = 5; // quorum reached, callback reverted
}| our field | from ReadRequested |
|---|---|
request_id |
requestId |
destination_chain |
account.chainNamespace + ":" + account.chainId |
owner / query / min_confirmations |
account.owner / readSpec.query / readSpec.minConfirmations |
destination_block_height |
readSpec.blockNumber |
expiry_block_height |
readSpec.expiryPushChainHeight |
callback_target / original_funder / fees_deposited / max_fee |
same-named event args |
created_at_height |
not emitted β take from the log's Push block height |
UniversalReads : requestId β UniversalRead (collections.Map)
PendingByExpiry : (expiryHeight, requestId) β () (KeySet, in-flight set)
ReadsByTxHash : (pushTxHash, requestId) β () (KeySet, batch reassembly)
Params : module params
There is deliberately no ballotKey β requestId index. The ballot terminal hook resolves a ballot
by scanning PendingByExpiry, which holds only unsettled reads β the same trade uexecutor already
makes in ballot_hooks.go:86 for the identical problem. That leaves PendingByExpiry with two
consumers, so it stays regardless of how the open expiry-cadence question is answered.
PendingByExpiry must be an ordered composite key so the sweeper can range-scan
[0, currentHeight] in EndBlocker rather than iterating everything.
proto/ucallback/v1/{types,tx,query,genesis,params}.proto
x/ucallback/
keeper/
keeper.go collections wiring, uexecutor + uvalidator keeper refs
evm_hooks.go PostTxProcessing β ingest ReadRequested
ingest.go log filter + decode β UniversalRead{PENDING}
msg_vote_read_result.go MsgVoteReadResult β VoteOnReadBallot
ballot_hooks.go AfterBallotTerminal(READ_RESULT) β fulfil
fulfill.go call UniversalCallback.fulfillExternalCallback via uexecutor
expire.go EndBlocker sweeper β expireExternalRead
grpc_query.go AllPendingReadRequests, GetUniversalRead
types/
constants.go keys.go codec.go errors.go events.go
module.go / depinject
Mirrors x/uexecutor/keeper/create_outbound.go:27-42:
for _, lg := range receipt.Logs {
if lg.Removed { continue }
if !strings.EqualFold(lg.Address, universalCallbackAddr) { continue } // β MANDATORY
if len(lg.Topics) == 0 || !strings.EqualFold(lg.Topics[0], ReadRequestedEventSig) { continue }
...
}The address filter is a security control, not a nicety. Matching on topic0 alone lets anyone
deploy a contract emitting an identical ReadRequested and conscript the entire validator set into
performing free external reads β a cheap DoS. The eventual fulfillExternalCallback would be
rejected (InvalidRequestId), so no funds move, but validator work and module txs are burned.
rpc AllPendingReadRequests(QueryAllPendingReadRequestsRequest)
returns (QueryAllPendingReadRequestsResponse); // paginated, mirrors AllPendingOutbounds
rpc GetUniversalRead(QueryGetUniversalReadRequest)
returns (QueryGetUniversalReadResponse); // mirrors v2 GetUniversalTxAllPendingReadRequests is the one the UV is blocked on. GetUniversalRead is the operator tool β
the get-universal-tx equivalent that made a stuck production tx diagnosable in minutes.
rpc VoteReadResult(MsgVoteReadResult) returns (MsgVoteReadResultResponse);
message MsgVoteReadResult {
option (cosmos.msg.v1.signer) = "signer";
string signer = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
string request_id = 2;
ReadResult result = 3;
}Mirrors MsgVoteOutbound (proto/uexecutor/v1/tx.proto:121). Requires a new enum value in
proto/uvalidator/v1/ballot.proto:25:
BALLOT_OBSERVATION_TYPE_READ_RESULT = 5;ballotKey = H(requestId β status β resultData β observedBlockHeight β observedBlockHash) β
excluding error text.
Extend BallotHooks.AfterBallotTerminal (x/uexecutor/keeper/ballot_hooks.go:56) with a
BALLOT_OBSERVATION_TYPE_READ_RESULT case β afterReadBallotTerminal β Β§5.5.
Call UniversalCallback.fulfillExternalCallback(requestId, resultData, observedBlockHeight, observedBlockHash) through uexecutor's DerivedEVMCall so msg.sender is the uexecutor module
(Β§2.1). Record the outcome as a PCTx β including error_msg on failure β and set status
FULFILLED or FAILED.
fulfillExternalCallbackdoescall{gas: callbackGasLimit}withcallbackGasLimitup toMAX_CALLBACK_GAS_LIMIT = 1_000_000and performs no 63/64 check. Since we are the caller, we must ensuregasleft() β₯ callbackGasLimit Γ 64/63 + bufferbefore dispatch, or the callback silently under-runs and fails permanently.
EndBlocker: range-scan PendingByExpiry over [0, ctx.BlockHeight()], submit
expireExternalRead(requestId), mark EXPIRED. Bound the per-block count (Β§9 Q5).
app.EVMKeeper.SetHooks(evmkeeper.NewMultiEvmHooks(
uexecutorkeeper.NewEVMHooks(app.UexecutorKeeper),
ucallbackkeeper.NewEVMHooks(app.UcallbackKeeper),
))Replaces the single-hook call at app/app.go:794. Plus: module registration, SetOrderEndBlockers
entry for the sweeper, and a ucallback entry in maccPerms.
BlockedAddresses()derives fromGetMaccPerms(), and since cosmos/evm v0.7 the blocked list also gatesSetBalance. AddingucallbacktomaccPermstherefore makes its address unable to receive native EVM value. That is almost certainly what we want β flag it if not.
- protos + generated types (
make proto-gen, Docker) - storage + keeper skeleton + genesis
AllPendingReadRequestsβ unblocks the UV team immediately, even returning empty- ingestion hook +
MultiEvmHooksrewire MsgVoteReadResult+ ballot type + tally- terminal hook β fulfilment
- expiry sweeper
GetUniversalRead+ CLI- upgrade handler (new store key β
StoreUpgrades.Added)
Step 3 is deliberately early and cheap: it deletes ErrReadQueriesNotAvailable and lets the UV team
integrate against a real endpoint while the rest lands.
- ingestion: forged log from a non-
UniversalCallbackaddress is ignored (security regression test) - ballot: two validators voting identical results converge; differing
error_msgmust not split them - fulfilment: callback revert β
FAILED+error_msgrecorded, request not retried - expiry: request past
expiry_block_heightis swept exactly once - upgrade sim from the current donut release with the new store key
Q1 β module name vs the UV's expectation.
The UV stub targets x/uexecutor Query/PendingReadRequests. If we ship x/ucallback, the UV team
must change the client path and proto import. Cheap, but it is a cross-team change that must be
agreed before they unblock. Do we confirm ucallback with them now?
Q2 β should we ever submit an ERROR ballot?
PR #296's EVM executor votes ERROR on any CallContract failure
(externalchains/evm/read_executor.go:61), including non-archive nodes, 429s and timeouts β while
the SVM and web2 executors correctly treat transport failures as transient. Because ERROR ballots are
byte-identical by design, validators failing for unrelated infrastructure reasons converge into a
confident quorum indistinguishable from a genuine revert β and the contract makes it permanent.
Do we harden core-side (refuse ERROR ballots lacking revert evidence), or require the UV fix first?
Q3 β retry semantics.
The contract marks fulfilledRequests[requestId] = true before dispatch, so a reverted callback is
terminal. Do we (a) accept that and record FAILED, or (b) ask contracts to mark fulfilled only on
success so a retry is possible? (b) is a contract change and must be requested while they are still
on a branch.
Q4 β do we gate on Push-chain confirmations before serving a request?
The UV sets ConfirmationType: store.ConfirmationInstant (pushwatcher/event_parser.go:114) β it
acts immediately on whatever we serve. If we serve from a block that later reorgs, validators do work
for a request that never existed. Serve immediately, or hold N blocks?
Q5 β sweeper budget. Max expiries per block? Unbounded risks a fat EndBlocker; too low and a backlog never drains.
Q6 β who pays for callback gas?
There is no validator/reader reward path anywhere in UniversalCallback.sol (grep: 0 matches).
The callbackGasLimit Γ tx.gasprice component is collected then refunded in full on both success and
failure β it pays nobody. The module bears real execution cost for up to 1M gas per read, gasless.
Is that intentional for v1?
Q7 β ReadRequested topic + UniversalCallback address: config or Go constants?
uexecutor hardcodes both (types/constants.go, uregistrytypes.SYSTEM_CONTRACTS). That pattern has
already failed once in production: RescueFundsOnSourceChainEventSig still declares a signature the
contract renamed to FundsRescued, and it silently matches nothing. Strong recommendation: put both
in chain config, like gateway_methods / vault_methods.
Q9 β π΄ web2 reads cannot be expressed by the contract.
The UV has a complete web2 path (externalchains/web2/read_executor.go, 418 lines, SSRF-hardened),
uread documents the CAIP form web2:https and marks DestinationBlockHeight "not applicable for
web2". But the contracts contain zero web2 references, and requestExternalReadSelf rejects it:
if (spec.blockNumber == 0
|| spec.blockNumber > _universalCore.chainHeightByChainNamespace(...)) revert InvalidBlockNumber();
if (spec.account.owner.length == 0) revert InvalidAccountId();
if (spec.minConfirmations < MIN_CONFIRMATIONS_FLOOR) revert InvalidMinConfirmations();A web2 request would need a fabricated blockNumber, a fictional web2 namespace height in
UniversalCore exceeding it, a dummy owner (the URL lives in query), and a meaningless
minConfirmations β₯ 1.
Ballots still converge β every web2 voter reports height 0 and an empty hash β so the tally needs no
special case. The costs are (a) we persist and serve a fake destination_block_height, and (b) any
future confirmation-gating on our side must exempt web2.
Is web2 in scope for v1? If yes, the contract needs a namespace-aware validation branch. If no, the UV's web2 executor is dead code and we should not model for it.
Q8 β one ucallback per read type, or reuse for future callbacks?
The name implies a general callback module. If future non-read callbacks are planned, UniversalRead
should probably sit under a broader UniversalCallbackRecord umbrella now rather than later.
Contracts (feat-read-state, pre-merge β cheapest to fix now):
expireExternalReadrefunds nothing β funder's fee is trapped- refund
call{value}+revertlets an app with noreceive()brick its own fulfilment forever UniversalReadClienthas noreceive();CrossLendMockadds one privately, so tests pass and the requirement is invisible to integrators- consider marking fulfilled only on callback success (Q3)
chainHeightByChainNamespaceignoreschainId, so alleip155chains share one height- fee derived from requester-controlled
tx.gasprice - web2 reads are unrequestable β validation assumes a blockchain (
blockNumber != 0, non-emptyowner,minConfirmations β₯ 1) while the UV has a complete web2 executor (Q9). Needs either a namespace-aware validation branch or an explicit "web2 is not in v1" decision
UV team (#296):
eth_calltransport failures must not become ERROR ballots (Q2) β copy the web2 executor's own three-way transient/deterministic split- confirm the
ucallbackmodule path (Q1) ureaddeletion once our generated types land