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>
311 lines
16 KiB
Python
311 lines
16 KiB
Python
#!/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")
|