feat(ink): escrow auto-release (C.2) + NFT modelo delegación documentado (C.1)

Decisiones C de David (2026-07-04):

ESCROW — Opción 2 (auto-release por tiempo, el tiempo como juez determinista):
El audit encontró [HIGH] que si alguien depositaba pero nunca marcaba un
milestone, los fondos quedaban ATRAPADOS (solo salían por disputa+árbitro), y los
campos auto_release_blocks/last_release_block eran código MUERTO. Fix: nueva
`claim_auto_release` (permisionless) que, tras `auto_release_blocks` sin actividad
ni disputa, libera el remanente al beneficiario (menos fee) y cierra el escrow.
`deposit()` arranca el reloj. CRÍTICO para la economía M2M: una AGI que contrata a
otra no queda en limbo porque alguien olvidó un hito. El depositor disconforme
levanta disputa ANTES del vencimiento. +test E2E (ink v6). Campos ya no muertos.

NFT — Opción 1 (delegación ERC-721 estándar, modularidad ADR-0057): el audit
marcó que un spender aprobado puede mover el token con pago cero. NO es bug — es
el estándar Web3 ("si no confías, no apruebes"). La venta con precio pactado +
split 91/9 vive en un contrato de MARKETPLACE SEPARADO (acoplar rompería los NFTs
al actualizar reglas de venta). Documentado el modelo en `transfer`. Sin cambio de
código. Contratos: escrow 6 tests, nft 4 tests verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
davidherranandClaude Fable 5 committed 2026-07-07 21:30:13 -05:00
1 parent 114721c24a
commit 0d56e0bcbf
2 files changed
+115 -1

No files matched your search

