Files
arkhe-origin-chain/scripts/e2e/rv17_tesoro.py
T
CharonandClaude Opus 5.5 4be7da78c3 feat(gobierno): GSUN-3, dos tercios para el tesoro (spec 21, pesos por certificar)
David, 2-nov-2026: «Sí a la súper-mayoría, dos tercios».

- Una propuesta de tesoro se aprueba sólo si su Aye es al menos dos tercios del voto
  emitido (Aye + Nay; la abstención no cuenta), además del quórum y de la mayoría simple.
- Se fija al crear la propuesta (storage TreasurySupermajorityOf) y se consume al decidirla.
  Las de tesoro abiertas antes de spec 21 conservan la mayoría simple con la que se abrieron.
- Revertir el veto de una propuesta de tesoro exige los dos tercios frente a TODA la
  oposición: el Nay original más lo que sostuvo el veto en la ventana. Es el bloqueante de
  la revisión adversaria: con Nay 4Q y 2Q sosteniendo, 8Q pagaban cuando el voto ordinario
  pedía 12Q.
- Evento nuevo ProposalRejectedForSupermajority, al final del enum. Sin migración y con
  transaction_version igual.
- reaches_supermajority: exacta (aye por den >= num por emitido), monótona, sin Aye nunca
  aprueba y falla cerrada si desborda. Un const assert exige que la fracción sea mayor que 1/2.
- Pruebas: 18 contrafactuales en rojo (17 en su aserción, 1 en compilación), 114 tests del
  pallet y 373 del workspace.
- E2E: pasos 2c (la propuesta nueva guarda 2/3), 2d (la sembrada en spec 18 no) y 2e (la
  frontera del upgrade sobre todas las propuestas de tesoro).

Falta antes de main: el certificado de Laurha para los tres pesos que cambian de esqueleto
(submit_treasury_proposal, finalize_proposal, finalize_override), el E2E vivo y el upgrade.
Sigue abierto, desde RV17: el override de una propuesta ORDINARIA no cuenta lo que sostiene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:03:52 -05:00

385 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""E2E de RV17 contra un nodo vivo, firmando como un wallet: la acción del tesoro queda atada
a la propuesta y nada la cambia después.
ORIGIN_RPC=http://127.0.0.1:9955 python3 scripts/e2e/rv17_tesoro.py # pasos 0-5
ORIGIN_RPC=... python3 scripts/e2e/rv17_tesoro.py sembrar # además, siembra el pago
ORIGIN_RPC=... python3 scripts/e2e/rv17_tesoro.py cerrar <id_hex> # a los 14 días: aprueba y paga
Lo que NO prueba este guion: el camino feliz del pago. Exige 14 días de votación con el quórum
de la propuesta (el 10 % de la emisión: ~10M OGC en la testnet de la casa, que Alice alcanza),
y luego el sudo del pago. Ese camino lo cubren los tests del pallet, y en vivo se siembra con
una propuesta y se cierra a los 14 días. Aquí se mide lo que sólo se ve en la cadena: la
codificación de la llamada nueva, el storage por RPC, el peso que se cobra y los errores tal
como llegan.
Comprueba, en este orden (se detiene en el primer fallo, código de salida 1):
0. el node NO decodifica las llamadas: los bytes de una llamada que no existe (Governance 99)
los rechaza el runtime, no el cliente. Un node con el `Block` completo del runtime los
rechaza con 1040 «Client error: Could not decode», y con ese mismo error rechaza también
una llamada nueva que un `set_code` acaba de añadir (medido en la rp1 el 2026-10-28);
1. tesoro:<monto> entra y emite TreasuryProposalSubmitted. Lo cobrado es el peso que declara
weights.rs más el de las extensiones de la transacción (nonce, fee, CheckWeight…), que es
el mismo para toda llamada: se mide con un treasury_deposit y el residuo tiene que
coincidir. Prueba el cableado; que el número sea el coste real lo prueba el bench;
2. Proposals(id) existe y TreasuryActions(id) = (Bob, monto), leído del storage por RPC;
2b. el quórum guardado es el 10 % de lo que puede votar (spec 19);
2c. desde spec 21 (GSUN-3), TreasurySupermajorityOf(id) = (2, 3): la propuesta de tesoro
nueva guarda sus dos tercios. Contra spec 20 este paso reprueba;
2d. la propuesta sembrada en spec 18 (SEMBRADA), si existe en esta cadena, NO tiene esa
entrada: se decide con la mayoría simple con la que se abrió;
2e. la frontera del upgrade: B es el primer bloque cuyo estado ya dice spec 21 (el del
set_code, que aún ejecutó el runtime anterior). Toda propuesta de tesoro con
start_block <= B no tiene marca, y toda la posterior la tiene. Se enumeran TODAS las
de TreasuryActions, no sólo la sembrada (revisión adversarial de GSUN-3);
3. tesoro:0 falla con TreasuryAmountBelowMinimum;
4. reenviar la misma propuesta falla con ProposalIdCollision y TreasuryActions no cambia;
5. sudo(treasury_disbursement) de esa propuesta, aún en votación, devuelve ProposalNotApproved
y no mueve el saldo de Bob.
"""
import hashlib, json, os, re, subprocess, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from upgrade_en_vivo import RAIZ, TOOL, ALICE, rpc, best, eventos, falla # noqa: E402
GOV = os.path.join(RAIZ, "crates/origin-chain-governance/src")
# ORIGIN_E2E_ETIQUETA cambia el título de la propuesta, y con él su id: hace falta para repetir el
# E2E en una cadena que ya tiene la propuesta de una corrida anterior.
ETIQUETA = os.environ.get("ORIGIN_E2E_ETIQUETA", "")
TESORO = "tesoro:{}" + (f":{ETIQUETA}" if ETIQUETA else "")
TITULO = hashlib.blake2b(b"e2e-rv17/titulo" + (f"/{ETIQUETA}".encode() if ETIQUETA else b""), digest_size=32).hexdigest()
# Cuentas de pallet («modl» ++ PalletId ++ ceros) cuyo OGC vuelve a una cuenta con llave y por eso
# SÍ cuenta como votable: los bonds y los depósitos de revive (configs.rs). Las demás cuentas de
# pallet no votan. El E2E las DESCUBRE recorriendo las cuentas de la cadena, no copia la lista del
# runtime: una omisión compartida pasaría en los dos.
VUELVEN = {(b"modl" + pid).ljust(32, b"\0").hex() for pid in (b"og/props", b"py/reviv")}
MONTO = 5 * 10**18 # 5 OGC
# El pago sembrado en la testnet de la casa el 28-oct (spec 18), que se cierra hacia el 26-nov.
SEMBRADA = "fe92182ecff4df083b42da838a509c82aab7a4d0213a902e5a5a89729bd06ad0"
# GSUN-3: la mayoría cualificada del tesoro, (num, den) como dos u32 en little endian.
DOS_TERCIOS = "0x" + (2).to_bytes(4, "little").hex() + (3).to_bytes(4, "little").hex()
BOB = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
BOB_HEX = "8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48"
def xxh64(data, seed):
P1, P2, P3, P4, P5 = (11400714785074694791, 14029467366897019727, 1609587929392839161,
9650029242287828579, 2870177450012600261)
M = (1 << 64) - 1
rotl = lambda x, r: ((x << r) | (x >> (64 - r))) & M # noqa: E731
def rnd(acc, x):
return (rotl((acc + x * P2) & M, 31) * P1) & M
n, i = len(data), 0
if n >= 32:
v = [(seed + P1 + P2) & M, (seed + P2) & M, seed, (seed - P1) & M]
while i + 32 <= n:
for k in range(4):
v[k] = rnd(v[k], int.from_bytes(data[i + 8 * k:i + 8 * k + 8], "little"))
i += 32
h = (rotl(v[0], 1) + rotl(v[1], 7) + rotl(v[2], 12) + rotl(v[3], 18)) & M
for k in range(4):
h = ((h ^ rnd(0, v[k])) * P1 + P4) & M
else:
h = (seed + P5) & M
h = (h + n) & M
while i + 8 <= n:
h = (rotl(h ^ rnd(0, int.from_bytes(data[i:i + 8], "little")), 27) * P1 + P4) & M
i += 8
if i + 4 <= n:
h = (rotl(h ^ ((int.from_bytes(data[i:i + 4], "little") * P1) & M), 23) * P2 + P3) & M
i += 4
while i < n:
h = (rotl(h ^ ((data[i] * P5) & M), 11) * P1) & M
i += 1
h ^= h >> 33
h = (h * P2) & M
h ^= h >> 29
h = (h * P3) & M
return h ^ (h >> 32)
def twox128(s):
b = s.encode()
return (xxh64(b, 0).to_bytes(8, "little") + xxh64(b, 1).to_bytes(8, "little")).hex()
def clave(item, id_hex):
"""StorageMap con Blake2_128Concat: twox128(pallet) ++ twox128(item) ++ blake2_128(k) ++ k."""
k = bytes.fromhex(id_hex)
return "0x" + twox128("Governance") + twox128(item) + hashlib.blake2b(k, digest_size=16).hexdigest() + k.hex()
def indice_de_error(nombre):
"""Índice SCALE de una variante de `Error<T>`: su posición en el enum del pallet."""
src = open(os.path.join(GOV, "lib.rs")).read()
cuerpo = re.search(r"pub enum Error<T> \{(.*?)\n \}", src, re.S).group(1)
sin_comentarios = re.sub(r"//[^\n]*", "", cuerpo)
variantes = re.findall(r"^\s*([A-Z]\w*)\s*,", sin_comentarios, re.M)
return variantes.index(nombre)
def peso_declarado(fn):
"""ref_time y proof_size que declara weights.rs, con RocksDbWeight (25 µs leer, 100 µs escribir)."""
src = open(os.path.join(GOV, "weights.rs")).read()
impl = src.split("for SubstrateWeight<T> {")[1].split("\nimpl WeightInfo for ()")[0]
b = re.search(r"fn " + fn + r"\(\) -> Weight \{(.*?)\n\t\}", impl, re.S).group(1)
n = lambda pat: int(re.search(pat, b).group(1).replace("_", "")) # noqa: E731
ref = n(r"from_parts\(([\d_]+), 0\)") + n(r"reads\((\d+)_u64") * 25_000_000 + n(r"writes\((\d+)_u64") * 100_000_000
return ref, n(r"from_parts\(0, ([\d_]+)\)")
def firmar(llamada, nonce, genesis, v):
return subprocess.run([TOOL, "firmar", llamada, str(nonce), genesis, str(v["specVersion"]),
str(v["transactionVersion"])], capture_output=True, text=True, check=True).stdout.strip()
def enviar(llamada, genesis, v):
"""Firma con el nonce siguiente de Alice, envía y devuelve los eventos del bloque que la incluye."""
n0 = best()
nonce = rpc("system_accountNextIndex", [ALICE])["result"]
xt = firmar(llamada, nonce, genesis, v)
r = rpc("author_submitExtrinsic", [xt])
if "error" in r:
falla(f"{llamada}: rechazada en el pool: {r['error']}")
t0 = time.time()
while time.time() - t0 < 60:
for n in range(n0 + 1, best() + 1):
h = rpc("chain_getBlockHash", [n])["result"]
blk = rpc("chain_getBlock", [h])["result"]["block"]
# Un node con el Block concreto entrega cada extrinsic como lista de bytes; el
# opaco, como cadena hex (lo que espera polkadot.js). Se aceptan las dos.
hexes = [x if isinstance(x, str) else "0x" + bytes(x).hex() for x in blk["extrinsics"]]
if xt in hexes:
pos = hexes.index(xt)
evs = [l for l in eventos(n).splitlines() if f"ApplyExtrinsic({pos})" in l]
return n, evs
time.sleep(2)
falla(f"{llamada}: no se incluyó en 60 s")
def cuentas_de_pallet(bloque):
"""Todas las cuentas «modl…» con entrada en System::Account en `bloque`, leídas del storage."""
pref = "0x" + twox128("System") + twox128("Account")
cuentas, desde = [], pref
while True:
ks = rpc("state_getKeysPaged", [pref, 1000, desde, bloque])["result"]
cuentas += [k[-64:] for k in ks if k[-64:].startswith(b"modl".hex())]
if len(ks) < 1000:
return cuentas
desde = ks[-1]
def saldo_total(cuenta_hex, bloque=None):
"""free + reserved de una cuenta, como `total_balance` del runtime."""
k = "0x" + twox128("System") + twox128("Account") + hashlib.blake2b(bytes.fromhex(cuenta_hex), digest_size=16).hexdigest() + cuenta_hex
v = rpc("state_getStorage", [k] + ([bloque] if bloque else []))["result"]
d = bytes.fromhex(v[2:]) if v else b""
return int.from_bytes(d[16:32], "little") + int.from_bytes(d[32:48], "little") if d else 0
def saldo_libre(cuenta_hex):
k = "0x" + twox128("System") + twox128("Account") + hashlib.blake2b(bytes.fromhex(cuenta_hex), digest_size=16).hexdigest() + cuenta_hex
v = rpc("state_getStorage", [k])["result"]
return int.from_bytes(bytes.fromhex(v[2:])[16:32], "little") if v else 0 # AccountInfo: nonce,consumers,providers,sufficients (4×u32) y luego free
def main(sembrar_pago=False):
genesis = rpc("chain_getBlockHash", [0])["result"]
v = rpc("state_getRuntimeVersion")["result"]
print(f"spec {v['specVersion']} · best #{best()}")
if v["specVersion"] < 18:
falla("RV17 llega con la spec 18")
nonce = rpc("system_accountNextIndex", [ALICE])["result"]
xt = firmar(TESORO.format(MONTO), nonce, genesis, v)
titulo = TITULO
if xt.count("0a10" + titulo) != 1:
falla("no encuentro la llamada Governance 16 en el extrinsic")
r = rpc("author_submitExtrinsic", [xt.replace("0a10" + titulo, "0a63" + titulo)])
e = r.get("error", {})
if not e:
falla("una llamada que no existe ENTRÓ en el pool")
if e.get("code") == 1040 or "Client error: Could not decode" in e.get("message", ""):
falla(f"el node decodifica las llamadas con sus tipos nativos: {e}")
print(f"0 ✅ llamada inexistente → la rechaza el runtime, no el cliente: {e.get('code')} {e.get('message', '')[:70]!r}")
def cobrado(evs):
ok = [e for e in evs if "ExtrinsicSuccess" in e]
if not ok:
falla("no hay ExtrinsicSuccess\n" + "\n".join(evs))
return (int(re.search(r"ref_time: (\d+)", ok[0]).group(1)),
int(re.search(r"proof_size: (\d+)", ok[0]).group(1)))
def residuo(evs, fn):
c, d = cobrado(evs), peso_declarado(fn)
return (c[0] - d[0], c[1] - d[1])
_, evs = enviar(f"depositar:{2 * MONTO}", genesis, v)
extensiones = residuo(evs, "treasury_deposit")
n, evs = enviar(TESORO.format(MONTO), genesis, v)
sub = [e for e in evs if "TreasuryProposalSubmitted" in e]
if not sub:
falla("tesoro: no hay TreasuryProposalSubmitted\n" + "\n".join(evs))
id_hex = bytes(json.loads(re.search(r"ProposalId\((\[[\d, ]+\])\)", sub[0]).group(1))).hex()
if residuo(evs, "submit_treasury_proposal") != extensiones:
falla(f"cobrado {cobrado(evs)} ≠ declarado {peso_declarado('submit_treasury_proposal')} + extensiones {extensiones}")
print(f"1 ✅ #{n}: TreasuryProposalSubmitted id 0x{id_hex[:12]}… · cobrado = weights.rs "
f"{peso_declarado('submit_treasury_proposal')} + extensiones {extensiones} (las mismas que treasury_deposit)")
if not rpc("state_getStorage", [clave("Proposals", id_hex)])["result"]:
falla("Proposals(id) no existe en el storage")
acc = rpc("state_getStorage", [clave("TreasuryActions", id_hex)])["result"]
if not acc:
falla("TreasuryActions(id) no existe en el storage")
b = bytes.fromhex(acc[2:])
if b[:32].hex() != BOB_HEX or int.from_bytes(b[32:48], "little") != MONTO or len(b) != 48:
falla(f"TreasuryActions(id) = {acc}, no (Bob, {MONTO})")
print(f"2 ✅ storage por RPC: Proposals(id) existe · TreasuryActions(id) = (Bob, {MONTO})")
# 2b · el quórum guardado es el 10 % de lo que PUEDE votar (David, 29-oct): la emisión menos
# las cuentas sin llave, al despachar. Se reconstruye desde el estado final del bloque más lo
# que quemó la propia transacción DESPUÉS de despacharla: la comisión se retira antes
# (`Withdraw`) pero se quema al final (`BurnedDebt`), así que al leer el quórum la emisión
# todavía la incluye (medido en el ensayo de spec 19: la diferencia era BurnedDebt / 10).
h = rpc("chain_getBlockHash", [n])["result"]
leer = lambda k: rpc("state_getStorage", [k, h])["result"] # noqa: E731
emision = int.from_bytes(bytes.fromhex(leer("0x" + twox128("Balances") + twox128("TotalIssuance"))[2:]), "little")
sin_llave = sum(saldo_total(c, h) for c in cuentas_de_pallet(h) if c not in VUELVEN)
guardado = int.from_bytes(bytes.fromhex(rpc("state_getStorage", [clave("VoteQuorumOf", id_hex)])["result"][2:]), "little")
quemado = sum(int(m) for e in evs for m in re.findall(r"BurnedDebt \{ amount: (\d+)", e))
if not quemado:
falla("no encuentro el BurnedDebt de la comisión\n" + "\n".join(evs))
emision += quemado
esperado = -(-(emision - sin_llave) // 10)
if guardado != esperado:
falla(f"quórum guardado {guardado} ≠ 10 % de lo votable {esperado}")
if v["specVersion"] >= 19 and guardado == -(-emision // 10):
falla("el quórum es el 10 % de la emisión TOTAL, no de lo que puede votar")
print(f"2b ✅ quórum guardado {guardado / 10**18:,.6f} OGC = 10 % de lo que puede votar "
f"({(emision - sin_llave) / 10**18:,.4f} OGC; las cuentas sin llave guardan {sin_llave / 10**18:,.4f})")
# ORIGIN_E2E_EXIGE_SPEC=21 hace el paso aunque la cadena diga una spec anterior: es el
# contrafactual (contra spec 20 tiene que reprobar en el storage, no en la versión).
if v["specVersion"] >= 21 or os.environ.get("ORIGIN_E2E_EXIGE_SPEC", "") == "21":
cualificada = rpc("state_getStorage", [clave("TreasurySupermajorityOf", id_hex)])["result"]
if cualificada != DOS_TERCIOS:
falla(f"TreasurySupermajorityOf(id) = {cualificada}, no (2, 3)")
print("2c ✅ TreasurySupermajorityOf(id) = (2, 3): la propuesta de tesoro guarda sus dos tercios")
if rpc("state_getStorage", [clave("Proposals", SEMBRADA)])["result"]:
vieja = rpc("state_getStorage", [clave("TreasurySupermajorityOf", SEMBRADA)])["result"]
if vieja is not None:
falla(f"la propuesta sembrada en spec 18 tiene TreasurySupermajorityOf = {vieja}")
if not rpc("state_getStorage", [clave("TreasuryActions", SEMBRADA)])["result"]:
falla("la propuesta sembrada perdió su TreasuryActions")
print(f"2d ✅ la sembrada 0x{SEMBRADA[:12]}… (spec 18) no tiene dos tercios: se decide con mayoría simple")
else:
print("2d — esta cadena no tiene la propuesta sembrada; no aplica")
frontera_del_upgrade()
def falla_con(llamada, error, que):
n, evs = enviar(llamada, genesis, v)
idx = indice_de_error(error)
texto = "\n".join(evs)
patron = rf"index: 10, error: \[{idx}, 0, 0, 0\]"
if not re.search(patron, texto):
falla(f"{llamada}: se esperaba {error} (índice {idx})\n{texto}")
print(f"{que} ✅ #{n}: {llamada.split(':')[0]} → {error} (Module 10, error {idx})")
falla_con(TESORO.format(0), "TreasuryAmountBelowMinimum", "3")
falla_con(TESORO.format(MONTO * 2), "ProposalIdCollision", "4")
if rpc("state_getStorage", [clave("TreasuryActions", id_hex)])["result"] != acc:
falla("el reenvío cambió TreasuryActions(id)")
print(" ✅ TreasuryActions(id) sigue siendo (Bob, monto original)")
antes = saldo_libre(BOB_HEX)
falla_con(f"pagar:0x{id_hex}:{MONTO}", "ProposalNotApproved", "5")
if saldo_libre(BOB_HEX) != antes:
falla("el saldo de Bob cambió")
print(f" ✅ el saldo libre de Bob no se movió ({antes})")
if sembrar_pago:
sembrar(id_hex, genesis, v)
def frontera_del_upgrade():
"""2e · B = primer bloque cuyo estado dice spec >= 21 (búsqueda binaria). Una propuesta de
tesoro con start_block <= B la creó el runtime anterior y no lleva marca; una posterior,
sí. Cualquier otra combinación reprueba."""
def spec(n):
return rpc("state_getRuntimeVersion", [rpc("chain_getBlockHash", [n])["result"]])["result"]["specVersion"]
lo, hi = 0, best()
if spec(hi) < 21:
falla("2e: la cadena no llegó a spec 21")
while lo < hi:
mid = (lo + hi) // 2
if spec(mid) >= 21:
hi = mid
else:
lo = mid + 1
b = lo
pref = "0x" + twox128("Governance") + twox128("TreasuryActions")
claves, desde = [], None
while True:
lote = rpc("state_getKeysPaged", [pref, 500, desde] if desde else [pref, 500])["result"]
claves += lote
if len(lote) < 500:
break
desde = lote[-1]
antes, despues = [], []
for k in claves:
pid = k[-64:]
p = rpc("state_getStorage", [clave("Proposals", pid)])["result"]
if not p:
falla(f"2e: TreasuryActions sin Proposals para 0x{pid[:12]}…")
inicio = int.from_bytes(bytes.fromhex(p[2:])[144:152], "little")
marca = rpc("state_getStorage", [clave("TreasurySupermajorityOf", pid)])["result"]
if inicio <= b and marca is not None:
falla(f"2e: 0x{pid[:12]}… es de antes del upgrade (#{inicio} <= #{b}) y tiene marca {marca}")
if inicio > b and marca != DOS_TERCIOS:
# Una ya decidida consumió su marca: sólo se exige en las que siguen en votación.
if bytes.fromhex(p[2:])[160] == 0:
falla(f"2e: 0x{pid[:12]}… es de después del upgrade (#{inicio} > #{b}), sigue en votación y no tiene (2, 3)")
(antes if inicio <= b else despues).append(f"0x{pid[:8]}…@#{inicio}")
print(f"2e ✅ frontera del upgrade en #{b}: {len(antes)} de antes sin marca ({', '.join(antes)}) · "
f"{len(despues)} de después con (2, 3)")
def sembrar(id_hex, genesis, v):
"""Deja la propuesta lista para cerrar el camino feliz en vivo: fondea el vault y vota Aye
con EXACTAMENTE el quórum guardado (la frontera `yes >= quórum`)."""
q = int.from_bytes(bytes.fromhex(rpc("state_getStorage", [clave("VoteQuorumOf", id_hex)])["result"][2:]), "little")
n, evs = enviar(f"depositar:{2 * MONTO}", genesis, v)
# (el paso 1 ya depositó otro tanto) treasury_deposit no emite evento propio: basta el Transfer de Balances y el éxito.
if not (any("Transfer" in e for e in evs) and any("ExtrinsicSuccess" in e for e in evs)):
falla("el depósito al vault no entró\n" + "\n".join(evs))
n, evs = enviar(f"votar:0x{id_hex}:{q}", genesis, v)
if not any("VoteCast" in e for e in evs):
falla("el voto Aye no entró\n" + "\n".join(evs))
p = rpc("state_getStorage", [clave("Proposals", id_hex)])["result"]
print(f"S ✅ #{n}: vault fondeado con {2 * MONTO} y Aye de Alice = quórum guardado ({q / 10**18:,.6f} OGC)")
print(f" cerrar después del fin del periodo: python3 scripts/e2e/rv17_tesoro.py cerrar {id_hex}")
def cerrar(id_hex):
"""A los 14 días: finaliza (debe quedar Approved), paga por sudo y comprueba el saldo de Bob."""
genesis = rpc("chain_getBlockHash", [0])["result"]
v = rpc("state_getRuntimeVersion")["result"]
n, evs = enviar(f"finalizar:0x{id_hex}", genesis, v)
if not any("ProposalFinalized" in e and "Approved" in e for e in evs):
falla("la propuesta no quedó Approved\n" + "\n".join(evs))
antes = saldo_libre(BOB_HEX)
n, evs = enviar(f"pagar:0x{id_hex}:{MONTO}", genesis, v)
if not any("Sudid { sudo_result: Ok" in e for e in evs):
falla("el pago por sudo falló\n" + "\n".join(evs))
if saldo_libre(BOB_HEX) - antes != MONTO:
falla(f"Bob recibió {saldo_libre(BOB_HEX) - antes}, no {MONTO}")
print(f"C ✅ #{n}: Approved y pagado: Bob recibió exactamente {MONTO} plancks")
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "cerrar":
cerrar(sys.argv[2].removeprefix("0x"))
else:
main(sembrar_pago=len(sys.argv) == 2 and sys.argv[1] == "sembrar")