Skip to content

feat: el contrato Soroban del jefe mundial, que no le cree a nadie - #26

Closed
leocagli wants to merge 2 commits into
mainfrom
contrato-jefe
Closed

feat: el contrato Soroban del jefe mundial, que no le cree a nadie#26
leocagli wants to merge 2 commits into
mainfrom
contrato-jefe

Conversation

@leocagli

Copy link
Copy Markdown
Collaborator

La autoridad sobre la vida del jefe mundial. No reemplaza la simulacion
JavaScript ni declara equivalencia de dano
(ver la deuda mas abajo).

Que resuelve

docs/world-boss.md lo pide con todas las letras:

La vida del jefe debe tener una sola autoridad por evento y replicarse como
{spawnId, hp, phase, revision}.

lib/net.js no puede serlo, y lo dice de si mismo en su primer comentario: "no
hay estado compartido, no hay autoridad, no hay historia"
. Sirve para verse
caminar. Un jefe con vida compartida necesita que alguien diga cuanta vida le
queda y que todos le crean.

La decision que ordena todo

El contrato no le cree a nadie: calcula.

Lo dificil no es guardar un numero, es que el jugador que dice "le hice 900 de
dano"
no pueda mentir. Las salidas habituales son creerle (malo), pedir una
prueba de conocimiento cero (caro), o abrir una ventana de disputa con fianzas
(economico, no matematico).

runa permite la buena, y por un motivo concreto: el lenguaje de guiones no
tiene bucles ni recursion
, asi que una pelea tiene un techo de ticks conocido
de antemano. Entonces hit() no recibe un numero de dano. Recibe semilla y
ticks, y el contrato rehace la pelea.

Hay un test que se llama el_jugador_no_dice_cuanto_dano_hizo y comprueba
justamente esa ausencia: si alguien agrega ese parametro alguna vez, deja de
compilar.

Que entre en el presupuesto esta medido, no supuesto

Sobre wasm de verdad, que es lo que corre la red:

ticks        cpu       mem     %cpu
   0      246464   1218113     0%     <- solo invocar
 100      617264   1218113     0%
 300     1358864   1218113     1%
 600     2471264   1218113     2%
 900     3583664   1218113     3%     <- pelea completa

duelo, dos peleas de 900 ticks en UNA invocacion:
  cpu     6917601   (6% del maximo)
  memoria 1217513   (2% del maximo)

3708 de cpu por tick. Queda margen de catorce veces.

La trampa que casi me hace publicar un numero falso

La primera medicion daba 17.907 para un tick y 17.907 para novecientos.
Identico. La causa: env.register(Contrato, ()) corre el contrato como Rust
nativo, y el medidor de presupuesto no ve el bucle; ese numero era solo el
costo de invocar.

Para medir de verdad hay que registrar el wasm con contractimport!. Quedaron
dos tests de guardia:

  • el_costo_crece_con_los_ticks falla si el medidor deja de ver el bucle.
  • El del duelo comprueba que costo casi el doble que una pelea sola, o sea que
    corrio las dos de verdad. Antes media dos invocaciones sueltas y el arnes
    reinicia el presupuesto entre ellas, asi que un duelo parecia salir gratis.

Un bug de precision que habria desincronizado las fases

Las fases se calculaban con division entera:

(238 * 100) / 360 = 66     ->  entra en la rama del 66%  ->  fase 1

Pero el JavaScript trabaja con decimales y 0.6611 no es <= 0.66, o sea fase
0. El jefe habria cambiado de fase un golpe antes en la cadena que en el
juego. Ahora se compara en cruz sin dividir, y los seis limites tienen test.

La deuda, dicha de frente

jefe/src/fight.rs es la forma del combate de runa, no su motor. El motor
vive en lib/world.js y lib/script.js y son unas quinientas lineas.

