Files
arkhe-origin-chain/scripts/e2e/rv17_tesoro.py
T
CharonandClaude Opus 5.5 158060bfe4 feat(gobierno): spec 19, el quorum es el 10 % de lo que puede votar
Decision de David, 2026-10-29: «Si, del 10 % de lo que puede votar».

Que puede votar:
- Es la emision vigente menos el saldo que nadie puede votar ni recuperar: el pool de emision,
  el de subsidios y el vault de gobierno (cuentas_sin_llave en configs.rs).
- Los bonds (og/props) y los depositos de pallet-revive NO se restan, porque vuelven enteros a
  una cuenta con llave. La revision adversaria de spec 19 lo demostro con sonda: restarlos
  dejaba bajar el quorum un 14 % en el genesis de mainnet, aparcando un bond en una propuesta
  de relleno y recuperandolo despues.

Efecto:
- En mainnet el quorum pasa de ~100M a ~35,1M OGC (10 % de 351M).
- En la testnet de la casa apenas cambia.
- Las propuestas abiertas conservan el quorum que guardaron al crearse.

Pruebas:
- quorums_del_runtime clasifica cada cuenta de pallet por su nombre.
- aparcar_un_bond_no_baja_el_quorum reproduce la sonda con la configuracion real.
- toda_cuenta_de_pallet_con_saldo_esta_clasificada vigila las cuentas modl del genesis.
- 10 contrafactuales en rojo.
- El E2E (paso 2b) descubre las cuentas de pallet recorriendo el storage, no copia la lista, y
  exige el quorum exacto. La comision se quema despues de despachar, y eso se mide en el
  BurnedDebt de la propia transaccion.

Pesos: submit_proposal, submit_treasury_proposal y veto_proposal leen 3 cuentas mas. Medidos en
la Pi: 5 procesos limpios, el nucleo calentado 3 s antes de cada uno, a 2,4 GHz.

Operacion de la rp1, arrancar.sh:
- en cada encendido desde el 26-oct el nodo moria al segundo porque la red no estaba; ahora
  espera a la ruta por defecto;
- un cerrojo flock impide dos instancias;
- el pid vivo se reconoce por su linea de comandos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:21:54 -05:00

311 lines
16 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;
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<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})")
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")