GSUN-3 (dos tercios para pagar del tesoro, commit anterior) entra con sus pesos medidos. - scripts/bench/certificados/2026-11-02-spec21-laurha.rs: el fundido de Laurha (sha256 03fbaf72), el máximo de 5 procesos en el santuario de la rp1 sobre el wasm de banco 6f75bb59. Control negativo de Laurha: en el mínimo, las 14 que no cambian quedan entre 0,990 y 1,002. - weights.rs: submit_treasury_proposal, finalize_proposal y finalize_override cambian de esqueleto (leen y escriben TreasurySupermajorityOf). Se les puso el bloque del certificado y luego se ensamblaron los cuatro certificados. --comprobar coincide, y ningún peso baja respecto a spec 20. - Cola de cast_vote: 130,07 µs de un solo proceso. Se cobra; sobre spec 20 (127,72) es un +1,8 %. - E2E (rv17_tesoro.py) paso 2e: busca el bloque del upgrade sólo dentro de la ventana de estado del nodo (~256 bloques), o lo toma de ORIGIN_BLOQUE_UPGRADE. La primera versión pedía el estado de bloques podados y el RPC no respondía. En vivo en la testnet de la casa, con el WASM 048ea752: - contrafactual: contra spec 20, el E2E reprueba en 2c porque TreasurySupermajorityOf no existe; - set_code en el bloque 98268: CodeUpdated, la firma vieja se rechaza, la nueva entra, y la finalidad avanza; - E2E verde del 0 al 5, con 2c (2, 3), 2d (la sembrada sin marca) y 2e (frontera en 98268: 5 propuestas de antes sin marca y 2 de después con ella). Pruebas: 373 del workspace, 0 fallan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
403 lines
22 KiB
Python
403 lines
22 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;
|
||
2b. el quórum guardado es el 10 % de lo que puede votar (spec 19);
|
||
2c. desde spec 21 (GSUN-3), TreasurySupermajorityOf(id) = (2, 3): la propuesta de tesoro
|
||
nueva guarda sus dos tercios. Contra spec 20 este paso reprueba;
|
||
2d. la propuesta sembrada en spec 18 (SEMBRADA), si existe en esta cadena, NO tiene esa
|
||
entrada: se decide con la mayoría simple con la que se abrió;
|
||
2e. la frontera del upgrade: B es el primer bloque cuyo estado ya dice spec 21 (el del
|
||
set_code, que aún ejecutó el runtime anterior). Toda propuesta de tesoro con
|
||
start_block <= B no tiene marca, y toda la posterior la tiene. Se enumeran TODAS las
|
||
de TreasuryActions, no sólo la sembrada (revisión adversarial de GSUN-3);
|
||
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
|
||
# El pago sembrado en la testnet de la casa el 28-oct (spec 18), que se cierra hacia el 26-nov.
|
||
SEMBRADA = "fe92182ecff4df083b42da838a509c82aab7a4d0213a902e5a5a89729bd06ad0"
|
||
# GSUN-3: la mayoría cualificada del tesoro, (num, den) como dos u32 en little endian.
|
||
DOS_TERCIOS = "0x" + (2).to_bytes(4, "little").hex() + (3).to_bytes(4, "little").hex()
|
||
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})")
|
||
|
||
# ORIGIN_E2E_EXIGE_SPEC=21 hace el paso aunque la cadena diga una spec anterior: es el
|
||
# contrafactual (contra spec 20 tiene que reprobar en el storage, no en la versión).
|
||
if v["specVersion"] >= 21 or os.environ.get("ORIGIN_E2E_EXIGE_SPEC", "") == "21":
|
||
cualificada = rpc("state_getStorage", [clave("TreasurySupermajorityOf", id_hex)])["result"]
|
||
if cualificada != DOS_TERCIOS:
|
||
falla(f"TreasurySupermajorityOf(id) = {cualificada}, no (2, 3)")
|
||
print("2c ✅ TreasurySupermajorityOf(id) = (2, 3): la propuesta de tesoro guarda sus dos tercios")
|
||
if rpc("state_getStorage", [clave("Proposals", SEMBRADA)])["result"]:
|
||
vieja = rpc("state_getStorage", [clave("TreasurySupermajorityOf", SEMBRADA)])["result"]
|
||
if vieja is not None:
|
||
falla(f"la propuesta sembrada en spec 18 tiene TreasurySupermajorityOf = {vieja}")
|
||
if not rpc("state_getStorage", [clave("TreasuryActions", SEMBRADA)])["result"]:
|
||
falla("la propuesta sembrada perdió su TreasuryActions")
|
||
print(f"2d ✅ la sembrada 0x{SEMBRADA[:12]}… (spec 18) no tiene dos tercios: se decide con mayoría simple")
|
||
else:
|
||
print("2d — esta cadena no tiene la propuesta sembrada; no aplica")
|
||
frontera_del_upgrade()
|
||
|
||
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 frontera_del_upgrade():
|
||
"""2e · B = primer bloque cuyo estado dice spec >= 21 (búsqueda binaria). Una propuesta de
|
||
tesoro con start_block <= B la creó el runtime anterior y no lleva marca; una posterior,
|
||
sí. Cualquier otra combinación reprueba."""
|
||
# Un nodo que no es de archivo sólo guarda el estado de los últimos ~256 bloques (medido el
|
||
# 3-nov: la primera versión buscaba por toda la cadena y el RPC no tenía el estado de los
|
||
# bloques viejos). Se busca dentro de esa ventana; si el upgrade es más viejo, se pasa
|
||
# ORIGIN_BLOQUE_UPGRADE (lo imprime upgrade_en_vivo.py en su paso 1).
|
||
def spec(n):
|
||
h = rpc("chain_getBlockHash", [n]).get("result")
|
||
r = rpc("state_getRuntimeVersion", [h]) if h else {}
|
||
return r.get("result", {}).get("specVersion") # None si el estado ya está podado
|
||
hi = best()
|
||
if (spec(hi) or 0) < 21:
|
||
falla("2e: la cadena no llegó a spec 21")
|
||
if os.environ.get("ORIGIN_BLOQUE_UPGRADE"):
|
||
b = int(os.environ["ORIGIN_BLOQUE_UPGRADE"])
|
||
if spec(b) is not None and spec(b) < 21:
|
||
falla(f"2e: ORIGIN_BLOQUE_UPGRADE={b} todavía es spec {spec(b)}")
|
||
if spec(b - 1) is not None and spec(b - 1) >= 21:
|
||
falla(f"2e: ORIGIN_BLOQUE_UPGRADE={b} no es el primero en spec 21")
|
||
else:
|
||
lo = max(0, hi - 250)
|
||
if spec(lo) is None:
|
||
falla(f"2e: el nodo no tiene el estado del bloque #{lo}; pasa ORIGIN_BLOQUE_UPGRADE")
|
||
if spec(lo) >= 21:
|
||
falla(f"2e: el upgrade es anterior a #{lo}, fuera de la ventana de estado; pasa ORIGIN_BLOQUE_UPGRADE")
|
||
while lo < hi:
|
||
mid = (lo + hi) // 2
|
||
if spec(mid) >= 21:
|
||
hi = mid
|
||
else:
|
||
lo = mid + 1
|
||
b = lo
|
||
pref = "0x" + twox128("Governance") + twox128("TreasuryActions")
|
||
claves, desde = [], None
|
||
while True:
|
||
lote = rpc("state_getKeysPaged", [pref, 500, desde] if desde else [pref, 500])["result"]
|
||
claves += lote
|
||
if len(lote) < 500:
|
||
break
|
||
desde = lote[-1]
|
||
antes, despues = [], []
|
||
for k in claves:
|
||
pid = k[-64:]
|
||
p = rpc("state_getStorage", [clave("Proposals", pid)])["result"]
|
||
if not p:
|
||
falla(f"2e: TreasuryActions sin Proposals para 0x{pid[:12]}…")
|
||
inicio = int.from_bytes(bytes.fromhex(p[2:])[144:152], "little")
|
||
marca = rpc("state_getStorage", [clave("TreasurySupermajorityOf", pid)])["result"]
|
||
if inicio <= b and marca is not None:
|
||
falla(f"2e: 0x{pid[:12]}… es de antes del upgrade (#{inicio} <= #{b}) y tiene marca {marca}")
|
||
if inicio > b and marca != DOS_TERCIOS:
|
||
# Una ya decidida consumió su marca: sólo se exige en las que siguen en votación.
|
||
if bytes.fromhex(p[2:])[160] == 0:
|
||
falla(f"2e: 0x{pid[:12]}… es de después del upgrade (#{inicio} > #{b}), sigue en votación y no tiene (2, 3)")
|
||
(antes if inicio <= b else despues).append(f"0x{pid[:8]}…@#{inicio}")
|
||
print(f"2e ✅ frontera del upgrade en #{b}: {len(antes)} de antes sin marca ({', '.join(antes)}) · "
|
||
f"{len(despues)} de después con (2, 3)")
|
||
|
||
|
||
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")
|