A fallback can restore evidence collection, but it can also create a dangerous shortcut. If one explorer says a contract is unverified, should a second endpoint be allowed to approve it? If the first endpoint times out, what facts must the second return before the safety check may continue?
This edition builds an offline decision layer for that boundary. The example is synthetic. It does not inspect a live token, connect to a wallet or submit an order. You will finish with a small program that permits fallback only for technical failures, applies the same safety policy to both providers and blocks every incomplete or contradictory result.
Reader problem and evidence inspected
On October 12, 2026, repository pull request 635 recorded a specific operational problem: all 20 contract-source reads observed on October 11 and 12 ended with “contract source record unavailable.” The merged change added Blockscout's Etherscan-compatible getsourcecode endpoint after the existing v2 endpoint failed. We inspected app/robinhood_contract_safety.py and its tests at merge commit 4b1720ca9dcc25f3c93665d152a40f1ad4978a88, then rechecked the trading branch at 2c6f718a69a115124db5cecdfc9ba0366bfecd07.
A separate public demand signal is Blockscout issue 10869, opened October 3, 2024. Its author could see blocks and transactions but could not verify contracts until an endpoint configuration error was corrected. That issue concerns contract verification rather than our exact read path. It shows that verification evidence can fail independently of general explorer availability, but it does not measure how often this happens.
Railway provides an important limit. Every service deployment record for the October 12 safety commit was SKIPPED. We therefore verified a repository change and tests, not changed production behavior. We found no funded result for this article.
Research question and prerequisites
Our question is: How can a second contract-source endpoint recover from a technical failure without overriding a semantic rejection or weakening the safety policy?
Use Python 3.12, an empty directory and about 30 minutes. The program needs only the standard library. The seven named cases are invented. Before running it, predict that three cases will be allowed and four blocked. Also predict that an explicit UNVERIFIED response will stop after the primary call even when a clean fallback fixture exists.
1. Separate availability from meaning
The first rule is classification. UNAVAILABLE and MALFORMED mean the primary endpoint did not provide usable evidence. Those states may invoke the fallback. UNVERIFIED is different. It is a usable negative answer, so the fallback must not shop for a more favorable verdict.
Unknown statuses also block. A new provider response cannot silently inherit permission simply because the code does not recognize it.
2. Keep policy outside both providers
Neither endpoint returns ALLOW. Each returns observations. The local decision layer requires verified source, a standard interface, no proxy indicator and no risky state-changing function. The same function examines primary and fallback records.
This matters because redundancy is not policy. A second endpoint improves availability only when its evidence passes the same rules. Blockscout's Robinhood API guide documents multiple API forms, and its migration guide lists getsourcecode support. Those documents establish available interfaces. They do not establish that either response is safe for a trading decision.
NIST SP 800-160 Volume 1, Revision 1 describes protective defaults and protective failure: protection should remain effective during failure, and a single component failure should not create unacceptable loss. In this lab, loss prevention means lack of trustworthy evidence becomes BLOCK, not permission.
3. Run the complete lab
Save the following as fallback_lab.py and run python3 -I fallback_lab.py. The isolation flag prevents Python from adding user site packages and the current directory to its import path.
from itertools import product
RETRYABLE = {"UNAVAILABLE", "MALFORMED"}
def answer(status, *, verified=None, standard=None, proxy=None, risks=()):
payload = None
if status == "OK":
payload = {
"verified": verified,
"standard": standard,
"proxy": proxy,
"risks": list(risks),
}
return {"status": status, "payload": payload}
def inspect_record(record):
if record.get("status") == "UNVERIFIED":
return "BLOCK", "source_unverified"
if record.get("status") != "OK":
return "BLOCK", "invalid_provider_status"
payload = record.get("payload")
if not isinstance(payload, dict):
return "BLOCK", "evidence_malformed"
verified = payload.get("verified")
standard = payload.get("standard")
proxy = payload.get("proxy")
risks = payload.get("risks")
if (type(verified) is not bool or type(standard) is not bool or
type(proxy) is not bool or not isinstance(risks, list) or
any(not isinstance(item, str) or not item for item in risks)):
return "BLOCK", "evidence_malformed"
if not verified:
return "BLOCK", "source_unverified"
if proxy:
return "BLOCK", "proxy_detected"
if not standard:
return "BLOCK", "abi_not_standard"
if risks:
return "BLOCK", "risky_function:" + sorted(risks)[0]
return "ALLOW", "verified_standard_nonproxy"
def decide(primary, fallback):
calls = ["primary"]
first = primary()
if first.get("status") in RETRYABLE:
calls.append("fallback")
second = fallback()
if second.get("status") in RETRYABLE:
return "BLOCK", "none", calls, "evidence_unavailable"
verdict, reason = inspect_record(second)
return verdict, "fallback", calls, reason
verdict, reason = inspect_record(first)
return verdict, "primary", calls, reason
def fixed(record):
return lambda: record
CLEAN = answer("OK", verified=True, standard=True, proxy=False)
CASES = {
"primary_clean": (CLEAN, answer("UNAVAILABLE")),
"fallback_clean": (answer("UNAVAILABLE"), CLEAN),
"primary_unverified": (answer("UNVERIFIED"), CLEAN),
"fallback_proxy": (
answer("UNAVAILABLE"),
answer("OK", verified=True, standard=True, proxy=True),
),
"both_down": (answer("UNAVAILABLE"), answer("UNAVAILABLE")),
"fallback_mint": (
answer("UNAVAILABLE"),
answer("OK", verified=True, standard=True, proxy=False, risks=("mint",)),
),
"malformed_then_clean": (answer("MALFORMED"), CLEAN),
}
EXPECTED = {
"primary_clean": ("ALLOW", "primary", ["primary"],
"verified_standard_nonproxy"),
"fallback_clean": ("ALLOW", "fallback", ["primary", "fallback"],
"verified_standard_nonproxy"),
"primary_unverified": ("BLOCK", "primary", ["primary"],
"source_unverified"),
"fallback_proxy": ("BLOCK", "fallback", ["primary", "fallback"],
"proxy_detected"),
"both_down": ("BLOCK", "none", ["primary", "fallback"],
"evidence_unavailable"),
"fallback_mint": ("BLOCK", "fallback", ["primary", "fallback"],
"risky_function:mint"),
"malformed_then_clean": ("ALLOW", "fallback", ["primary", "fallback"],
"verified_standard_nonproxy"),
}
checks = 0
results = {}
for name, (primary_record, fallback_record) in CASES.items():
result = decide(fixed(primary_record), fixed(fallback_record))
if result != EXPECTED[name]:
raise AssertionError((name, result, EXPECTED[name]))
checks += 1
results[name] = result
verdict, source, calls, reason = result
print(f"case={name} verdict={verdict} source={source} "
f"calls={','.join(calls)} reason={reason}")
allow = sum(result[0] == "ALLOW" for result in results.values())
block = len(results) - allow
fallback_used = sum(result[2] == ["primary", "fallback"]
for result in results.values())
print(f"summary allow={allow} block={block} fallback_used={fallback_used}")
bad_payload = {"status": "OK", "payload": {"verified": True}}
negative = {
"unknown_status": decide(fixed({"status": "MAYBE"}), fixed(CLEAN)),
"missing_fields": decide(fixed(bad_payload), fixed(CLEAN)),
"semantic_reject": decide(fixed(answer("UNVERIFIED")), fixed(CLEAN)),
}
negative_expected = {
"unknown_status": ("BLOCK", "primary", ["primary"],
"invalid_provider_status"),
"missing_fields": ("BLOCK", "primary", ["primary"],
"evidence_malformed"),
"semantic_reject": ("BLOCK", "primary", ["primary"],
"source_unverified"),
}
for name, result in negative.items():
if result != negative_expected[name]:
raise AssertionError((name, result, negative_expected[name]))
checks += 1
print(f"negative={name} verdict={result[0]} "
f"calls={','.join(result[2])} reason={result[3]}")
allowed_truth_rows = 0
for verified, standard, proxy, risky in product((False, True), repeat=4):
record = answer("OK", verified=verified, standard=standard,
proxy=proxy, risks=("mint",) if risky else ())
verdict, _ = inspect_record(record)
expected_allow = verified and standard and not proxy and not risky
if (verdict == "ALLOW") != expected_allow:
raise AssertionError("TRUTH_TABLE_MISMATCH")
allowed_truth_rows += verdict == "ALLOW"
checks += 1
print(f"truth_table rows=16 allowed={allowed_truth_rows}")
print(f"checks={checks} passed; external_calls=0 orders=0")
Expected complete stdout:
case=primary_clean verdict=ALLOW source=primary calls=primary reason=verified_standard_nonproxy
case=fallback_clean verdict=ALLOW source=fallback calls=primary,fallback reason=verified_standard_nonproxy
case=primary_unverified verdict=BLOCK source=primary calls=primary reason=source_unverified
case=fallback_proxy verdict=BLOCK source=fallback calls=primary,fallback reason=proxy_detected
case=both_down verdict=BLOCK source=none calls=primary,fallback reason=evidence_unavailable
case=fallback_mint verdict=BLOCK source=fallback calls=primary,fallback reason=risky_function:mint
case=malformed_then_clean verdict=ALLOW source=fallback calls=primary,fallback reason=verified_standard_nonproxy
summary allow=3 block=4 fallback_used=5
negative=unknown_status verdict=BLOCK calls=primary reason=invalid_provider_status
negative=missing_fields verdict=BLOCK calls=primary reason=evidence_malformed
negative=semantic_reject verdict=BLOCK calls=primary reason=source_unverified
truth_table rows=16 allowed=1
checks=26 passed; external_calls=0 orders=0
4. Read the result as an evidence path
primary_clean allows without consulting the fallback. fallback_clean allows only after the primary reports a technical failure and the second record passes every invariant. malformed_then_clean demonstrates the same recovery for an unusable primary record.
The negative cases are more important. primary_unverified blocks after one call. The clean fallback fixture is intentionally ignored. fallback_proxy and fallback_mint prove that changing endpoints does not change policy. both_down blocks with evidence_unavailable, which is operationally different from calling the contract unsafe.
The independent truth table covers all 16 combinations of verified source, standard interface, proxy indicator and risky-function indicator. Exactly one combination allows. There are 26 checks in total, and the complete stdout was compared byte for byte with the expected block before publication.
5. Apply the pattern without touching execution
For a real integration, define a provider result schema before writing network code. Record provider identity, endpoint family, observed time, transport status, parsing status and the normalized facts used by policy. Do not log API keys, wallet identifiers or raw payloads that may contain sensitive data.
Then write three explicit lists:
- Technical states that may use the fallback, such as timeout, forbidden response from a misconfigured client or malformed JSON.
- Semantic states that are final negatives, such as unverified source.
- Invariants every usable record must satisfy before approval.
Keep the order permission in a separate component. The evidence collector may report ALLOW in this educational program, but a production collector should normally return facts and a reasoned safety verdict to an independent authorization boundary. A safety-source success is not a fill, profit result or model validation.
Competing guidance and the added artifact
We searched Blockscout's official API, migration and verification guidance plus the public issue above on October 13, 2026. The official material explains endpoint forms, contract verification and configuration. It does not provide this exact fallback decision table. That bounded sample does not prove the topic lacks other coverage.
The added artifact is a runnable, provider-neutral policy harness. It distinguishes technical failure from semantic rejection, records which provider was used, rejects malformed states and checks every combination of four safety facts.
Troubleshooting, limits and completion check
If your expected output differs, check whether a semantic negative accidentally enters RETRYABLE. If a proxy or risky function passes through the fallback, confirm that both provider paths call the same inspect_record function. If a new status appears, add a reviewed classification and a negative test before enabling it.
This lab does not prove the two endpoints are independent. They may share infrastructure or underlying data. It does not verify bytecode, proxy storage slots, function signatures, cache behavior, rate limits or authenticity of the source response. The fallback also increases request cost and complexity. The manual fallback is to keep the candidate blocked and inspect the evidence record without submitting an order.
Completion requires exact stdout equality, calls=primary for primary_unverified, one allowed truth-table row and a written explanation of why both_down is unavailable evidence rather than a safe contract. The next experiment will add signed fixtures, cache expiration and a simulated provider disagreement, then test whether the evidence record remains reproducible after restart.