Skip to content

RTC payment verification rejects every settled transfer: history rows carry no status field #964

Description

@stmr

RTC payment verification can never succeed: settled transfers carry no status field

_verify_rtc_payment reads the recipient's transfer history and then gates on a status field:

status = str(matching_tx.get("status", "")).strip().lower()
if status not in _FINAL_RTC_STATUSES:          # settled, confirmed, completed, finalized, success, paid
    return {"verified": False, "error": f"unsettled_rtc_tx:{status or 'missing'}"}

A read-only request to https://rustchain.org/wallet/history?miner_id=stmr in this session returned four already received and settled transfers. Every one of them has exactly these keys:

amount, epoch, from, reason, timestamp, tx_hash, type

There is no status key on a settled transfer, so matching_tx.get("status", "") yields "", which is not in _FINAL_RTC_STATUSES, and every genuine payment is rejected with unsettled_rtc_tx:missing. The other checks (amount, sender match, replay, freshness) are all reachable only after this gate.

Running the module's own _verify_rtc_payment against that exact payload:

verification_with_live_payload:      {"verified": false, "error": "unsettled_rtc_tx:missing"}
verification_with_status_supplied:   {"verified": true, "tx_hash": "dde21...", "amount_rtc": 12.0}

The control differs only by adding the status field the API does not send, which confirms the status gate is the sole blocker. Net effect: the RTC payment path for x402-gated services rejects all settled payments, so those endpoints stay unpaid-access-only.

A second, related issue in the same gate: RTC_PAYMENT_MAX_AGE_SECONDS defaults to 3600, and the age is computed against the transfer timestamp, so even with the status problem fixed a payer who requests their service more than an hour after paying is rejected as stale_rtc_tx.

Suggested fix: treat the absence of status on a history row as settled when the row is present in the wallet's own history (that is what "it is in my history" means), or derive settlement from epoch/type the way the rest of the codebase does. Raise or re-base the freshness window so it does not silently reject slow follow-ups.

Reproduction

This executes the repository's real _verify_rtc_payment from main, feeding it the real history payload captured above. requests is stubbed so no network call is made, and RTC_PAYMENT_MAX_AGE_SECONDS=0 isolates the status gate from the age gate.

import ast
import copy
import json
import os
import sys
import types
from urllib.request import urlopen

URL = 'https://raw.githubusercontent.com/Scottcjn/beacon-skill/main/beacon_skill/x402_bridge.py'
source = urlopen(URL, timeout=40).read().decode()
tree = ast.parse(source)

# Real, already-settled transfers read from the public endpoint in this session.
LIVE_HISTORY = {
    'miner_id': 'stmr',
    'ok': True,
    'total': 4,
    'transactions': [
        {'amount': 12, 'epoch': 32983, 'from': 'founder_team_bounty',
         'reason': 'transfer_in:founder_team_bounty:dde21dc9c56167093b1adcc5b3d6b2b8',
         'timestamp': 1784585686, 'tx_hash': 'dde21dc9c56167093b1adcc5b3d6b2b8', 'type': 'transfer_in'},
        {'amount': 3, 'epoch': 27572, 'from': 'founder_community',
         'reason': 'transfer_in:founder_community:5650b8f8b87f5dd2eac3e07a227171ca',
         'timestamp': 1781343064, 'tx_hash': '5650b8f8b87f5dd2eac3e07a227171ca', 'type': 'transfer_in'},
    ],
}


class FakeResponse:
    ok = True

    def __init__(self, payload):
        self._payload = payload

    def json(self):
        return self._payload


class FakeRequests:
    """Stands in for `requests` inside _verify_rtc_payment; no network is used."""

    def __init__(self, payload):
        self.payload = payload

    def get(self, url, params=None, verify=None, timeout=None):
        assert url.endswith('/wallet/history'), url
        assert params == {'miner_id': 'stmr'}, params
        assert verify is True and timeout == 10
        return FakeResponse(self.payload)


os.environ['RTC_PAY_TO'] = 'stmr'
os.environ['RTC_PAYMENT_MAX_AGE_SECONDS'] = '0'  # disable the age gate so the status gate is isolated

node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == '_verify_rtc_payment')
WANTED = {'PRICING', '_VERIFIED_RTC_TX_HASHES', '_FINAL_RTC_STATUSES', 'X402_VERSION',
          'BASE_CHAIN_ID', 'USDC_BASE', 'RUSTCHAIN_API_BASE', 'PAY_TO_ADDRESS', 'X402_FACILITATOR'}
consts = [n for n in tree.body if isinstance(n, (ast.Assign, ast.AnnAssign))
          and any(getattr(t, 'id', '') in WANTED
                  for t in (n.targets if isinstance(n, ast.Assign) else [n.target]))]

ns = {'os': os, 'json': json, 'time': __import__('time'), 'Dict': dict, 'Any': object}
exec(compile(ast.Module(body=consts + [node], type_ignores=[]), URL, 'exec'), ns)
verify = ns['_verify_rtc_payment']

tx = LIVE_HISTORY['transactions'][0]
header = json.dumps({'tx_hash': tx['tx_hash'], 'amount_rtc': float(tx['amount']),
                     'from_wallet': tx['from'], 'to_wallet': 'stmr'})

fake_requests = types.ModuleType('requests')
fake_requests.get = FakeRequests(copy.deepcopy(LIVE_HISTORY)).get
sys.modules['requests'] = fake_requests
result_live = verify(header, 'atlas_query')

# Control: identical transfer, with only the absent status field supplied.
control_history = copy.deepcopy(LIVE_HISTORY)
control_history['transactions'][0]['status'] = 'settled'
fake_requests.get = FakeRequests(control_history).get
result_control = verify(header, 'atlas_query')

print(json.dumps({
    'transfer_used': {'tx_hash': tx['tx_hash'], 'amount': tx['amount'], 'from': tx['from'],
                      'keys_present': sorted(tx.keys())},
    'verification_with_live_payload': result_live,
    'verification_with_status_supplied': result_control,
}, indent=2))

Output:

{
  "transfer_used": {
    "tx_hash": "dde21dc9c56167093b1adcc5b3d6b2b8",
    "amount": 12,
    "from": "founder_team_bounty",
    "keys_present": [
      "amount",
      "epoch",
      "from",
      "reason",
      "timestamp",
      "tx_hash",
      "type"
    ]
  },
  "verification_with_live_payload": {
    "verified": false,
    "error": "unsettled_rtc_tx:missing"
  },
  "verification_with_status_supplied": {
    "verified": true,
    "tx_hash": "dde21dc9c56167093b1adcc5b3d6b2b8",
    "amount_rtc": 12.0
  }
}

Scope: this is a fail-closed rejection of legitimate payments. No payment was forged, replayed or bypassed, no endpoint was called, and no account or key was used.

Relationship to existing work

Issue #860 reported that the RTC fallback did not verify the actual transfer, and PR #861 added the history verification. This report does not repeat that: it concerns the verification introduced there rejecting genuine settled payments because of the status gate. If #861 already addresses it on a newer branch than the code I read, please close this as a duplicate.

Duplicate screening: beacon-skill "_verify_rtc_payment" returns only #860, beacon-skill "unsettled_rtc_tx" only PR #861, and beacon-skill "x402" "status" only PR #861.

AI assistance disclosure: source inspection, live read-only payload capture, local reproduction and report preparation were AI-assisted.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions