Files
arkhe-origin-chain/scripts/e2e/rv17_tesoro.py
T
CharonandClaude Opus 5.5 935ca1ef5e feat(pesos): spec 21 con el certificado de Laurha, en vivo desde el bloque 98268
GSUN-3 (dos tercios para pagar del tesoro, commit anterior) entra con sus pesos medidos.

- scripts/bench/certificados/2026-11-02-spec21-laurha.rs: el fundido de Laurha
  (sha256 03fbaf72), el máximo de 5 procesos en el santuario de la rp1 sobre el wasm de banco
  6f75bb59. Control negativo de Laurha: en el mínimo, las 14 que no cambian quedan entre
  0,990 y 1,002.
- weights.rs: submit_treasury_proposal, finalize_proposal y finalize_override cambian de
  esqueleto (leen y escriben TreasurySupermajorityOf). Se les puso el bloque del certificado y
  luego se ensamblaron los cuatro certificados. --comprobar coincide, y ningún peso baja
  respecto a spec 20.
- Cola de cast_vote: 130,07 µs de un solo proceso. Se cobra; sobre spec 20 (127,72) es un +1,8 %.
- E2E (rv17_tesoro.py) paso 2e: busca el bloque del upgrade sólo dentro de la ventana de estado
  del nodo (~256 bloques), o lo toma de ORIGIN_BLOQUE_UPGRADE. La primera versión pedía el
  estado de bloques podados y el RPC no respondía.

En vivo en la testnet de la casa, con el WASM 048ea752:
- contrafactual: contra spec 20, el E2E reprueba en 2c porque TreasurySupermajorityOf no existe;
- set_code en el bloque 98268: CodeUpdated, la firma vieja se rechaza, la nueva entra, y la
  finalidad avanza;
- E2E verde del 0 al 5, con 2c (2, 3), 2d (la sembrada sin marca) y 2e (frontera en 98268:
  5 propuestas de antes sin marca y 2 de después con ella).

Pruebas: 373 del workspace, 0 fallan.

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

403 lines
22 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."""
# Un nodo que no es de archivo sólo guarda el estado de los últimos ~256 bloques (medido el
# 3-nov: la primera versión buscaba por toda la cadena y el RPC no tenía el estado de los
# bloques viejos). Se busca dentro de esa ventana; si el upgrade es más viejo, se pasa
# ORIGIN_BLOQUE_UPGRADE (lo imprime upgrade_en_vivo.py en su paso 1).
def spec(n):
h = rpc("chain_getBlockHash", [n]).get("result")
r = rpc("state_getRuntimeVersion", [h]) if h else {}
return r.get("result", {}).get("specVersion") # None si el estado ya está podado
hi = best()
if (spec(hi) or 0) < 21:
falla("2e: la cadena no llegó a spec 21")
if os.environ.get("ORIGIN_BLOQUE_UPGRADE"):
b = int(os.environ["ORIGIN_BLOQUE_UPGRADE"])
if spec(b) is not None and spec(b) < 21:
falla(f"2e: ORIGIN_BLOQUE_UPGRADE={b} todavía es spec {spec(b)}")
if spec(b - 1) is not None and spec(b - 1) >= 21:
falla(f"2e: ORIGIN_BLOQUE_UPGRADE={b} no es el primero en spec 21")
else:
lo = max(0, hi - 250)
if spec(lo) is None:
falla(f"2e: el nodo no tiene el estado del bloque #{lo}; pasa ORIGIN_BLOQUE_UPGRADE")
if spec(lo) >= 21:
falla(f"2e: el upgrade es anterior a #{lo}, fuera de la ventana de estado; pasa ORIGIN_BLOQUE_UPGRADE")
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")