UMA OOReporter and SignedProposer Diff Audit
OpenZeppelin Security
Findings
Total issues: 9 (8 resolved)
Critical: 0 (0 resolved) · High: 0 (0 resolved) · Medium: 0 (0 resolved) · Low: 5 (4 resolved)
Notes & Additional Information
4 notes raised (4 resolved)
Client Reported Issues
0 reported issues (0 resolved)
Scope
OpenZeppelin conducted a diff audit of the UMAprotocol/managed-oracle repository covering four pull requests. Pull requests #76 and #77 branch from the base snapshot 53b74b8, pull request #78 is stacked on pull request #77, and pull request #79 branches from master after the fix review and merge of the other three:
- Pull request 76, Reporter consolidation and shared oracle requests across duplicate Polymarket identifiers. Base 53b74b8, head 6ee2aba.
- Pull request 77, Permit2 relay with partial-success batching. Base 53b74b8, head 32e23e4.
- Pull request 78, UUPS upgradeability for the relay, stacked on pull request 77. Base 32e23e4, head eb3e7c4.
- Pull request 79, One-signature partial-success proposal batches for the relay. Base 886c23b, head 621b4d3.
Additionally, during the audit, we reviewed pull request #80, Reserve gas per reporter callback and combine settlement loops. Base 6ee2aba, head aa7fc2c. This pull request was later merged independently into pull request #76.
In scope were the following modified or added files:
src
├── common
│ └── implementation
│ └── TryMulticall.sol
├── optimistic-oracle-v2
│ └── implementation
│ └── SignedProposer.sol
src
└── reporters
├── integrations
│ └── PolymarketOOReporter.sol
├── interfaces
│ ├── IOOReporter.sol
│ ├── IOOReporterModule.sol
│ ├── IOptimisticOracleV2.sol
│ └── IOptimisticRequester.sol
└── OOReporter.solTest and scripts files were not included in the scope of this review. In some cases, examining them was necessary in order to understand or verify the behavior of the code in scope, and observations arising from that work have been reported where they were considered useful. These should not be read as a comprehensive review of the test suites.
The final state of the pull requests including all the implemented resolutions can be found at following commits:
- pull request #76 - commit 71d3df1
- pull request #77 - commit 5b87be6
- pull request #78 - commit 113e672 (merged into PR #77)
System Overview
This engagement covers four pull requests against the UMA managed oracle system. The first changes how Polymarket request identifiers map onto oracle requests. The second and third add a permissioned relay for submitting proposals on behalf of offchain signers and then place it behind an upgradeable proxy. The fourth adds a second batching path to that relay, driven by a single signature. All four interact with ManagedOptimisticOracleV2, which extends UMA's Optimistic Oracle with administrative control over individual price requests, including custom bonds, liveness periods, proposer and requester whitelists, and permissioned settlement. The base oracle was covered in a prior OpenZeppelin engagement.
Pull request #76, reporter consolidation and shared oracle requests: OOReporter sits between the Polymarket market modules and the oracle, holds the mapping between Polymarket request identifiers (requestId) and oracle requests, and records the raw settled outcome for market-side translation into payouts. The reporter contracts, interfaces, tests, and deployment scripts move out of a nested package into the root source tree, so the reporter is now built with the root project's compiler settings and OpenZeppelin dependency. Previously each pair of price identifier and request rules was reserved by exactly one requestId and a second registration reverted. Up to ten request IDs may now share one canonical oracle request when they come from the same requester and carry the same liveness bounds, a rule enforced in registerRequest. The first identifier registered becomes the canonical request and owns the lifecycle, later identifiers route their reads and mutations to it, and a new namespaced storage field requestIdsByReporterRequestKey records the association. On a settlement producing a usable value, priceSettled emits a resolution event for every associated identifier before the requester callbacks run together inside one external self-call wrapped in try and catch. The deployed reporter is an upgradeable proxy, so the reviewed contract is an upgrade target for live state. The proxy is deployed on Polygon at 0x53703Dd6129d723066b6362510E5aE2fECd48218, and its implementation at the time of review is 0xb20b5FDAf5d8FFb342857b0A26B0a0471Ae2Da38.
Pull request #77, Permit2 relay with partial-success batching: SignedProposer submits oracle proposals for offchain signers. A signer authorizes a proposal through a Permit2 witness signature binding the oracle, requester, identifier, timestamp, ancillary data, proposed price, and a maximum payment, alongside the Permit2 token, amount, nonce, deadline, and the relay as spender. A delegated relayer submits the signature and chooses an actual payment up to the signed cap. The relay resolves the request's effective proposer whitelist at execution time, adding the signer temporarily only when it is not already present, validates the permit token against the request currency, checks the exact amount received, measures the oracle's real token pull by balance difference rather than trusting a return value, refunds any excess, and grants then revokes the oracle allowance around the proposal call. The change under review adds tryMulticall, which lets a delegated relayer submit several independent proposal attempts in one transaction without one ordinary child revert rolling back its successful siblings. Children are restricted to the proposal selector, nested batches are blocked, and each child's token movement, nonce consumption, temporary whitelist membership, and allowance are atomic within that child. No per-child gas cap is applied, so an unsuccessful result records a failed execution attempt rather than an invalid proposal.
Pull request #78, relay upgradeability: Stacked on pull request 77, this moves the relay from a directly deployed contract to a UUPS implementation behind an ERC1967 proxy, matching the mechanism already used by the managed oracle and the reporter. Constructor configuration is replaced by a one-time initializer, the implementation disables its own initializers, the upgradeable access control, multicall, and reentrancy bases are used, and the Permit2 address moves from an immutable into proxy storage. The existing default administrator authorizes upgrades, with no separate upgrade role, delay, or proxy administrator. The proxy address becomes the Permit2 spender and the owner of any whitelists the relay controls, so signatures and whitelist ownership follow the proxy rather than an implementation.
Pull request #79, one-signature proposal batching: This pull request adds proposeBatch, a second partial-success path covering one signer and one collateral token. While the tryMulticall path carries a separate permit, signature, and nonce for each child, here the whole batch is authorized by one Permit2 signature over a nested EIP-712 witness, BatchWitness(BatchProposal[] proposals), binding the ordered proposals and a per-proposal budget covering each one's bond and payment. Those budgets must sum exactly to the permit amount, which is checked before the single upfront transfer. Each proposal then runs in an external self-call that only the contract itself may enter while allowing silent failure, so a failed proposal rolls back its own effects without disturbing its siblings, and unspent budget from both outcomes returns to the signer in one final refund.
Additional pull request #80, Reserve gas per reporter callback: Stacked on pull request 76, this pull request ensures that, regardless of whether the callback succeeds during request settlement, the RequestResolved event is always emitted without reverting by using a gas reserve mechanism. It ensures that a certain amount of gas is always reserved for emitting this event for both canonical and duplicate requests.
Security Model and Trust Assumptions
The privileged roles and trust assumptions recorded in the prior OOReporter engagement continue to apply unchanged. This section adds the roles governing the relay, which is new to this scope, and the assumptions that identifier association and signature authorization introduce. Any finding whose impact depends on one of these being violated is out of scope unless an integrator or unprivileged actor could plausibly breach it.
Privileged Roles
- The reporter owner controls reporter configuration, fund movement, and upgrades.
- It manages the requester and oracle-initializer allowlists, sets the default re-request budget and the automatic re-request setting, withdraws tokens through the sweep function, claims deferred oracle payouts, and authorizes reporter upgrades. Ownership is two-step and renunciation is disabled.
- The oracle initializer creates and replaces oracle requests for the reporter.
- It selects the reward, bond, and liveness within the range registered for each request, drawing the reward from the reporter's balance.
- The reporter requester registers identifiers and forwards rule updates.
- Under these changes it also determines which identifiers share one oracle request, since association follows from supplying byte-identical request rules under the same price identifier and liveness bounds.
- The relay default administrator manages roles, payments, whitelist ownership, and relay upgrades.
- It grants and revokes the other relay roles, withdraws retained order-flow payments, transfers ownership of whitelists held by the relay, and authorizes implementation upgrades.
- The delegated relayer executes signed proposals.
- It submits individual proposals and partial-success batches, chooses the actual payment up to the signer's cap, and controls batch composition and the transaction gas limit.
- The whitelist administrator modifies whitelists owned by the relay.
- It adds and removes entries directly, and cannot remove the relay itself through that path.
- The resolver is the only actor that can settle a request, and the managed oracle administrators govern the underlying oracle.
- The administrators control implementation upgrades, system-wide defaults, and per-request bond, liveness, and proposer whitelist overrides.
Trust Assumptions
- All permissioned actors within the scope of these contracts are fully trusted.
- There is no intermediate, partially trusted role. Each permissioned actor is relied upon to act honestly and competently, and findings whose only precondition is a permissioned actor behaving maliciously are out of scope.
- The reporter owner is trusted to administer the contract safely.
- It is relied upon to configure allowlists and budgets correctly and to hold the upgrade and sweep authority securely. Ownership transfer requires acceptance by the incoming owner, and renunciation reverts, so neither can be performed by accident.
- The oracle initializer is trusted to initialize and re-request faithfully.
- The re-request budget is treated as a safeguard that limits the impact of a compromised initializer key rather than as protection against the initializer itself.
- It is further relied upon to understand that initializing an identifier already associated with an open request performs no oracle work. The reward, bond, and liveness of a shared request are fixed by whichever identifier is initialized first, and the arguments supplied on later calls are discarded without an event.
- The requester-supplied
requestIdandrequestRulesare trusted to be well-formed and to identify the intended market.- The reporter treats
requestIdas an opaque identifier chosen by the requester and does not derive it or validate it against any underlying market data. Deriving the identifier and checking that it corresponds to the correct market is the requester's responsibility, performed by Polymarket or by future integrators. - Request rules are no longer required to be unique across request identifiers. Up to ten identifiers sharing a price identifier and rules blob can be associated to one canonical oracle request and resolve to one outcome. Byte equality of those fields, together with a matching requester and liveness range, is treated as proof that the markets should resolve identically. No check is performed on market shape, event identity, outcome ordering, or the oracle request timestamp.
- The reporter treats
- The deployment enables a single coordinated integrator.
- Requesting is expected to be restricted to Polymarket per deployed instance. Association is scoped to a single requester, so one requester cannot join another's oracle request, but identity coordination within that requester remains the integrator's responsibility.
- Resolution and finality depend on the resolver acting.
- The reporter records an outcome only once a resolver settles a request to a non-P4 value. A dispute causes the reporter to open a fresh oracle request rather than binding the market to the eventual resolution of the disputed request.
- On a dispute, the reporter automatically opens a replacement request and advances its internal timestamp. The disputed request stays live and eventually resolves at the Data Verification Mechanism, but because a request's identity includes its timestamp, that resolution settles only the superseded round and is disregarded by the reporter as stale. Market resolution follows the outcome of the latest replacement round.
- A single settlement now resolves every
requestIdassociated with the original canonical request, so one resolver action determines the outcome of all of them.
- Resolution delivery to the market side is attempted once.
- The reporter provides no way to re-run a callback that failed, and the natural operator action of initializing the identifier again returns without delivering anything. Recovery therefore depends on the downstream market module keeping its reporting entry point permissionless.
- Price-result validation and payout translation are the responsibility of the Polymarket integration.
- The reporter records and exposes only the raw settled outcome. Interpreting that outcome and paying out markets happen on the Polymarket side and are not enforced by the reporter. Because one settled value now serves every associated identifier, each of which translates it using its own market shape, that translation carries correspondingly more weight.
- Updated rules are taken into consideration immediately.
- It is assumed that the Data Verification Mechanism and the integrated oracles immediately process updated rules for all requests and provide the latest valid response based on those updated rules. A rules update posted for one identifier applies to the oracle request shared by every identifier associated with it.
- Signers and delegated relayers are trusted to validate the oracle named in a proposal.
- The chosen oracle supplies the request currency, resolves the effective proposer whitelist, and receives an allowance for the signed amount, so an unintended oracle can spend up to the Permit2-authorized amount for that proposal.
- Signers are trusted to accept that the relay's code may change while their authorization is outstanding.
- A Permit2 witness names the proxy as spender, and the default administrator can replace the implementation behind that address at any time, so a signature remains executable by code that did not exist when it was signed. Permit2 deadlines and nonces are the only signer-side limits on that window.
- The underlying UMA infrastructure is trusted and out of scope.
- The reporter and the Managed Optimistic Oracle rely on the Data Verification Mechanism that resolves disputes, the Store, and the Finder used to locate these components, together with the ERC-20 token used for rewards and bonds. These systems are treated as trusted external dependencies.
Low Severity
Lifecycle Events Use Caller-Supplied Identifier While Shared Request State Is Mutated Under Canonical Entry
The initializeRequest, rerequest, setRequestReward, and setRequestRerequestBudget functions all resolve state through the canonical entry but emit their events under the requestId supplied by the caller. The clearest case arises when a duplicate request ID is used to initialize a shared request that has not yet been initialized. The branch that handles duplicates is entered only when the canonical entry is already initialized, so on a first initialization control falls past it and into the normal path. That path writes the canonical entry's initialized, oracleInitializer, requestTimestamp, reward, proposalBond, liveness, and re-request budget, marks the supplied duplicate as initialized, and creates the oracle request from the canonical entry's stored price identifier and request rules. Every piece of state describing the live oracle request therefore belongs to the canonical entry. The RequestInitialized event, however, names the duplicate.
A later call for the canonical identifier cannot correct this. The duplicate branch requires requestId != canonicalRequestId, so the canonical falls through to the RequestAlreadyInitialized check and reverts. The canonical request ID never emits an initialization event for the entire life of the request, even though it is the entry whose requestTimestamp identifies the oracle request and the entry to which every read is routed.
The effect compounds across the lifecycle, because the identifier carried by later events depends on which path emitted them. A manual re-request emits under the caller-supplied identifier, whereas an automatic re-request emits under the canonical one, since the settlement and dispute callbacks resolve their identifier through _loadCallbackRequest. Reward updates and budget changes likewise carry whichever identifier the caller passed. One shared oracle request can therefore report its initialization under one identifier, its automatic re-requests under another, and its resolution under all of them, with nothing in the payloads indicating that they describe the same request. Monitoring that treats a registered identifier with no matching RequestInitialized as an anomaly will flag the canonical entry indefinitely.
The fan-out introduced by this pull request is applied to resolution only. RequestResolved is emitted for every associated identifier, whereas the re-request, budget, reward, and callback-failure events remain keyed to the canonical identifier alone. An indexer following a duplicate request ID therefore observes no initialization, no dispute, and no re-request, followed by a resolution event with no preceding lifecycle, and receives no signal when the callback for that identifier is skipped. Separately, because the resolution events are emitted outside the callback self-call, they survive a reverted callback batch and are emitted again by the recovery path, making RequestResolved an at-least-once event per identifier.
Consider emitting lifecycle events under the canonical identifier with the caller-supplied identifier as an additional field, or applying the same fan-out used for resolution, and documenting the per-identifier event surface and the at-least-once delivery of RequestResolved.
Update: Resolved in pull request #84 at commit 9e8e739 and pull request #88 at commit 6fe2854. The team stated:
“Shared lifecycle events now consistently use the canonical request ID, including initialization through an alias, manual re-requests, rule/reward updates and budget changes. Event signatures are preserved; we have not added a caller-supplied alias field. The README documents how indexers map registrations to the canonical ID, distinguish per-ID resolution/callback events, and deduplicate RequestResolved under the current batch implementation.
Validation: the alias lifecycle regression fails on the base and passes with the fix; all 69 reporter tests pass, including upgrade coverage.”
TryMulticall Introduces Unnamespaced Storage Into Every Inheriting Contract
TryMulticall is an abstract base placed in src/common/implementation/, alongside the other shared building blocks of the system, so it is positioned for reuse beyond its single current inheritor. It declares its reentrancy flag as a plain sequential state variable. Every contract that inherits the base therefore receives a storage variable whose slot is determined by that contract's own inheritance linearization, rather than by anything under the base's control.
This is inconsistent with the rest of the system and it propagates a layout constraint outward. OOReporter places all of its state under an ERC-7201 namespace, and the OpenZeppelin upgradeable bases the relay inherits do the same, so TryMulticall is the only contributor of sequential storage in that hierarchy. For any future upgradeable inheritor, the flag must be accounted for in that contract's layout, and reordering its inheritance list would silently move every variable declared after it.
Consider moving the batching flag into an ERC-7201 namespace so that TryMulticall contributes no sequential storage, which would match the convention already used elsewhere in the repository and remove the coupling entirely. Alternatively, consider heavily documenting this storage scheme so that future integrations are aware.
Update: Resolved in pull request #85 at commit 8fda1fd. The team stated:
“The TryMulticall reentrancy flag now lives in the ERC-7201 namespace uma.storage.TryMulticall, so the batching base contributes no sequential storage.
Integration caveat: #77 uses immutable permit2. When applied to the upgradeable version in #78, removing the sequential flag shifts permit2 from slot 0, offset 1 to offset 0. This must be incorporated before that version's first deployment; an already-deployed proxy would require compatible storage handling. The PR does not implement such a migration.
All 52 SignedProposer unit tests pass, including nested batching and a regression that independently derives the namespace slot and verifies that the guard reads it. The namespace and compiler storage layouts were also checked.”
Request Identifiers Registered After Settlement Inherit a Stale Outcome With No Oracle Round
The registerRequest function of the OOReporter contract binds a caller-supplied requestId to a (priceIdentifier, requestRules) pair. Under the reviewed changes, that pair is no longer reserved by a single identifier. Up to ten identifiers may share it, provided each registration originates from the same requester and carries the same liveness bounds. The first identifier registered becomes the canonical request and owns the oracle lifecycle, and the rewritten _requireRegistered helper routes every subsequent read and mutation for a linked identifier to that canonical entry.
The registration path performs no check on the canonical request's state. Neither resolved nor initialized is consulted, so an identifier may be bound to a pair whose oracle request settled arbitrarily long ago. Because reads are routed to the canonical entry, isRequestResolved returns true and getRequestResolution returns the stored outcome for the new identifier from the moment registration returns. No oracle request is created for it, no reward is committed, no proposer posts a bond, and no challenge window opens. The condition is also terminal: rerequest and setRequestRerequestBudget both revert with RequestAlreadyResolved, initializeRequest cannot take its main path because the canonical entry is already initialized, and no function clears or re-points an association. The affected market can never obtain an oracle round of its own.
The documentation describes a narrower behavior than the implementation permits. Both the package README and the initializeRequest documentation state that a duplicate registered after resolution triggers reporting on its first initialization, which implies that the onlyOracleInitializer role acts as a checkpoint. It does not. The Polymarket reporter module exposes a permissionless reporting function that consults only its own registration flag, set during registration itself, together with the two reporter read functions above. Registration alone is therefore sufficient for any account to drive the inherited outcome into the market-side flow, and the market-side dispute window is configurable to zero. Since the oracle request timestamp is not part of the association key, the exposure is not limited to deliberate reuse: a recurring question whose period is carried outside the rules text collides with its own earlier instances. For the numerical identifier used by atomic neg-risk batches the consequence is sharper still, because the settled value is an index into one specific event's condition ordering and that ordering is not part of the key, so two linked events with matching arity can resolve to different winners from the same value.
Consider rejecting registration against a pair whose canonical request has already resolved, so that the failure is explicit and the requester is forced to domain-separate the request rules. Alternatively, consider correcting the documentation to state that registration, not initialization, is what makes a late duplicate readable.
Update: Resolved in pull request #81 at commit 2862544. The team stated:
“We are taking the documentation alternative in the recommendation.
Sharing an outcome between requests with identical resolution semantics is intentional, including after settlement. The README and interface NatSpec now explicitly state that registration immediately exposes the canonical outcome, creates no new oracle round or challenge window, and permits module reporting without another initialization.
They also require requesters to distinguish observation periods and numerical outcome-to-position mappings in the rules. The reporter does not validate external mappings or include market/event identity in its association key. Late registration remains allowed.
Validation: the existing late-duplicate test now checks resolved state and the stored outcome immediately after registration, before initialization, and passes.”
Inconsistent Guards Between multicall and tryMulticall
The SignedProposer contract inherits two independent batching entry points that both execute self-delegatecalls. The tryMulticall function restricts callers to DELEGATED_PROPOSER_ROLE, rejects any child whose selector is not propose, and blocks nested invocation through a dedicated _tryMulticallEntered flag. The inherited MulticallUpgradeable applies none of these: it is callable by any account, accepts any selector, and holds no reentrancy state of its own.
The divergence is not currently exploitable. Because delegatecall preserves msg.sender, every function reached through multicall re-applies its own access control, and no combination bypasses the batching guards. A child of tryMulticall cannot be multicall, since the selector allowlist is validated before any child executes, and a reentrant call is rejected either by the reentrancy flag or by the nonReentrant modifier on propose. The safety of multicall is therefore an emergent property of every reachable function being individually guarded, rather than something the entry point enforces, so a function added later without its own access control would be reachable through one path and not the other.
Consider documenting in SignedProposer that multicall is deliberately unrestricted and that its safety depends on every externally reachable function enforcing its own authorization, or alternatively updating multicall to apply the same safeguards as tryMulticall.
Update: Resolved in pull request #82 at commit 9c5dae5. The team stated:
“SignedProposer NatSpec and the batching documentation now explicitly describe atomic multicall as deliberately unrestricted at the batch entry point. Delegatecall preserves the caller, so each reachable function, including future additions, must enforce its own authorization and any necessary reentrancy protection. The documentation contrasts this with the delegated-proposer, propose-only and nesting restrictions of tryMulticall.
This is a documentation-only change; batching permissions and runtime behavior are unchanged.”
Relayer Can Cause a Proposal to Silently Fail While Consuming the Signature Nonce
The proposeBatch function executes a batch of proposals authorized by a single Permit2 signature. The signed witness binds the proposals and their budgets, but not the payment charged for each item. Payments are chosen by the relayer at submission time and bounded only by the signed maxPayment. A payment at or below that cap proceeds as intended. A payment above it is rejected inside executeBatchProposal, a self-call whose revert the parent captures rather than propagates. By then, the Permit2 transfer has already spent the nonce. The item is therefore silently discarded while the signature is consumed, and the relayer decides which proposals are skipped.
The single-proposal propose function rejects the same value before the nonce is touched. The pre-existing tryMulticall function is also a partial-success batch, but each child carries its own permit and nonce. A failed child rolls its nonce back and the signature remains usable. Because the documentation presents proposeBatch as the compact alternative to a tryMulticall of propose calls, migrating between them newly costs a signature on every failed item. The impact depends on the trust placed in the DELEGATED_PROPOSER_ROLE. Even if every holder is fully trusted, an honest relayer submitting a malformed payments array still destroys the signature where propose would have failed safely.
Consider validating each payment against the corresponding maxPayment in the pre-flight loop that already iterates the batch, before the nonce is consumed, and retaining the existing check as defense in depth. Note that exceeding maxPayment is not the only lever: the oracle receives an allowance of only maxAmount - payment, so an item that does not satisfy maxAmount - maxPayment >= bond can be starved from within the cap. Rejecting an item whose maxPayment exceeds its maxAmount in the same loop is therefore advisable. Thorough unit testing is essential, as the current suite assigns payments as uniformly in-bound or uniformly out-of-bound values only.
Update: Acknowledged, not resolved. The team stated:
“We acknowledge and accept this behavior under the fully trusted, company-controlled DELEGATED_PROPOSER_ROLE model. The new PR #89 explicitly documents this trust assumption and that relayer-induced failures, including payment errors or insufficient bond budgets, refund unused funds but consume the shared nonce on batch completion; retries require a fresh signature. No execution changes or pre-flight payment checks are proposed.”
Notes & Additional Information
Unbounded Revert Data in the Batch Failure Path Can Revert an Entire Batch
When a child call fails, tryMulticall copies the entire revert payload into memory and hashes it for the ProposalCallFailed event. The oracle named in a proposal is caller-supplied and its revert data bubbles up through propose, so an adversarial oracle controls the size of that payload.
Because the copy and the hash run in the parent frame after the child returns, they are charged against the gas the parent retained under EIP-150 rather than against the child's share. A sufficiently large payload therefore exhausts the parent during the copy and reverts the whole batch, discarding proposals that had already succeeded, including those submitted on behalf of other signers.
Consider bounding the copied revert data to a fixed length before hashing, for example by performing the call in assembly and copying only the smaller of returndatasize() and a fixed cap. Thorough unit testing is essential, since the emitted revertDataHash would then cover a truncated payload. Alternatively, consider documenting this revert scenario for future implementations.
Update: Resolved in pull request #86 at commit da6cb0d. The team stated:
“TryMulticall now performs the delegatecall without automatically copying return data and copies/hashes at most the first 256 bytes on failure. Successful return data is discarded. The event and operator documentation explicitly define revertDataHash as the hash of that bounded prefix.
All 52 SignedProposer unit tests pass. A regression preserves an earlier successful proposal after a 512 KiB revert under a 2M-gas batch cap; it fails on the base and passes with the fix. Nonzero patterned payloads verify both the error selector and the truncated hash._
This fixes unbounded failure-data processing. It does not introduce a per-child gas cap or remove the documented possibility of whole-batch failure when outer gas is insufficient.
It was acknowledged that the cap protects copying withinTryMulticall, while the intermediate proxy can still exhaust gas copying returndata. Hence, the documented possibility of a full batch revert with insufficient outer gas remains. Given the low likelihood of this scenario, we kept the code unchanged to avoid further complexity.”
Relay Contracts Do Not Declare a Security Contact
Neither TryMulticall nor SignedProposer carries a @custom:security-contact NatSpec tag, while every other principal contract in the repository does, including both oracles, both address whitelists, and both reporters. The tag is the conventional channel for vulnerability disclosure and is surfaced by block explorers. The omission in SignedProposer predates these changes; TryMulticall introduces a new contract without it.
Consider adding the tag to the newly introduced batching contract, and to the relay for consistency with the rest of the repository.
Update: Resolved in pull request #83 at commit ba63d6f. The team stated:
“Added @custom:security-contact bugs@umaproject.org to both TryMulticall and SignedProposer, matching the existing repository convention. This is a NatSpec-only change; runtime behavior is unchanged.”
Unnecessary Memory Copy of the payments Array in proposeBatch
The proposeBatch function declares its payments parameter as uint256[] memory, while the proposals array on the same signature is declared calldata. The payments array is only ever read, both in the pre-flight loop and when encoding each child call. Copying it into memory therefore serves no purpose and adds gas proportional to the batch size.
Consider declaring the payments parameter as calldata to match the other array on the same signature.
Update: Resolved in pull request #90 at commit 38eb19f. The team stated:
“PR #90 changes the read-only payments parameter from memory to calldata, avoiding the unnecessary copy while preserving the external ABI and intended execution behavior. All 11 selected existing SignedProposer batching tests passed with Solidity 0.8.30.”
Incomplete Contract-Level Documentation
The contract-level @dev block of the SignedProposer contract contrasts two batching entry points. It explains that the inherited multicall accepts any caller and selector and provides no reentrancy guard. It also explains that tryMulticall restricts the caller to delegated proposers, accepts only propose, and rejects nested partial-success batches. The pull request #79 adds proposeBatch as a third partial-success entry point, but the note was not updated to reflect it.
Consider extending the note to cover all three batching entry points and the guarantees that distinguish them.
Update: Resolved in pull request #91 at commit 10b4d1c. The team stated:
“PR #91 extends the contract-level documentation to cover multicall, tryMulticall, and proposeBatch, distinguishing authorization, reentrancy protection, failure isolation, and individual versus shared permit/nonce behavior.”
Conclusion
The OOReporter package connects a prediction market to UMA's Managed Optimistic Oracle, while SignedProposer relays oracle proposals on behalf of offchain signers who authorize them with Permit2 witness signatures. OpenZeppelin audited four pull requests: the consolidation of duplicate requests in OOReporter; the addition of partial-success batching, shifting to UUPS upgradeable proxy, and the introduction of a second batch path authorized by a single signature in SignedProposer.
Both subsystems follow a permissioned design in which a small set of trusted operators governs the request lifecycle. The reporter's authorization model is unchanged, so the roles and trust assumptions recorded in the previous engagement continue to apply, while the relay contributes its own administrator, delegated relayer, and whitelist administrator roles. No critical, high, or medium severity issues were identified.
The relay changes are narrow and defensively written, restricting batch children to a single selector, rolling back each child's effects atomically on failure, and measuring token movement rather than trusting a return value. The reporter change is smaller in code but larger in consequence, since sharing one oracle lifecycle across identifiers replaces a check that previously failed closed on repeated request rules with one that admits them, so correctness now rests on the requester supplying identical rules only for markets that deserve the same answer. The issues identified follow from that shift and from the reduced observability of a shared lifecycle, and are oriented toward code hardening rather than loss of funds; documenting the requester obligations that association introduces would reduce the residual risk further.
OpenZeppelin thanks the UMA team for their cooperation and responsiveness throughout the engagement.
Appendix
Issue Classification
OpenZeppelin classifies smart contract vulnerabilities on a 5-level scale:
- Critical
- High
- Medium
- Low
- Note/Information
Critical Severity
This classification is applied when the issue’s impact is catastrophic, threatening extensive damage to the client's reputation and/or causing severe financial loss to the client or users. The likelihood of exploitation can be high, warranting a swift response. Critical issues typically involve significant risks such as the permanent loss or locking of a large volume of users' sensitive assets or the failure of core system functionalities without viable mitigations. These issues demand immediate attention due to their potential to compromise system integrity or user trust significantly.
High Severity
These issues are characterized by the potential to substantially impact the client’s reputation and/or result in considerable financial losses. The likelihood of exploitation is significant, warranting a swift response. Such issues might include temporary loss or locking of a significant number of users' sensitive assets or disruptions to critical system functionalities, albeit with potential, yet limited, mitigations available. The emphasis is on the significant but not always catastrophic effects on system operation or asset security, necessitating prompt and effective remediation.
Medium Severity
Issues classified as being of medium severity can lead to a noticeable negative impact on the client's reputation and/or moderate financial losses. Such issues, if left unattended, have a moderate likelihood of being exploited or may cause unwanted side effects in the system. These issues are typically confined to a smaller subset of users' sensitive assets or might involve deviations from the specified system design that, while not directly financial in nature, compromise system integrity or user experience. The focus here is on issues that pose a real but contained risk, warranting timely attention to prevent escalation.
Low Severity
Low-severity issues are those that have a low impact on the client's operations and/or reputation. These issues may represent minor risks or inefficiencies to the client's specific business model. They are identified as areas for improvement that, while not urgent, could enhance the security and quality of the codebase if addressed.
Notes & Additional Information Severity
This category is reserved for issues that, despite having a minimal impact, are still important to resolve. Addressing these issues contributes to the overall security posture and code quality improvement but does not require immediate action. It reflects a commitment to maintaining high standards and continuous improvement, even in areas that do not pose immediate risks.