#!/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 # 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: 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; 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 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`: su posición en el enum del pallet.""" src = open(os.path.join(GOV, "lib.rs")).read() cuerpo = re.search(r"pub enum Error \{(.*?)\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 {")[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})") 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 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")