Mientras no se porten, el dano que calcula el contrato y el que calcula el
juego no coinciden, y este PR no declara ninguna equivalencia.
Eso no se
arregla leyendo los dos archivos y convenciendose: se arregla con un test
cruzado que corra la misma semilla y los mismos ticks en los dos lenguajes.
Hasta que ese test exista y este verde, esto sirve para probar el diseno y no
para repartir premios de verdad.

Queda escrito tambien en contracts/README.md.

Un numero de balance, por si sirve

Con 32,6 de dano promedio por pelea, 360 de vida caen en once peleas. Es
decision de diseno y no del contrato, pero queda medido.

Verificacion

contratos    16 tests (11 jefe + 5 medicion), todos verdes
wasm         jefe 14.245 bytes, medicion 2.737, target wasm32v1-none
javascript   65/65 y 515/515, sin cambios
lint         limpio
git diff --check   limpio

Los numeros de JavaScript no cambian porque el contrato no toca JavaScript.

Lo que NO entra al repositorio

Ni contracts/target, ni snapshots generados, ni caches, ni dependencias
compiladas. .prettierignore hace falta porque prettier recorre el arbol entero
y no lee .gitignore.

Comment thread contracts/jefe/src/lib.rs
Comment on lines +172 to +186
pub fn hit(env: Env, player: Address, seed: u32, ticks: u32) -> Result<BossState, Error> {
player.require_auth();

if ticks == 0 || ticks > FIGHT_CAP {
return Err(Error::TicksFueraDeRango);
}

let mut boss: BossState = env
.storage()
.instance()
.get(&Key::Boss)
.ok_or(Error::NoIniciado)?;
if !boss.alive {
return Err(Error::YaMurio);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Security: hit() lets an attacker front-run and steal damage credit

hit binds a fight only to a player-chosen seed/ticks pair and dedups globally per spawn (Key::Used(spawn_id)), but nothing binds the seed to the caller. Because the seed and ticks are visible in a pending transaction, an attacker can copy them into their own hit call, get the damage credited to their address (Key::Damage), and cause the honest player's transaction to fail with PeleaRepetida, ultimately stealing the reward via claim. For a contract whose whole purpose is to be a trustless authority, mix the caller's Address into the fight identity/seed (e.g. derive the RNG seed or the Used key from player + seed) so a replayed fight cannot be attributed to a different address.

Was this helpful? React with 👍 / 👎

Comment thread contracts/jefe/src/lib.rs
Comment on lines +116 to +122
pub fn init(env: Env, admin: Address) -> Result<(), Error> {
if env.storage().instance().has(&Key::Admin) {
return Err(Error::YaIniciado);
}
env.storage().instance().set(&Key::Admin, &admin);
Ok(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: Persistent/instance storage TTL is never extended

hit, claim, spawn and init write to instance and persistent storage but never call extend_ttl. On a live network the Boss, Damage, Used and Claimed entries can be archived once their TTL lapses, which would make an in-progress boss disappear or make contributions un-claimable. Add TTL bumps (e.g. env.storage().instance().extend_ttl(...) and extend_ttl on the persistent keys after each write). This is design-demo code today, but should be resolved before mainnet as the PR notes.

Was this helpful? React with 👍 / 👎

root added 2 commits August 25, 2026 14:32
docs/world-boss.md pide una sola autoridad sobre la vida del jefe, replicada
como {spawnId, hp, phase, revision}. lib/net.js no puede serlo y lo dice de si
mismo en su primer comentario: "no hay estado compartido, no hay autoridad, no
hay historia". Esto es esa autoridad, y nada mas que eso.

Lo dificil de un jefe compartido no es guardar un numero: es que el jugador que
dice "le hice 900 de dano" no pueda mentir. Las salidas habituales son creerle,
pedir una prueba de conocimiento cero, o abrir una ventana de disputa con
fianzas. runa permite la buena, y por un motivo concreto: el lenguaje de guiones
no tiene bucles ni recursion, asi que una pelea tiene un techo de ticks conocido
de antemano.

Entonces hit() no recibe un numero de dano. Recibe semilla y ticks, y el
contrato rehace la pelea. Si el jugador miente sobre el resultado no gana nada,
porque el resultado no se lo pregunta nadie.

Que eso entre en el presupuesto esta medido sobre wasm de verdad:

  cada tick                     3.708 de cpu
  una pelea de 900 ticks    3.583.664      3% del presupuesto
  un duelo, dos peleas      6.917.601      6% del presupuesto

Queda margen de catorce veces.

DEUDA DECLARADA, y esto es importante: jefe/src/fight.rs es la FORMA del combate
de runa, no su motor. El motor vive en lib/world.js y lib/script.js. Mientras no
se porten, el dano que calcula el contrato y el que calcula el juego NO
coinciden, y este commit no declara ninguna equivalencia. Eso se arregla con un
test cruzado que corra la misma semilla y los mismos ticks en los dos lenguajes,
no leyendo los archivos y convenciendose. Hasta que ese test exista y este
verde, esto sirve para probar el diseno y no para repartir premios de verdad.
La simulacion JavaScript no se toca ni se reemplaza.

Dos cosas que casi salen mal y ahora tienen test:

- env.register(Contrato, ()) corre el contrato como Rust nativo y el medidor de
  presupuesto no ve el bucle: daba 17.907 para un tick y para novecientos. Hay
  que registrar el wasm con contractimport!. El test el_costo_crece_con_los_ticks
  vigila eso, y el del duelo comprueba que las dos peleas corrieron de verdad.
- Las fases se calculaban con division entera y 238 de 360 daba 66 redondeado,
  asi que el jefe cambiaba de fase un golpe antes que en el juego. Ahora se
  compara en cruz. Los seis limites tienen test.

Un numero que conviene mirar: con 32,6 de dano promedio por pelea, 360 de vida
caen en once peleas. Es decision de diseno, no del contrato, pero queda medido.

No se agrega contracts/target, ni snapshots, ni dependencias compiladas.
.prettierignore hace falta porque prettier recorre el arbol entero y no lee
.gitignore.

  contratos    16 tests (11 jefe + 5 medicion), todos verdes
  wasm         jefe 14.245 bytes, medicion 2.737
  javascript   65/65 y 515/515, sin cambios: el contrato no toca JS
…rkspace

`contracts/duel-arena` entro a main con el PR #45, despues de que esta rama se
abriera. Sin tocar nada, cargo lo excluia del workspace en silencio: `cargo build`
en `contracts/` compilaba jefe y medicion, y duel-arena no aparecia por ningun lado
ni daba error. Quien arme el CI manana habria construido dos de tres contratos sin
enterarse.

Ahora la exclusion es explicita y dice por que existe. Las dos razones son reales:

- duel-arena ancla `soroban-sdk = "=26.1.1"` con igual, mientras jefe y medicion
  usan 27.0.6, que es la version del protocolo vivo en mainnet y testnet. En un
  mismo workspace conviven pero se compilan dos copias del SDK.
- duel-arena trae su propio `[profile.release]`, y un workspace solo honra el de la
  raiz, asi que meterlo adentro le cambiaria las opciones de compilacion por la
  espalda.

Unificar las versiones es trabajo aparte y merece su propia issue. Lo que arregla
este commit es que la exclusion sea una decision visible en vez de un olvido.

  cargo metadata   sin error
  workspace ve     medicion, jefe
  cargo test -p jefe   11/11
@gitar-bot

gitar-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 0 resolved / 2 findings

Introduces the Soroban world boss contract to compute authoritative fight outcomes on-chain, but hit() allows front-running damage credit and persistent storage TTL is never extended.

⚠️ Security: hit() lets an attacker front-run and steal damage credit

📄 contracts/jefe/src/lib.rs:172-186

hit binds a fight only to a player-chosen seed/ticks pair and dedups globally per spawn (Key::Used(spawn_id)), but nothing binds the seed to the caller. Because the seed and ticks are visible in a pending transaction, an attacker can copy them into their own hit call, get the damage credited to their address (Key::Damage), and cause the honest player's transaction to fail with PeleaRepetida, ultimately stealing the reward via claim. For a contract whose whole purpose is to be a trustless authority, mix the caller's Address into the fight identity/seed (e.g. derive the RNG seed or the Used key from player + seed) so a replayed fight cannot be attributed to a different address.

💡 Bug: Persistent/instance storage TTL is never extended

📄 contracts/jefe/src/lib.rs:116-122 📄 contracts/jefe/src/lib.rs:190-204 📄 contracts/jefe/src/lib.rs:245-259

hit, claim, spawn and init write to instance and persistent storage but never call extend_ttl. On a live network the Boss, Damage, Used and Claimed entries can be archived once their TTL lapses, which would make an in-progress boss disappear or make contributions un-claimable. Add TTL bumps (e.g. env.storage().instance().extend_ttl(...) and extend_ttl on the persistent keys after each write). This is design-demo code today, but should be resolved before mainnet as the PR notes.

🤖 Prompt for agents
Code Review: Introduces the Soroban world boss contract to compute authoritative fight outcomes on-chain, but hit() allows front-running damage credit and persistent storage TTL is never extended.

1. ⚠️ Security: hit() lets an attacker front-run and steal damage credit
   Files: contracts/jefe/src/lib.rs:172-186

   `hit` binds a fight only to a player-chosen `seed`/`ticks` pair and dedups globally per spawn (`Key::Used(spawn_id)`), but nothing binds the seed to the caller. Because the seed and ticks are visible in a pending transaction, an attacker can copy them into their own `hit` call, get the damage credited to their address (`Key::Damage`), and cause the honest player's transaction to fail with `PeleaRepetida`, ultimately stealing the reward via `claim`. For a contract whose whole purpose is to be a trustless authority, mix the caller's `Address` into the fight identity/seed (e.g. derive the RNG seed or the `Used` key from `player` + seed) so a replayed fight cannot be attributed to a different address.

2. 💡 Bug: Persistent/instance storage TTL is never extended
   Files: contracts/jefe/src/lib.rs:116-122, contracts/jefe/src/lib.rs:190-204, contracts/jefe/src/lib.rs:245-259

   `hit`, `claim`, `spawn` and `init` write to instance and persistent storage but never call `extend_ttl`. On a live network the `Boss`, `Damage`, `Used` and `Claimed` entries can be archived once their TTL lapses, which would make an in-progress boss disappear or make contributions un-claimable. Add TTL bumps (e.g. `env.storage().instance().extend_ttl(...)` and `extend_ttl` on the persistent keys after each write). This is design-demo code today, but should be resolved before mainnet as the PR notes.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Important

Your trial ends in 7 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

@leocagli

Copy link
Copy Markdown
Collaborator Author

Cierro este por la misma razón que el #25: su contenido ya está integrado en el trabajo
local de main
.

Lo traen dos commits: 20fe285 feat: el contrato Soroban del jefe mundial, que no le cree a nadie y bdb4b03 feat: mejorar fases y telegráficos del jefe mundial, más
bd8e840 chore(contracts): dejar por escrito que duel-arena queda fuera del workspace,
que era la exclusión explícita del workspace que agregué cuando entró duel-arena con el
#45.

Un dato que quedó comprobado recién y vale para el repositorio: los tests de Rust de este
contrato pasan, pero sólo si se compila el wasm antes.

cargo build --target wasm32v1-none --release
cargo test
  jefe        11 passed
  medicion     5 passed

Sin el build previo, medicion/src/test.rs:23 falla con No such file or directory
porque hace contractimport! de su propio wasm. No es un bug, es orden de compilación,
pero conviene dejarlo escrito en el README para que no muerda al próximo.

La rama contrato-jefe queda en el remoto hasta que se pushee el main local.

@leocagli leocagli closed this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant