feat(gobierno): GSUN-3, dos tercios para el tesoro (spec 21, pesos por certificar)

David, 2-nov-2026: «Sí a la súper-mayoría, dos tercios».

- Una propuesta de tesoro se aprueba sólo si su Aye es al menos dos tercios del voto
  emitido (Aye + Nay; la abstención no cuenta), además del quórum y de la mayoría simple.
- Se fija al crear la propuesta (storage TreasurySupermajorityOf) y se consume al decidirla.
  Las de tesoro abiertas antes de spec 21 conservan la mayoría simple con la que se abrieron.
- Revertir el veto de una propuesta de tesoro exige los dos tercios frente a TODA la
  oposición: el Nay original más lo que sostuvo el veto en la ventana. Es el bloqueante de
  la revisión adversaria: con Nay 4Q y 2Q sosteniendo, 8Q pagaban cuando el voto ordinario
  pedía 12Q.
- Evento nuevo ProposalRejectedForSupermajority, al final del enum. Sin migración y con
  transaction_version igual.
- reaches_supermajority: exacta (aye por den >= num por emitido), monótona, sin Aye nunca
  aprueba y falla cerrada si desborda. Un const assert exige que la fracción sea mayor que 1/2.
- Pruebas: 18 contrafactuales en rojo (17 en su aserción, 1 en compilación), 114 tests del
  pallet y 373 del workspace.
- E2E: pasos 2c (la propuesta nueva guarda 2/3), 2d (la sembrada en spec 18 no) y 2e (la
  frontera del upgrade sobre todas las propuestas de tesoro).

Falta antes de main: el certificado de Laurha para los tres pesos que cambian de esqueleto
(submit_treasury_proposal, finalize_proposal, finalize_override), el E2E vivo y el upgrade.
Sigue abierto, desde RV17: el override de una propuesta ORDINARIA no cuenta lo que sostiene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CharonandClaude Opus 5.5 committed 2026-11-03 23:03:52 -05:00
1 parent 6910f52b1e
commit 4be7da78c3
5 files changed
+486 -19

No files matched your search

+74
View File
@@ -23,6 +23,15 @@ Comprueba, en este orden (se detiene en el primer fallo, código de salida 1):
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
@@ -45,6 +54,10 @@ TITULO = hashlib.blake2b(b"e2e-rv17/titulo" + (f"/{ETIQUETA}".encode() if ETIQUE
# 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"
@@ -247,6 +260,24 @@ def main(sembrar_pago=False):
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)
@@ -271,6 +302,49 @@ def main(sembrar_pago=False):
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."""
def spec(n):
return rpc("state_getRuntimeVersion", [rpc("chain_getBlockHash", [n])["result"]])["result"]["specVersion"]
lo, hi = 0, best()
if spec(hi) < 21:
falla("2e: la cadena no llegó a spec 21")
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`)."""