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>
259 lines
13 KiB
Python
259 lines
13 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")
|
||
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")
|