+104 -1
View File
@@ -51,7 +51,10 @@
//! - `milestones`: Vec<Milestone> canonical (amount + due_block + description_hash)
//! - `escrow_fee_bps`: u16 (default 150 = 1.5%, BM V2 baseline)
//! - `dispute_window_blocks`: u32 (window canonical para raise dispute post-release)
//! - `auto_release_blocks`: u32 (auto-release if no dispute canonical post-completion)
//! - `auto_release_blocks`: u32 (VIVO desde 2026-07-04, David Opción C.2): el
//! tiempo como juez determinista — `claim_auto_release` (permisionless) libera el
//! remanente al beneficiario si transcurre esta ventana sin actividad ni disputa.
//! Cierra el fund-lock del caso sin-milestones para la economía M2M. `0` = off.
//! - `early_release_allowed`: bool (depositor pueda release early canonical)
#![cfg_attr(not(feature = "std"), no_std, no_main)]
@@ -195,6 +198,12 @@ mod origin_escrow {
InvalidConfiguration,
TransferFailed,
EarlyReleaseProhibited,
/// Audit 2026-07-04 (C, David Opción 2): el auto-release está deshabilitado
/// para este escrow (`auto_release_blocks == 0`).
AutoReleaseDisabled,
/// Audit 2026-07-04: aún no transcurrió `auto_release_blocks` desde la
/// última actividad (depósito o release) — el tiempo-juez no ha vencido.
AutoReleaseNotReady,
}
pub type Result<T> = core::result::Result<T, Error>;
@@ -317,6 +326,12 @@ mod origin_escrow {
}
self.deposited_amount = value;
self.state = EscrowState::Active;
// Audit 2026-07-04 (C, David Opción 2): arranca el reloj del auto-
// release. `last_release_block` es el reloj de "última actividad"
// (depósito o release de milestone); si no hay actividad ni disputa
// por `auto_release_blocks`, cualquiera puede gatillar `claim_auto_release`.
// Cierra el fund-lock del caso sin-milestones para la economía M2M.
self.last_release_block = Some(self.env().block_number());
self.env().emit_event(Deposited {
depositor: self.depositor,
amount: value,
@@ -324,6 +339,65 @@ mod origin_escrow {
Ok(())
}
/// Audit 2026-07-04 (C, David Opción 2): AUTO-RELEASE por tiempo — el
/// tiempo es el JUEZ determinista. **Permisionless**: cualquiera lo gatilla
/// cuando el escrow lleva `Active` (sin disputa) y transcurrió
/// `auto_release_blocks` desde la última actividad (depósito o release).
/// Libera el REMANENTE (deposited − released) al beneficiario (menos el fee
/// de tesorería) y cierra el escrow. Cierra el fund-lock del caso "se
/// depositó pero nadie marcó un milestone" — CRÍTICO para la economía M2M:
/// una AGI que contrata a otra no puede quedar en limbo infinito porque
/// alguien olvidó marcar un hito. El depositor que NO esté conforme debe
/// levantar disputa ANTES del vencimiento (la disputa mueve el estado a
/// Disputed y congela este path).
#[ink(message)]
pub fn claim_auto_release(&mut self) -> Result<()> {
// Solo Active — una disputa habría movido el estado a Disputed.
if self.state != EscrowState::Active {
return Err(Error::InvalidState);
}
// auto_release_blocks == 0 ⇒ feature deshabilitado para este escrow.
if self.auto_release_blocks == 0 {
return Err(Error::AutoReleaseDisabled);
}
let reference = self.last_release_block.unwrap_or(0);
let now = self.env().block_number();
if now < reference.saturating_add(self.auto_release_blocks) {
return Err(Error::AutoReleaseNotReady);
}
let remaining = self.deposited_amount.saturating_sub(self.released_amount);
let fee = remaining
.saturating_mul(self.escrow_fee_bps as Balance)
.saturating_div(10_000);
let net = remaining.saturating_sub(fee);
// FIX-1 CEI: TODO el estado muta ANTES de los transfers.
self.released_amount = self.deposited_amount;
self.state = EscrowState::Completed;
self.last_release_block = Some(now);
// ── INTERACTIONS ──
if net > 0 {
self.env()
.transfer(self.beneficiary, U256::from(net))
.map_err(|_| Error::TransferFailed)?;
}
if fee > 0 {
self.env()
.transfer(self.treasury, U256::from(fee))
.map_err(|_| Error::TransferFailed)?;
}
// ── EVENTS ──
self.env().emit_event(EscrowCompleted {
total_released: self.released_amount,
total_refunded: 0,
treasury_fee: fee,
});
Ok(())
}
/// Add milestone canonical (depositor-only, pre-active state).
/// Sum of milestone amounts MUST eventually equal total_amount.
#[ink(message)]
@@ -647,5 +721,34 @@ mod origin_escrow {
// +0.4 OGC: Σ = 1.0 OGC == total → ok (boundary)
assert!(e.add_milestone(400_000_000_000_000_000, 100, [1u8; 32]).is_ok());
}
/// Audit 2026-07-04 (C, David Opción 2): AUTO-RELEASE por tiempo cierra el
/// fund-lock del caso sin-milestones (economía M2M). Deposita, avanza el
/// tiempo más allá de `auto_release_blocks` sin disputa, y el remanente
/// se libera al beneficiario. Antes del vencimiento → AutoReleaseNotReady.
#[ink::test]
fn auto_release_libera_al_beneficiario_por_tiempo() {
let total = 1_000_000_000_000_000_000u128; // 1 OGC (= total_amount de mk)
let depositor = Address::from([1u8; 20]);
let mut e = mk(); // auto_release_blocks = 1000, fee 1.5%
let contract = ink::env::test::callee();
ink::env::test::set_contract_balance(depositor, U256::from(total * 3));
ink::env::test::set_contract_balance(contract, U256::from(total * 3));
// Depósito (depositor) → Active, arranca el reloj en el bloque actual (0).
ink::env::test::set_caller(depositor);
ink::env::test::set_value_transferred(U256::from(total));
assert!(e.deposit().is_ok());
assert_eq!(e.state(), EscrowState::Active);
// Sin milestones ni disputa. Antes del vencimiento → not ready.
assert_eq!(e.claim_auto_release(), Err(Error::AutoReleaseNotReady));
// El tiempo (juez determinista) vence: avanza > auto_release_blocks.
ink::env::test::set_block_number::<ink::env::DefaultEnvironment>(1001);
assert!(e.claim_auto_release().is_ok(), "auto-release tras el vencimiento");
assert_eq!(e.state(), EscrowState::Completed);
assert_eq!(e.released_amount(), total, "remanente liberado");
}
}
}
+11
View File
@@ -492,6 +492,17 @@ mod origin_nft_mint {
/// Transfer canonical de token con sale_price (secondary sale).
/// Royalty 5% canonical paid al original creator from sale_price.
///
/// **Modelo de delegación (David Opción C.1, 2026-07-04, audit ultra):**
/// el guard es `caller == owner || approved`, semántica ERC-721 ESTÁNDAR —
/// una aprobación ES delegación total ("si no confías, no apruebes"). Que un
/// spender aprobado pueda mover el token con `sale_price = 0` NO es un bug:
/// es el estándar de oro Web3. La lógica de VENTA con precio pactado + la
/// división 91/9 (creador/tesorería) NO vive aquí: vivirá en un contrato de
/// **marketplace SEPARADO** (disciplina de costuras de traits, ADR-0057) —
/// meterla aquí acoplaría el NFT base al marketplace y romper los NFTs al
/// actualizar las reglas de venta. `sale_price` aquí es un pago opcional que
/// fluye al vendedor + royalty; el precio-obligatorio es cancha del marketplace.
///
/// Flow canonical:
/// 1. Verify paused == false
/// 2. Verify token exists canonical