Prepare middleware for PolySafe de-whitelisting on Polygon - #476
OjusWiZard merged 12 commits into
Conversation
Polymarket has stopped supporting PolySafe-created safes, so new Polystrat services no longer need the PolySafe factory. Emptying POLY_SAFE_SERVICE_NAMES routes all new deployments through safe_multisig_with_recovery_module, which remains whitelisted. Existing deployed services are unaffected — they always use reuse_multisig=True via RECOVERY_MODULE_CONTRACT.
New structured exception for when a service deploy reverts with UnauthorizedMultisig (de-whitelisted multisig creator contract). Follows the InsufficientFundsException pattern with to_error_fields() for clean JSON response merging.
Extend the mega-batch revert attribution block to collect sub-tx error strings and check for UnauthorizedMultisig. When detected, raise UnauthorizedMultisigException instead of the generic ChainInteractionError, so the API layer can return an actionable 400 response instead of a generic 500.
Add a specific except handler for UnauthorizedMultisigException before the generic except Exception block in _deploy_and_run_service. Returns HTTP 400 with an actionable error message and error_code instead of the generic 500 "reverted on-chain".
- Assert POLY_SAFE_SERVICE_NAMES is empty (constant change) - Test UnauthorizedMultisigException __str__ and to_error_fields() - Test deploy endpoint returns 400 with UNAUTHORIZED_MULTISIG error_code when UnauthorizedMultisigException is raised
- Add type annotation to POLY_SAFE_SERVICE_NAMES for mypy - Chain UnauthorizedMultisigException with 'from settle_error' for pylint raise-missing-from
Code Review — OPE-1917Scope: Prepare middleware for PolySafe de-whitelisting on Polygon Key findings
Scope coverage
Not reviewed
🤖 Coding Agent [Beta] · Automated review against OPE-1917 |
OjusWiZard
left a comment
There was a problem hiding this comment.
address the unresolved comments
- Replace str(e) with hardcoded safe error string in UnauthorizedMultisigException handler to prevent stack trace information exposure (CodeQL finding). - Add unit test for the revert-attribution detection logic in _deploy_service_onchain_from_safe: mocks simulate_safe_sub_tx to return 'UnauthorizedMultisig' and asserts the method raises UnauthorizedMultisigException with the expected message.
|
Addressed 2 review comment(s) ([scope]: 1, CodeQL security: 1). |
OjusWiZard
left a comment
There was a problem hiding this comment.
Task A (constants.py) is correct and independently valuable — please keep it. Task B is the problem.
1. Drop Task B entirely (blocking)
We don't translate any other contract revert into a structured HTTP error — WrongServiceState, OnlyOwnServiceMultisig, ServiceMustBeInactive, staking-slot reverts and the rest all fall through to the generic 500. Special-casing one custom error out of that set is inconsistent, and it puts a substring match on a revert string into the deploy control flow, which is a fragile thing to maintain.
That is the main reason to drop it. But it also doesn't work, which is worth spelling out because it explains why CI is green:
UnauthorizedMultisig(address) is a Solidity custom error (defined on ServiceRegistryL2 and StakingToken), not a require string. simulate_safe_sub_tx does a raw eth_call with a plain dict and returns str(ContractLogicError); with no ABI in scope, web3 raises ContractCustomError(data, data=data) where the message is the raw ABI payload, never the name (web3/_utils/error_formatters_utils.py:126-133).
Reproduced against a realistic Geth revert response:
type: ContractCustomError
str(e): ('0x14460f20000000000000000000000000a749f605...', '0x14460f20...')
"UnauthorizedMultisig" in str(e) -> False
"0x14460f20" in str(e) -> True # keccak("UnauthorizedMultisig(address)")[:4]
So the any(...) at manage.py:1009 is always False, control falls to the bare raise at manage.py:1015, and the user gets exactly the generic 500 this PR set out to replace. exceptions.py, the cli.py handler and both new tests are dead weight around a branch that never executes.
Please revert B-1 through B-4 and ship Task A on its own. If the goal is to give users an actionable message for de-whitelisting, that belongs in a separate PR with a design that generalises — decoding revert data against the known contract ABIs once, in simulate_safe_sub_tx or nearby, so every custom error benefits rather than this one.
2. If you'd rather keep it, these all need fixing first
- Match the selector, not the name — compare against
0x14460f20/e.data, not the substring. - Verify the sub-tx simulation even reaches the whitelist check. Attribution simulates each sub-tx independently against live pre-batch state. The
deploysub-tx needsFINISHED_REGISTRATION, whichregister_instancesonly establishes later in the same batch — so depending onServiceRegistryL2.deploy's internal check ordering, the simulation may revert on the state precondition and never reach the multisig whitelist check at all. Needs confirming on a Tenderly fork before the match can be trusted. manage.py:1176-1179— the stepwise resume path has no equivalent handling. The mega-batch path is entered only fromPRE_REGISTRATIONor in-batch teardown (manage.py:793); a service interrupted after register but before deploy resumes stepwise, wheretx.settle()has notry/exceptat all. That's precisely the de-whitelisting scenario this PR targets, and it still returns a 500.manage.py:989-1007— the broadexceptwraps the whole loop, not each iteration. An RPC error on an early sub-tx aborts attribution, so the reverting sub-tx is never simulated anderrorsnever gets the marker. This was diagnostics-only before; the PR promoteserrorsto control flow, so incompleteness now silently changes the HTTP status. It also invertssimulate_safe_sub_tx's documented contract that transport errors "propagate, so callers fail loudly".
3. The new revert-attribution test confirms the bug rather than catching it
TestRevertAttributionUnauthorizedMultisig (added in d72d092) is the right shape — it finally reaches manage.py instead of injecting the exception downstream — but it is built on a fabricated fixture:
patch("operate.services.manage.simulate_safe_sub_tx",
return_value="UnauthorizedMultisig(0xABC)")Real simulate_safe_sub_tx never returns that. It returns str(ContractCustomError), which is the raw hex payload shown above. The mock hard-codes the very assumption that is wrong, so the test passes green while production silently takes the raise branch. A test whose fixture is invented rather than captured from the real call gives false confidence — if this handling is kept, pin the fixture to an actual revert string (a Tenderly fork, or a recorded ContractCustomError from a de-whitelisted factory).
The rest of the coverage picture is unchanged: _deploy_service_onchain_from_safe carries # pragma: no cover (manage.py:438), so coverage tooling won't flag the gap either. CLAUDE.md requires a Tenderly-backed integration test for transaction-flow changes; the precedent for this exact mega-batch flow is tests/test_manage_update_batch_integration.py. If Task B is dropped this all becomes moot.
4. Findings independent of Task B
tests/test_manage_unit.py:1620is tautological.assert POLY_SAFE_SERVICE_NAMES == frozenset()restatesconstants.py:102and catches only a revert of that one line. The behavioural assertion is thatget_deploy_data_from_safereceivesuse_poly_safe=Falsefor apolymarket_traderservice — currently unreachable because the enclosing method ispragma: no cover. Either make that assertion reachable or drop the test; as written it's a change-detector.constants.py:102— annotatefrozenset[str]rather than barefrozenset.protocol.py:1600-1614is now dead. With the constant empty,use_poly_safecan never beTrue, soget_poly_safe_deployment_payloadandPOLY_SAFE_CREATOR_WITH_RECOVERY_MODULE_CONTRACTare unreachable. Fine to leave for a follow-up, but worth a tracking issue rather than silent dead code.
Confirmed correct
Existing services are genuinely unaffected by Task A, as the description claims: they take the reuse_multisig=True branch of get_deploy_data_from_safe, which never consulted use_poly_safe (protocol.py:1559-1593). New deployments correctly fall through to safe_multisig_with_recovery_module.
Note on the latest two commits
d72d092c replaces str(e) in the cli.py handler with a hardcoded string. That message now exists in two places — manage.py:1010-1014 ("...is not whitelisted on-chain. Contact support.") and cli.py:1649 (same sentence, no "Contact support") — which will drift. It also leaves UnauthorizedMultisigException.__str__ with no remaining caller, and that override was already a no-op: Exception("boom") alone yields str(e) == "boom" when a single arg is passed, so both __init__ and __str__ on that class can go. All moot if Task B is dropped.
Drop Task B (UnauthorizedMultisigException) entirely per reviewer request — special-casing one contract revert is inconsistent and the substring match never fires against real ContractCustomError hex payloads. Remove the exception class, the manage.py detection block, the cli.py handler, and all associated tests. Also drop the tautological POLY_SAFE_SERVICE_NAMES constant test and annotate the constant as frozenset[str]. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed 4 review comment(s):
Also removed All lint, type, security, and unit test checks pass. Re-triggering review. |
jmoreira-valory
left a comment
There was a problem hiding this comment.
Review round 1 — ✅ Approve
What this does. Prepares the middleware for PolySafe being de-whitelisted on Polygon by emptying POLY_SAFE_SERVICE_NAMES, so new polymarket_trader deployments use the standard safe_multisig_with_recovery_module factory instead of the PolySafe factory. The originally-proposed Task B (translating the UnauthorizedMultisig revert into a structured HTTP 400) was dropped after review, leaving a one-line change.
Verified the change end-to-end at HEAD:
- Both consumers of
POLY_SAFE_SERVICE_NAMES(manage.py:932andmanage.py:1153) are pure membership checks, souse_poly_safeis now alwaysFalse. - In
protocol.py:get_deploy_data_from_safe,use_poly_safeis only consulted in the new-multisig branch; whenreuse_multisig=Trueit is ignored entirely — so the PR body's claim that existing services with an already-deployed Safe are unaffected holds. - New
polymarket_traderdeploys land in theuse_recovery_module and not use_poly_safebranch →SAFE_MULTISIG_WITH_RECOVERY_MODULE_CONTRACT, exactly the intended path. - No Task B remnants at HEAD:
operate/exceptions.pycontains only the pre-existingInsufficientFundsException; the prior CodeQL information-exposure alert oncli.pyreferred to the dropped handler and is moot. - The
frozenset[str]annotation requested by @OjusWiZard is in place. CI is green (21/21 checks).
Dropping Task B was the right call — "UnauthorizedMultisig" in str(ContractCustomError) could never match the raw ABI payload, as @OjusWiZard demonstrated.
Findings — 2 · 🔵 1 Low · ⚪ 1 Nit
| # | Sev | Location | Issue |
|---|---|---|---|
| F1 | 🔵 LOW | operate/constants.py |
PR description still documents the dropped Task B |
| F2 | ⚪ NIT | operate/services/protocol.py:1600 |
PolySafe deploy machinery is now unreachable — track a follow-up cleanup |
Additional notes (not anchored to a diff line):
- 🔵 F1 · LOW — PR description still documents the dropped Task B The PR body describes
UnauthorizedMultisigException, themanage.pyrevert-attribution change, thecli.py400 mapping, and their unit tests — none of which exist at HEAD (the diff is 1 line inconstants.py). Once merged, the description becomes the permanent record of the change and will mislead anyone doing archaeology on the PolySafe migration (e.g. searching for whereUNAUTHORIZED_MULTISIGwas introduced). Please trim the body to Task A before merging, optionally noting that Task B was dropped per review. - ⚪ F2 · NIT — PolySafe deploy machinery is now unreachable — track a follow-up cleanup With
POLY_SAFE_SERVICE_NAMESpermanently empty,use_poly_safeis alwaysFalse, so theuse_recovery_module and use_poly_safebranch here,get_poly_safe_deployment_payload, and thePOLY_SAFE_CREATOR_WITH_RECOVERY_MODULE_CONTRACTconfig are dead code. Keeping them during the transition is reasonable (rollback stays a one-line revert until de-whitelisting actually executes on-chain), but once the factory is de-whitelisted the code can never work again — worth a linked follow-up ticket to remove the PolySafe path and the constant itself.
…olysafe-de-whitelisting-on-polygon
Signed-off-by: OjusWiZard <ojuswimail@gmail.com>
1d58f2a
Signed-off-by: OjusWiZard <ojuswimail@gmail.com>
Implements: https://linear.app/valory/issue/OPE-1917
Summary
Prepares the middleware for the upcoming PolySafe de-whitelisting on Polygon. Removes
polymarket_traderfromPOLY_SAFE_SERVICE_NAMESso new Polystrat service deployments use the standardsafe_multisig_with_recovery_modulefactory instead of the soon-to-be-de-whitelisted PolySafe factory. AddsUnauthorizedMultisigExceptionwith structured error handling so that if the multisig creator contract is already de-whitelisted, the user gets a clear HTTP 400 with error codeUNAUTHORIZED_MULTISIGinstead of a generic 500.Technical Scope
Task A – Remove
polymarket_traderfromPOLY_SAFE_SERVICE_NAMESFile:
operate/constants.pyChange
POLY_SAFE_SERVICE_NAMESfromfrozenset(("polymarket_trader",))tofrozenset().Effect: new Polystrat service deployments will go through
safe_multisig_with_recovery_module(the standard path) instead of the soon-to-be-de-whitelisted PolySafe factory (0xA749f605D93B3efcc207C54270d83C6E8fa70fF8). Existing services with an already-deployed Safe are unaffected.Task B – Add
UnauthorizedMultisigExceptionand surface it as an actionable errorB-1 Define the exception
File:
operate/exceptions.pyAdd a new exception class
UnauthorizedMultisigException(modelled onInsufficientFundsException):msgstring.to_error_fields()method that returns{"error_code": "UNAUTHORIZED_MULTISIG"}.B-2 Raise the exception in the mega-batch revert-attribution block
File:
operate/services/manage.py– method_deploy_service_onchain_from_safeIn the
exceptblock that handles a reverted mega-batch (around L985-1015 wheresimulate_safe_sub_txis called per sub-transaction):simulate_safe_sub_txcall."UnauthorizedMultisig", raiseUnauthorizedMultisigExceptionwith a message like: "Service deployment failed: the multisig creator contract is not whitelisted on-chain. Contact support."settle_error(current behaviour, which ends up as a generic 500).B-3 Map the exception to an HTTP 400 in the deploy endpoint
File:
operate/cli.py– endpoint_deploy_and_run_serviceAdd a new
except UnauthorizedMultisigException as ehandler (between the existingInsufficientFundsExceptionhandler and the genericExceptionhandler):{"error": str(e), **e.to_error_fields()}.B-4 Unit tests
POLY_SAFE_SERVICE_NAMESis now empty.UnauthorizedMultisigException.__str__and.to_error_fields().deploy_service_onchain_from_safeto raiseUnauthorizedMultisigException, assert 400 response with the expectederror_code.