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:
1 parent
114721c24a
commit
0d56e0bcbf
2 files changed
+115
-1
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user