Files
arkhe-origin-chain/scripts/e2e/rv17_tesoro.py
T
CharonandClaude Opus 5.5 b6d05eaf45 feat(gobierno): spec 18 (RV17), quorum del voto ordinario y pago del tesoro atado a la propuesta
Gobierno (origin-chain-governance):
- El voto ordinario exige un quorum de Aye, el 10 % de PILAR_4 §6.6. Se fija AL CREAR la
  propuesta (VoteQuorumOf) y se consume al decidirla: un set_code no mueve la meta de un
  voto abierto. Cuenta solo el Aye: con el Nay, un voto en contra podria aprobar; con la
  abstencion, el mismo OGC inflaria el quorum de muchas propuestas.
- Un override del veto tiene que alcanzar ese quorum y superar el Nay original, las dos
  condiciones del voto ordinario. Sin ellas, vetar abarataba una aprobacion (dos bloqueantes
  de las revisiones adversariales).
- submit_treasury_proposal (call 16) fija destinatario y monto (TreasuryActions), y
  treasury_disbursement solo paga exactamente eso, una vez. Rechaza montos bajo el deposito
  existencial y como destinatario la propia tesoreria o la cuenta-bond.
- Sin entrada en VoteQuorumOf no hay quorum: la emergencia (que nunca lleva accion de
  tesoro) y lo creado antes de RV17, que se abrio y voto sin quorum.

Runtime: los dos quorums son el 10 % de la emision VIGENTE, redondeado hacia arriba y nunca
por debajo del bond (DiezPorCientoDeLaEmision). Con 100M fijos, en la testnet de la casa
(100M emitidos, todos de Alice) nada se habria aprobado nunca; en mainnet sigue siendo ~100M.
Spec 18, transaction_version 1: todo lo nuevo es aditivo, sin migracion.

Node: pasa a opaque::Block. Decodificaba los extrinsics con tipos nativos y rechazaba en el
pool la llamada que anadia el set_code (1040, el mismo error que una llamada inexistente).
Los bytes y los hashes no cambian: el binario nuevo sincronizo desde cero los 39 273 bloques
de la testnet de la casa con los mismos hashes en 8 alturas. El RPC sirve los extrinsics en hex.

Pesos: seis extrinsics cambiaron de codigo y se midieron en la Pi 5 (smoke declarado: 5
procesos limpios, sin CI en ningun segundo, maximo de los 5). Los otros once siguen siendo los
certificados por Laurha. Se descarto una medida contaminada: su muestreador no veia los tests
de la CI.

Pruebas:
- 363 tests verdes en el workspace.
- 24 contrafactuales, los 24 en rojo.
- Ensayo en la rp1 sobre una cadena con el codigo vivo de spec 17: upgrade a 18 y E2E
  firmando como un wallet (scripts/e2e/rv17_tesoro.py).
- Ejemplo firmar_como_wallet con tesoro, pagar, votar, depositar y finalizar; los eventos
  llevan su fase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:55:24 -05:00

259 lines
13 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")
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 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(f"tesoro:{MONTO}", nonce, genesis, v)
titulo = hashlib.blake2b(b"e2e-rv17/titulo", digest_size=32).hexdigest()
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(f"tesoro:{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})")
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:0", "TreasuryAmountBelowMinimum", "3")
falla_con(f"tesoro:{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")