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)
|
//! - `milestones`: Vec<Milestone> canonical (amount + due_block + description_hash)
|
||||||
//! - `escrow_fee_bps`: u16 (default 150 = 1.5%, BM V2 baseline)
|
//! - `escrow_fee_bps`: u16 (default 150 = 1.5%, BM V2 baseline)
|
||||||
//! - `dispute_window_blocks`: u32 (window canonical para raise dispute post-release)
|
//! - `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)
|
//! - `early_release_allowed`: bool (depositor pueda release early canonical)
|
||||||
|
|
||||||
#![cfg_attr(not(feature = "std"), no_std, no_main)]
|
#![cfg_attr(not(feature = "std"), no_std, no_main)]
|
||||||
@@ -195,6 +198,12 @@ mod origin_escrow {
|
|||||||
InvalidConfiguration,
|
InvalidConfiguration,
|
||||||
TransferFailed,
|
TransferFailed,
|
||||||
EarlyReleaseProhibited,
|
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>;
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
@@ -317,6 +326,12 @@ mod origin_escrow {
|
|||||||
}
|
}
|
||||||
self.deposited_amount = value;
|
self.deposited_amount = value;
|
||||||
self.state = EscrowState::Active;
|
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 {
|
self.env().emit_event(Deposited {
|
||||||
depositor: self.depositor,
|
depositor: self.depositor,
|
||||||
amount: value,
|
amount: value,
|
||||||
@@ -324,6 +339,65 @@ mod origin_escrow {
|
|||||||
Ok(())
|
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).
|
/// Add milestone canonical (depositor-only, pre-active state).
|
||||||
/// Sum of milestone amounts MUST eventually equal total_amount.
|
/// Sum of milestone amounts MUST eventually equal total_amount.
|
||||||
#[ink(message)]
|
#[ink(message)]
|
||||||
@@ -647,5 +721,34 @@ mod origin_escrow {
|
|||||||
// +0.4 OGC: Σ = 1.0 OGC == total → ok (boundary)
|
// +0.4 OGC: Σ = 1.0 OGC == total → ok (boundary)
|
||||||
assert!(e.add_milestone(400_000_000_000_000_000, 100, [1u8; 32]).is_ok());
|
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).
|
/// Transfer canonical de token con sale_price (secondary sale).
|
||||||
/// Royalty 5% canonical paid al original creator from sale_price.
|
/// 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:
|
/// Flow canonical:
|
||||||
/// 1. Verify paused == false
|
/// 1. Verify paused == false
|
||||||
/// 2. Verify token exists canonical
|
/// 2. Verify token exists canonical
|
||||||
|
|||||||
Reference in new issue
Block a user