From 9cea4d9fd05e66c3fd447f5326b3bfdeb0772202 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 22 Jul 2026 12:23:42 -0500 Subject: [PATCH] uwrit: enforce UWREQ lifecycle expiry with bounded epoch-driven pruning (SEC-129 / WSA-223) expires_at_epoch was recorded on terminal UWREQ rows but never read, and no code path erased a row; PENDING requests had no deadline at all, so consensus state grew monotonically and an abandoned race pinned the user's funds in custody indefinitely. - New byexpire secondary index + pruneuwreqs(max_rows): a bounded sweep inlined from sysio.epoch::advance (after chklocks, before buildenv so expiry SWAP_REVERTs ride the same epoch's envelopes). PENDING rows past uwconfig.uwreq_pending_timeout_epochs are expired -- refund/revert via the reject_and_refund machinery, terminal status EXPIRED (first producer of that proto value) -- and terminal rows past uwconfig.uwreq_retention_epochs are erased. Never throws past the auth gate (epoch or self, like chklocks/drainfwq); a backlog drains across epochs. - Deadline stamping: createuwreq/drainfwq stamp creation_epoch + pending timeout; try_select_winner zeroes the deadline (the wall-clock lock window owns CONFIRMED rows -- chklocks always terminalizes them); terminal transitions stamp the retention window. Zero means "no deadline, never swept" (dclaim's zero-sentinel predicate). - Payload compaction: non-winner UIC blobs clear at CONFIRMED; the inbound attestation copy and every stored UIC byte blob clear at terminal transitions -- retention keeps compact audit metadata only. - Row-growth rails: per-leg UIC byte cap, candidate cap, source_tx_id / depositor / attestation-data caps at ingestion, and a projected pack_size whole-row guard before growing modifies -- all fail-closed (log + skip), never check(), per the never-throw dispatch contract. - setconfig grows uwreq_pending_timeout_epochs + uwreq_retention_epochs (defaults 10/10; positive, ceiling-capped against deadline-stamp wrap). Tests: 13 new cases. Unit: sweep auth/no-op/zero-budget + knob validation/round-trip. Dispatch-suite integration driving real epoch advances (the fixture now deploys sysio.system and satisfies the emissions gate, per the emissions_tests recipe): pre-deadline advances leave rows untouched; PENDING -> EXPIRED + compaction -> retention -> erased; from-WIRE expiry refunds the full escrow fee-exempt; the COMPLETED happy path ages out to steady state; 33 due rows against the 32-row budget prove the bound; oversized-UIC and candidate-cap rails hold. Full contracts_unit_test suite green (520 cases). Change-Id: Ifce803b6bfea1e7fa2668cd745d702b7845413c7 --- contracts/sysio.epoch/CMakeLists.txt | 1 + contracts/sysio.epoch/src/sysio.epoch.cpp | 17 + contracts/sysio.epoch/sysio.epoch.wasm | Bin 77403 -> 77610 bytes .../include/sysio.uwrit/sysio.uwrit.hpp | 141 +++++- contracts/sysio.uwrit/src/sysio.uwrit.cpp | 235 +++++++++- contracts/sysio.uwrit/sysio.uwrit.abi | 36 ++ contracts/sysio.uwrit/sysio.uwrit.wasm | Bin 128509 -> 136762 bytes contracts/tests/sysio.dispatch_tests.cpp | 433 +++++++++++++++++- contracts/tests/sysio.uwrit_tests.cpp | 98 +++- 9 files changed, 923 insertions(+), 38 deletions(-) diff --git a/contracts/sysio.epoch/CMakeLists.txt b/contracts/sysio.epoch/CMakeLists.txt index 533340f93c..7ffb82c8eb 100644 --- a/contracts/sysio.epoch/CMakeLists.txt +++ b/contracts/sysio.epoch/CMakeLists.txt @@ -36,6 +36,7 @@ if(BUILD_SYSTEM_CONTRACTS) $ $ $ + $ $ $ ) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 56d3a3871a..f51a3c4554 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -4,6 +4,9 @@ #include #include #include +// For uwrit::MAX_UWREQ_PRUNE_PER_EPOCH — the per-epoch budget advance hands +// to the inline `pruneuwreqs` sweep (the constant is owned by sysio.uwrit). +#include // Canonical sysio.system emissions types + compute_epoch_emission. The // [[sysio::contract("sysio.system")]] attribute on emission_config / t5_state // pins them to sysio.system's ABI; no readonly mirror needed here. @@ -394,6 +397,20 @@ void epoch::advance() { std::make_tuple() ).send(); + // Bounded UWREQ lifecycle sweep (SEC-129 / WSA-223): erase terminal + // uwreqs whose retention window elapsed; expire + refund PENDING uwreqs + // whose race never resolved inside the pending timeout. Runs after + // chklocks so both uwrit maintenance sweeps stay adjacent, and before + // buildenv so any SWAP_REVERT the expiry path emits rides THIS epoch's + // outbound envelopes. Budget-bounded (never throws) — a backlog simply + // drains across subsequent epochs. + action( + permission_level{get_self(), "owner"_n}, + UWRIT_ACCOUNT, + "pruneuwreqs"_n, + std::make_tuple(uwrit::MAX_UWREQ_PRUNE_PER_EPOCH) + ).send(); + // Before incrementing: evaluate per-op delivery state for the EXPIRING // epoch. The active group of the expiring epoch (`current_batch_op_group` // BEFORE the increment) is the set of ops responsible for delivering diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 3d66b1ac2245b310039e4bffa462bc0b3daca7b2..5cfbfc42a45d40c05c2310a7701c3a88d1d28997 100755 GIT binary patch delta 4479 zcmb7Hc~n(Z7r(#ro&vrHTm~5)gZGgGIG`zxN%^uem!{Q98#K|3l&6A<*@vZMT9lTN zJG!Kgr$jN!%n}hR%+v-osWkQ5=f3-lTz!9hYrS>%x%=$1r{CWDo_D;; zzOve0IM^=orzmd?ihJw%M?l_$#_*h{ zGnEiqX7CFn*sB@8wAj3#(GZz?YY)p*>hS3*o z7so$!Z?M94ST#Qj&FA(YujlZmiaOkw-7Z+r@DAxz)xH3ktj}XC?GPXptx4+)JVUh| zS6~Fa+397Bq{f}s@y5Q+gAkUbAz2hQaNAhth5)bXrL?ZkU=YpgdIv@sUvt5EAEf`_tBRT8L z-C*&*m9NeaVIK&0km#W`V*ir@HK-p@*334M*(SrSIBgDd#T(hi z%9+`~X^MU0x{)<2!j9cEdG=83F{)=DmriVYyD7e+lW*tZnCVc?oUa5vre5zJrE5hF zN-k=Luj%n34d2kDqW)M;`-?i@Q!BIw=l*~{sLQ-GY^O2v+EM?x4q84h2`-~zUOafl zl&o(|*XGCK0@bXKrql&N;R!BH;ZpKyLKfX$cJ%`FUhp6;(%c2f;w3>l7DmxG3lh1> zt5+mY)WZ8QjWQO#$?v}`ybIHew(oIMPNgozBbZfO+y>LBviOchF)q!fHPD1Qog&`v zk2urvf8Mt&$5pX4PnGbhank%T#WG(d*!-^dd$D=vq9nvq+eL}EWb|9K0BSsU6PF3A zWk36>m#E*OR3l}{19rCl-0}fta8dN#@)$F?7?&I{=efMh0DtvcI4=WCT_FR^UvV?Q zZ7X~MreFp+S0AP7l`mo@Wvt@jpoME(w0>2eV2;SLFVq=^bM-*f6B*4wI6j=~Hf!}? zYPmqh8}C(@(Vh?2qJWk6OB|eOAbkCU;RW)3+|- z8CbJ*K96Rq+4?6<+%cZnKkg`FcJa$>85AvA2f6qni zr<{T)TZO-w517ro-)!as|7hm@zGgmnOEVvo&1^hXF$;}ylAtc*Ii-%JD+jVsNm&Ow zawoobFc*g?qOv>p=7W_{%o$Nx$?_cT>SDGE3h;hdJ!^dp;SItl+q8*g` zdV$xPeDiPbGNX4%)&Wa1XjE?I`SM0-ZY^ z^>?dnF&tILkhT>rhi2nR)KqbJ6rNL1SIN=J^|Vb=F3&PadDNPeM{OBJS5M?gG^_fd z*qxS)Nm=F-`Oc-gi!NM@qL7mVYh7O_cnJL$EPcku}O^yCh3PG+8Z5T6-U zrv{?;XK+Nb3w-0?pPa^0ncJ)WUwp2fFOBEFI|m%6SLe2;qS{AeZ;jxD2%&pg}*maMr})^`E-mTPyW4N+!aQ7L|xfzGl&6i?U;;T$)!s zFZZQmWpL!tu*-4vQ}7e0;$$LyaJd7$@^dr%Osjw1fL|!{msIg9DCbauvF;Z`^!|;L zYSr0P0Qt8m9(7cbXzHsu(=$%cWogN2S!Pe`9g-AHctRvZA6|@hpYTD{MhW#O zy}BtWvT!}@v8jhsSxcucj>qrD66aTJrlh;&piD_EbPgw>en$SXisBj_sk=*DhrUL@9qSL# zq#T+_S${3{|9uN?Q1Bmp@nZR~Af$uzKUcbMl{YO*dFda6b@^uT_H6XKZmk!^ZctQkf%Ir&Nw4y3fZxx6(x&Y#x^;(zomOvcd z*6#y4)!%r_w=r8^0`$Zj{S43^d-XVhW?kR$1;7{en*juv74{ehAbiu%91bbAz|OXz zW>m7%>hwIpTHe!F3nV4@(_+&4P{XkjR9Km$^MANt=z5$Dk3_yJlK2y@775m7SLX_T z%+sgY(Co2=GFd!{&930OHDTr?n(5|Ju+q&twZ48Zz0&^SswbJROD13O^K0L+Ba-Xu z??Aekf%wvHA}CcZ>u86-e7#dcjKhNR@M^!8i{r)=NSWgC&wg=qEzaC~m26c@XOGXl}-iJ5lxWL-Qut)W!PR+i+Gt zuOj*82Lmhh_F)*075dUJ6#vcMsSgiF>$F>Qd>L+u5dS}NvT`A-Q5J)o3z3`wIn3AS zpM;|fC-fT;@aUT(5Tf^sghM|PftEgQe|)6(kHlDh+a8JNhHE7lb^3`&5K8oMjgjj= z-8>2*Sf_W4LK?HYQFsl8{#z8+zw!%3C?`60qL_vBC$U{@iVdlz(neQ4hug z{}aaj{Imaby{=cUu3uI4>--;d z)it`zu`6P|bZkz#uwvF2w>jzQ!UUKD=@Z0sS{7c(24kzKX)%-IW(aM>xWqIkdWlPk zO^p-Wa4QRlk~mXO%=j5GX|dD(8YgI+wwyERZ!yU!@d>dhGh?R4&9rT}r_M}`Pl}06 zO^r*L5i>DqQXD>Ekvex*5wM@=g>Q=kMA6q5zYqt2AATqH012IRomxpG;O~z;bz}P- zhjOTZ6L1nvK^2^VZ{RGPgYTg0E>^_46|)jn%67B8Y#-at4zPpl5G!Mc*%8+I+!6JJ zE~yv9uhp$ikF4tiO@TBC>httk>ns;&2P z``LOr8)`pSPv@4}`PIbt$3?zv?R(X<7mW43m980%+#KToUN8`Dg@~-YHX-0gNdXk5(|1R#KfMFYEqaB zS2VjD_aLkMSw9A$_Sdb3iZTqi$Q zu9GI?-~mNs_Q`-ofJC)^P&7aic8{_`GWvElp&IouOs4=0_;6|id8 zWJRrw{<1CQaN&EQt==cahn%r{bfANa>?wVKIq_v`!I-o*wExQFP|9)F=-}VCaOjj@ zXeT;8kaiN|dG>SSGsISLUM_-OkFg2uFg-C0KTi0m1xrm&44}NJIZ3ZFD8$O?y!G6~yD}1re01^9x1+98)_jBKUGKbFn9EKy$e_Y{bZNGjU?de|6i) z%&sm#y&&slos_HI%~-e> z9h6WfXpf_o{fJrf{BS^epkpa`$;=|DVap9V_!@`f1URaMIDv|)#+8Wnpxxsnd zmX!(@u+t)0jr!sogT3gv@;n^JJ1ZmLB6e9N!+&t-s!^~RSFY-b4J-9{bKVdw=D^jL z;1N1zhrvPYpB(~+Fd@4enABC-J^(&Ax5xuetua9(#^xIE*&64zK3=k5$pov+cs)MV zNsVY+`zGAO__bZwrwnrnJTY&rA9eKBd|$l2_6_(9gV!ym_XFz&!e{F9b=0qO(J^N- zv66CvU>>f?>DSueC5y6y%%pjEHD?rfX_klN+APa$y|~qjg57EjBdeG@gsfKQ27os{ z&-H`*s_%w1AbC@NQHhW?a&NeFAAL6jtB*I1(2>#C^WM>7^2D8aMq5nY4LXuo10EyRN*K)9-9k!R>}%x z8y2Nx4L!9CTGOW z`1CyQk4YE0fr=|GwCE32J%1rngk3nf+82t{1=Xwhx7U2B8|=f8ms$w!Q}NP8fc-e_ zvX|+AeQ*}p2d749a2DaZ%V{)RJZndr%Iq>4rQv#*zf&z9!MU|l#iLXfmd9Ssv@7xv zT!!vf2EtKw^p!H8DBE3o6Hcp>uDuJRPJ`YcXjcTfCD7C+@WEoXO8*z13gBDNf6Aa0 zTCd4!;Ps;b-{ajI8K##*sIj|w30K@44nN=zH>02i&9`Q_T&4jEGV8)zq*~0mciA?Jzh@Aii}9DUc&T{9)y4YY7hI~it`ZgZcwb48$^38L~j1Cwiu zEH(g2k49sg#*T0Y!x~HBC%n@b%SFhZO6mcB zr;>UA0fNjdZLCs_4Wl5TjYSv|A#|b7D6_Z`A4fJBoHad^wK_~}N`Z&!m8Mr1TtL2=`y?W;IGx{tI4u+YW$D5oi2 zOnclBo?-Um0I%jKG)mrUl+=-m%g(TmJqKk?YZ%6UgOd8z&>ztM*L?HKhqT;khbVN) zebb9Fjg-K`aev6YAW- zt}POw-56vcNN|Y|+<58pOfF>FHNx6Kz)v3;FyObd)U^+OlL7vrcmfQfi^O{X1HGFq zF6Hx=Qu#U4FydG!g*0Qrme~ihCP>wzQ7UbXa*%i&ym~a0PtjsI!=$DW*m%hUGn=|8 zOBndHET?}mK2ZRbDhA;#H!^J-E0k^`42FLx$s+V1H#dmTv2T{mExKaAC>=+wbjE0< zL*-CpE2%)yskb;@<0I{^k8clu6b-WX}*%IMq5IwB7C^TH z>EDdB=%kg59;+0+19Tj;hUbDV4O)Sd9u33xR!K`9p>0o|WT=KT)9V6}3<5vD_S3Ec?C(`~S1xiipz$~q;xH4KOKB|j1o zA-5#F4R`=-C>ibs4bV21d0D`XUseUWn$km7LfyezFIYnO-%zqu+NdPEgNY6 z0keQLfLY8#Tm>GPXbG}P+c{VdlsHd7*r7b}gjCz{MnOskl^S zyAfW8O68#uf@!a#3E~_pxo)XaSz!Vr@$ya3tC?4F!vyq2+EY^C4Ur5gOX_{W%-*^H z{JZBAKml-P^m+n~SoB}Qi-3O&wRYr7LUIpyS;RjP*+d=7MW`>, sysio::kv::index<"bywinner"_n, - sysio::const_mem_fun> + sysio::const_mem_fun>, + sysio::kv::index<"byexpire"_n, + sysio::const_mem_fun> >; /// Singleton holding the next-issued `lock_id` + the depot-origin @@ -575,8 +689,17 @@ namespace sysio { /// Fee (bps of the escrow) on caller-controlled drain-time reverts, /// routed like the settlement fee. <= MAX_FEE_BPS. uint32_t fromwire_revert_fee_bps = DEFAULT_FROMWIRE_REVERT_FEE_BPS; + /// Epochs a PENDING uwreq may wait for its underwriter race before + /// `pruneuwreqs` expires it (refund/revert + EXPIRED). The deadline + /// is stamped per row at creation; retuning is not retroactive. + uint32_t uwreq_pending_timeout_epochs = DEFAULT_UWREQ_PENDING_TIMEOUT_EPOCHS; + /// Epochs a terminal (COMPLETED / REJECTED / EXPIRED) uwreq row is + /// retained for audit before `pruneuwreqs` erases it. Stamped per + /// row at the terminal transition; retuning is not retroactive. + uint32_t uwreq_retention_epochs = DEFAULT_UWREQ_RETENTION_EPOCHS; SYSLIB_SERIALIZE(uw_config, (fee_bps)(collateral_lock_duration_ms) - (min_fromwire_amount)(fromwire_revert_fee_bps)) + (min_fromwire_amount)(fromwire_revert_fee_bps) + (uwreq_pending_timeout_epochs)(uwreq_retention_epochs)) }; using uwconfig_t = sysio::kv::global<"uwconfig"_n, uw_config>; diff --git a/contracts/sysio.uwrit/src/sysio.uwrit.cpp b/contracts/sysio.uwrit/src/sysio.uwrit.cpp index 54a6d8d836..359373b7ef 100644 --- a/contracts/sysio.uwrit/src/sysio.uwrit.cpp +++ b/contracts/sysio.uwrit/src/sysio.uwrit.cpp @@ -57,6 +57,13 @@ constexpr sysio::symbol WIRE_SYMBOL{"WIRE", 9}; /// from 1 and can never reach 2^63, so the two id spaces are disjoint. constexpr uint64_t DEPOT_ORIGIN_ID_BASE = 0x8000000000000000ULL; +/// Fixed serialized-size allowance for a NEW `commit_entry`'s non-vector +/// fields (underwriter name, two timestamp/outpost-id pairs, status, empty +/// reason + the vectors' length prefixes) in rcrdcommit's projected-row-size +/// guard. The true packed size is ~50 bytes; 128 over-counts on purpose — +/// the guard may only ever be tighter than reality, never looser. +constexpr size_t COMMIT_ENTRY_PACK_ALLOWANCE_BYTES = 128; + uint64_t current_time_ms() { return static_cast(current_time_point().sec_since_epoch()) * 1000; } @@ -678,7 +685,9 @@ uint64_t next_fromwire_id(name self) { void uwrit::setconfig(uint32_t fee_bps, uint64_t collateral_lock_duration_ms, uint64_t min_fromwire_amount, - uint32_t fromwire_revert_fee_bps) { + uint32_t fromwire_revert_fee_bps, + uint32_t uwreq_pending_timeout_epochs, + uint32_t uwreq_retention_epochs) { require_auth(get_self()); // Reject a 100% (or higher) fee: it zeroes the post-fee WIRE leg // (`net == 0`), which let a swap debit destination reserve liquidity while @@ -701,13 +710,28 @@ void uwrit::setconfig(uint32_t fee_bps, // the never-throw drainfwq drain (`refundwire` rejects zero transfers). check(fromwire_revert_fee_bps <= MAX_FEE_BPS, "fromwire_revert_fee_bps must be below 10000 (100%): a 100% revert fee zeroes the refund"); + // A zero pending timeout would expire every uwreq the epoch it is created — + // no race could ever resolve; a zero retention would erase terminal rows + // before the audit window they exist for. The shared ceiling prevents a + // near-UINT32_MAX knob from wrapping the `current_epoch + knob` deadline + // stamp to a tiny epoch index (instant expiry). + check(uwreq_pending_timeout_epochs > 0, + "uwreq_pending_timeout_epochs must be positive"); + check(uwreq_pending_timeout_epochs <= MAX_UWREQ_LIFECYCLE_EPOCHS, + "uwreq_pending_timeout_epochs exceeds the lifecycle ceiling"); + check(uwreq_retention_epochs > 0, + "uwreq_retention_epochs must be positive"); + check(uwreq_retention_epochs <= MAX_UWREQ_LIFECYCLE_EPOCHS, + "uwreq_retention_epochs exceeds the lifecycle ceiling"); uwconfig_t cfg_tbl(get_self()); uw_config cfg = cfg_tbl.get_or_default(uw_config{}); - cfg.fee_bps = fee_bps; - cfg.collateral_lock_duration_ms = collateral_lock_duration_ms; - cfg.min_fromwire_amount = min_fromwire_amount; - cfg.fromwire_revert_fee_bps = fromwire_revert_fee_bps; + cfg.fee_bps = fee_bps; + cfg.collateral_lock_duration_ms = collateral_lock_duration_ms; + cfg.min_fromwire_amount = min_fromwire_amount; + cfg.fromwire_revert_fee_bps = fromwire_revert_fee_bps; + cfg.uwreq_pending_timeout_epochs = uwreq_pending_timeout_epochs; + cfg.uwreq_retention_epochs = uwreq_retention_epochs; cfg_tbl.set(cfg, ram_payer); } @@ -763,6 +787,10 @@ void uwrit::createuwreq(uint64_t attestation_id, } } + // One config snapshot for the whole ingestion: the variance quote and the + // PENDING deadline stamp below must read one consistent config. + const uw_config cfg = read_config(get_self()); + // Pull the slug_name triples + amounts out of the decoded SwapRequest. // The source token's code lives on the TokenAmount; the source chain // and source reserve are top-level fields. Destination has all three @@ -854,6 +882,22 @@ void uwrit::createuwreq(uint64_t attestation_id, return; } + // Row-growth rails (SEC-129 / WSA-223): the row stores these payloads + // verbatim and every later `modify` re-serializes the whole row inside the + // never-throw dispatch surfaces, so oversized inputs are refused at the + // door. All caps sit far above legitimate traffic; a violation is a + // malformed/hostile outpost payload — refund it like every other + // structural rejection, never check(). + if (data.size() > MAX_ATTESTATION_INBOUND_DATA_BYTES || + sr.source_tx_id.size() > MAX_SOURCE_TX_ID_BYTES || + sr.actor.address.size() > MAX_DEPOSITOR_BYTES) { + emit_swap_revert(get_self(), chain_code, attestation_id, sr, + src_chain_code, src_reserve_code, + "SwapRequest rejected: payload exceeds a size cap " + "(attestation data / source_tx_id / depositor address)"); + return; + } + // Privacy gate — a private reserve only swaps against a counterpart // reserve owned by the same WIRE account (the authex-linked matcher // recorded at match time), and is excluded from WIRE-endpoint swaps @@ -898,7 +942,7 @@ void uwrit::createuwreq(uint64_t attestation_id, // matching reserves are present. const uint64_t current_quote = swap_quote(src_chain_code, src_token_code, src_reserve_code, dst_chain_code, dst_token_code, dst_reserve_code, - src_amount, current_fee_bps(get_self())); + src_amount, cfg.fee_bps); if (sr.target_amount != 0) { if (current_quote == 0) { // Zero quote: skip only when a required reserve is genuinely @@ -949,7 +993,12 @@ void uwrit::createuwreq(uint64_t attestation_id, .winner = name{}, .committed_at_ms = 0, .settled_at_ms = 0, - .expires_at_epoch = 0, + // The PENDING deadline (SEC-129 / WSA-223): if no underwriter wins the + // race within this many epochs, `pruneuwreqs` expires the row and + // refunds the source side. No trigger ever announces "no winner is + // coming" — commits are external and optional — so the deadline is what + // converts a later epoch advance into the recovery trigger. + .expires_at_epoch = get_current_epoch() + cfg.uwreq_pending_timeout_epochs, .attestation_inbound_data = std::move(data), .attestation_outbound_data = {}, }); @@ -995,20 +1044,29 @@ void disqualify_candidate(uwrit::uwreqs_t& reqs, const uwrit::id_key& pk, }); } -/// Terminally reject a uwreq that can never settle: refund the source side -/// and mark the row REJECTED, releasing any in-flight commits. **Non-throwing** -/// — safe inside the evalcons dispatch chain (a `check()` here would stall OPP -/// consensus chain-wide). The refund routes by source-leg kind: +/// Terminally close a uwreq that can never settle: refund the source side and +/// mark the row with `terminal_status` (REJECTED for active rejections — the +/// default; EXPIRED when `pruneuwreqs` abandons a PENDING race past its +/// deadline), releasing any in-flight commits. **Non-throwing** — safe inside +/// the evalcons dispatch chain (a `check()` here would stall OPP consensus +/// chain-wide). The refund routes by source-leg kind: /// * outpost source (`src_needed`): best-effort SWAP_REVERT back to the /// source outpost (requires the stored SwapRequest to decode; a depot /// source has no outpost to route to, so `find_outpost_id_for_chain` /// returns nullopt and the revert is skipped). /// * depot source (from-WIRE): refund the escrowed WIRE to the /// depositor via `reserv::refundwire`. +/// The terminal modify also stamps the retention deadline +/// (`uwconfig.uwreq_retention_epochs`) and clears the row's heavy payloads +/// (inbound attestation copy + every stored UIC byte blob) — they were only +/// needed to reach this decision; the compact audit metadata (ids, codes, +/// amounts, statuses, reasons, timestamps) is what retention keeps. void reject_and_refund(name self, uwrit::uwreqs_t& reqs, const uwrit::id_key& pk, const uwrit::uw_request_t& req, bool src_needed, const std::string& revert_reason, - const std::string& commit_reason) { + const std::string& commit_reason, + UnderwriteRequestStatus terminal_status = + UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_REJECTED) { if (src_needed) { opp::attestations::SwapRequest sr; auto in = zpp::bits::in{ @@ -1036,15 +1094,23 @@ void reject_and_refund(name self, uwrit::uwreqs_t& reqs, const uwrit::id_key& pk "refund on uwreq ", req.id, "\n"); } reqs.modify(same_payer, pk, [&](auto& r) { - r.status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_REJECTED; + r.status = terminal_status; r.settled_at_ms = current_time_ms(); - r.expires_at_epoch = get_current_epoch() + uwrit::UWREQ_RETENTION_EPOCHS; + r.expires_at_epoch = get_current_epoch() + read_config(self).uwreq_retention_epochs; for (auto& c : r.commits_by) { if (c.status == UnderwriteStatus::UNDERWRITE_STATUS_INTENT_SUBMITTED) { c.status = UnderwriteStatus::UNDERWRITE_STATUS_RELEASED; c.reason = commit_reason; } + // Terminal compaction (SEC-129 / WSA-223): the raw UIC blobs were + // only needed to resolve the race; retention keeps the compact + // audit metadata, not the payload bytes. + c.source_uic_bytes.clear(); + c.dest_uic_bytes.clear(); } + // The refund above already decoded the snapshot's copy; the stored + // bytes have no remaining reader on a terminal row. + r.attestation_inbound_data.clear(); }); } @@ -1403,6 +1469,11 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate) { r.status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED; r.winner = candidate; r.committed_at_ms = current_time_ms(); + // Deadline cleared (SEC-129 / WSA-223): a CONFIRMED row is owned by the + // wall-clock lock window — `chklocks` is guaranteed to terminalize it + // once the locks expire, so a second (epoch-count) deadline would just + // race the first. 0 excludes the row from the `pruneuwreqs` sweep. + r.expires_at_epoch = 0; // Mark the winner's commit_entry CONFIRMED, others RELEASED (loser). for (auto& c : r.commits_by) { if (c.underwriter == candidate) { @@ -1412,6 +1483,14 @@ void try_select_winner(name self, uint64_t uwreq_id, name candidate) { c.status = UnderwriteStatus::UNDERWRITE_STATUS_RELEASED; c.reason = "lost the COMMIT race"; } + // Race resolved — non-winner UIC blobs have no remaining reader + // (re-arm via rcrdcommit is PENDING-gated, and only PENDING rows + // re-evaluate). The winner's bytes stay through the lock window as + // the challenge-evidence trail; `chklocks` clears them at COMPLETED. + if (c.underwriter != candidate) { + c.source_uic_bytes.clear(); + c.dest_uic_bytes.clear(); + } } }); @@ -1523,6 +1602,40 @@ void uwrit::rcrdcommit(uint64_t uwreq_id, return; } + // Row-growth rails (SEC-129 / WSA-223) — every rail fails closed with no + // mutation, never check() (we are inside the evalcons dispatch chain). + // A stored UIC leg is capped so a hostile relay cannot inflate the row + // with an oversized blob… + if (uic_bytes.size() > MAX_UIC_LEG_BYTES) { + sysio::print("rcrdcommit: uwreq ", uwreq_id, " UIC payload of ", + uic_bytes.size(), " bytes exceeds the per-leg cap, skipping\n"); + return; + } + // …the candidate roster is capped so the ACTIVE-underwriter population + // bounds scan cost but not row size… + const bool existing_entry = + std::any_of(req_snapshot.commits_by.begin(), req_snapshot.commits_by.end(), + [&](const commit_entry& c) { return c.underwriter == underwriter; }); + if (!existing_entry && req_snapshot.commits_by.size() >= MAX_UWREQ_CANDIDATES) { + sysio::print("rcrdcommit: uwreq ", uwreq_id, " already carries ", + req_snapshot.commits_by.size(), + " candidates (cap reached), skipping new candidate\n"); + return; + } + // …and the projected whole-row size is guarded so THIS modify — and every + // later consensus-dispatched one — stays clear of chain KV value limits. + // Conservative projection: current packed row + the incoming leg bytes + + // a fixed allowance for a new entry's non-vector fields (replaced bytes, + // if any, are ignored — over-counting only tightens the guard). + const size_t projected_row_bytes = pack_size(req_snapshot) + + uic_bytes.size() + + (existing_entry ? 0 : COMMIT_ENTRY_PACK_ALLOWANCE_BYTES); + if (projected_row_bytes > MAX_UWREQ_ROW_BYTES) { + sysio::print("rcrdcommit: uwreq ", uwreq_id, " projected row size ", + projected_row_bytes, " bytes exceeds the row cap, skipping\n"); + return; + } + reqs.modify(same_payer, pk, [&](auto& r) { auto* c = find_or_create_commit(r, underwriter); uint64_t now_ms = current_time_ms(); @@ -1807,7 +1920,10 @@ void uwrit::drainfwq() { .winner = name{}, .committed_at_ms = 0, .settled_at_ms = 0, - .expires_at_epoch = 0, + // Same PENDING deadline as createuwreq (SEC-129 / WSA-223): a + // from-WIRE race that never resolves is expired by `pruneuwreqs`, + // which refunds the escrow in full via `refundwire`. + .expires_at_epoch = get_current_epoch() + cfg.uwreq_pending_timeout_epochs, .attestation_inbound_data = std::move(encoded), .attestation_outbound_data = {}, }); @@ -1880,10 +1996,15 @@ void uwrit::chklocks() { } // COMPLETED flip — a CONFIRMED uwreq whose final lock just swept has - // exited its challenge window. + // exited its challenge window. The flip stamps the retention deadline + // (`pruneuwreqs` erases the row once it elapses) and clears the remaining + // heavy payloads: with the challenge window closed, the winner's UIC + // evidence and the inbound attestation copy have no remaining reader — + // retention keeps the compact audit metadata only (SEC-129 / WSA-223). uwreqs_t reqs(get_self()); auto byuwreq = locks.get_index<"byuwreq"_n>(); const uint32_t now_ep = get_current_epoch(); + const uint32_t retention_epochs = read_config(get_self()).uwreq_retention_epochs; for (uint64_t id : affected) { auto lit = byuwreq.lower_bound(id); if (lit != byuwreq.end() && lit->uwreq_id == id) continue; // locks remain @@ -1894,9 +2015,89 @@ void uwrit::chklocks() { reqs.modify(same_payer, pk, [&](auto& row) { row.status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_COMPLETED; row.settled_at_ms = current_time_ms(); - row.expires_at_epoch = now_ep + UWREQ_RETENTION_EPOCHS; + row.expires_at_epoch = now_ep + retention_epochs; + row.attestation_inbound_data.clear(); + for (auto& c : row.commits_by) { + c.source_uic_bytes.clear(); + c.dest_uic_bytes.clear(); + } }); } } +// --------------------------------------------------------------------------- +// pruneuwreqs — bounded UWREQ lifecycle sweep (SEC-129 / WSA-223) +// --------------------------------------------------------------------------- +// +// The enforcement half of `uw_request_t.expires_at_epoch`: every deadline is +// stamped by the lifecycle writes (createuwreq / drainfwq / reject_and_refund +// / chklocks) and evaluated only HERE, lazily, when the epoch machinery +// triggers this action. Nothing is scheduled; a row past its deadline is +// inert data until the sweep fires. NEVER throws past the auth gate: it runs +// inline inside `sysio.epoch::advance`, where an abort stalls epoch progress +// chain-wide. The per-call `max_rows` budget keeps the sweep inside advance's +// transaction CPU deadline (same rationale as MAX_FWQ_DRAIN_PER_EPOCH); a +// backlog drains across subsequent epochs. +void uwrit::pruneuwreqs(uint32_t max_rows) { + // Two valid callers, mirroring chklocks / drainfwq: + // * sysio.epoch::advance — inlined each epoch with MAX_UWREQ_PRUNE_PER_EPOCH. + // * sysio.uwrit — manual backlog drain with a caller-chosen budget. + check(has_auth(EPOCH_ACCOUNT) || has_auth(get_self()), + "pruneuwreqs requires sysio.epoch or sysio.uwrit authority"); + if (max_rows == 0) return; + + const uint32_t now_ep = get_current_epoch(); + uwreqs_t reqs(get_self()); + auto idx = reqs.get_index<"byexpire"_n>(); + + // Collect-then-act, bounded to `max_rows` copies: a modify/erase + // invalidates the index cursor (same two-pass shape as chklocks), and the + // bounded copy keeps the scan cost budgeted. `lower_bound(1)` skips the + // `expires_at_epoch == 0` population — rows with no deadline (CONFIRMED, + // owned by the wall-clock lock window) are never swept. + std::vector due; + for (auto it = idx.lower_bound(uint64_t{1}); + it != idx.end() && it->expires_at_epoch <= now_ep && due.size() < max_rows; + ++it) { + due.push_back(*it); + } + if (due.empty()) return; + + for (const auto& req : due) { + auto pk = id_key{req.id}; + switch (req.status) { + case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING: { + // The race never resolved inside the pending timeout — abandon + // it: refund the source side (SWAP_REVERT to the source outpost, + // or the full from-WIRE escrow via refundwire — expiry is not a + // caller-controlled revert cause), flip to EXPIRED, and start the + // terminal retention window. PENDING rows hold no locks (locks + // are first written in the same modify that flips CONFIRMED), so + // there is nothing to release. + const bool src_needed = !leg_is_depot(req.src_chain_code); + reject_and_refund(get_self(), reqs, pk, req, src_needed, + "underwrite request expired: no underwriter resolved the race " + "within the pending timeout", + "uwreq expired: pending deadline elapsed before the race resolved", + UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_EXPIRED); + break; + } + case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_REJECTED: + case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_COMPLETED: + case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_EXPIRED: + // Terminal row past its retention window — remove it. + reqs.erase(pk); + break; + default: + // CONFIRMED (or a future status) with a live deadline should be + // unreachable — winner selection zeroes the deadline. Defensive + // skip + log; never throw inside advance. + sysio::print("pruneuwreqs: uwreq ", req.id, " in status ", + magic_enum::enum_integer(req.status), + " unexpectedly carries an elapsed deadline, skipping\n"); + break; + } + } +} + } // namespace sysio diff --git a/contracts/sysio.uwrit/sysio.uwrit.abi b/contracts/sysio.uwrit/sysio.uwrit.abi index 95bc74caca..68e0f90ee9 100644 --- a/contracts/sysio.uwrit/sysio.uwrit.abi +++ b/contracts/sysio.uwrit/sysio.uwrit.abi @@ -199,6 +199,16 @@ } ] }, + { + "name": "pruneuwreqs", + "base": "", + "fields": [ + { + "name": "max_rows", + "type": "uint32" + } + ] + }, { "name": "rcrdcommit", "base": "", @@ -252,6 +262,14 @@ { "name": "fromwire_revert_fee_bps", "type": "uint32" + }, + { + "name": "uwreq_pending_timeout_epochs", + "type": "uint32" + }, + { + "name": "uwreq_retention_epochs", + "type": "uint32" } ] }, @@ -344,6 +362,14 @@ { "name": "fromwire_revert_fee_bps", "type": "uint32" + }, + { + "name": "uwreq_pending_timeout_epochs", + "type": "uint32" + }, + { + "name": "uwreq_retention_epochs", + "type": "uint32" } ] }, @@ -468,6 +494,11 @@ "type": "drainfwq", "ricardian_contract": "" }, + { + "name": "pruneuwreqs", + "type": "pruneuwreqs", + "ricardian_contract": "" + }, { "name": "rcrdcommit", "type": "rcrdcommit", @@ -563,6 +594,11 @@ "name": "bywinner", "key_type": "uint64", "table_id": 29561 + }, + { + "name": "byexpire", + "key_type": "uint64", + "table_id": 36295 } ] } diff --git a/contracts/sysio.uwrit/sysio.uwrit.wasm b/contracts/sysio.uwrit/sysio.uwrit.wasm index eb22f28046e2a207e9c7e474d62145a7ca3a6c38..cce16ed5276c1061a517e501ec593965a386dd5b 100755 GIT binary patch delta 49339 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 diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index cbadc48e61..b83eb97028 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -807,6 +807,87 @@ class sysio_dispatch_tester : public tester { BOOST_REQUIRE(!get_uwreq(att_id).is_null()); } + // ── SEC-129 / WSA-223: real epoch-aging helpers ────────────────────────── + // + // The bootstrap advance gate-blocks on missing emissions state, so every + // dispatch test normally runs at epoch 0. The UWREQ lifecycle sweep is + // trigger-driven off real advances, so its tests configure emissions the + // way emissions_tests.cpp does and then genuinely advance the epoch index. + + /// Push a sysio.system action (ABI resolved from chain state — the system + /// account runs this build's genesis code, like sysio.roa above). + action_result push_system(name signer, name action_name, const fc::variant_object& data) { + try { + base_tester::push_action(config::system_account_name, action_name, signer, data); + return success(); + } catch (const fc::exception& ex) { + return error(ex.top_message()); + } + } + + /// Make `sysio.epoch::advance` genuinely advance: the genesis `sysio` + /// account runs only the boot contract, so the full sysio.system (where + /// the emissions gate's config + t5 state live) is deployed first — the + /// same sequence as emissions_tests.cpp's fixture — then the emission + /// config + t5 state are initialized and the T5 holding accounts payepoch + /// transfers WIRE to are created. Values mirror emissions_tests.cpp's + /// defaults. Requires setup_wire_token_and_reserves() first (payepoch pays + /// WIRE out of sysio's token balance). + void enable_epoch_advancement() { + set_code(config::system_account_name, contracts::system_wasm()); + set_abi(config::system_account_name, contracts::system_abi().data()); + BOOST_REQUIRE_EQUAL(success(), push_system(config::system_account_name, "init"_n, + mvo()("version", 0)("core", "4,SYS"))); + produce_blocks(); + for (auto a : {"sysio.dclaim"_n, "sysio.gov"_n, "sysio.batch"_n, "sysio.ops"_n}) { + if (!control->db().find(a)) { + create_accounts({a}, /*multisig=*/false, /*include_code=*/false, + /*include_roa_policy=*/false, /*include_ram_gift=*/true); + } + } + produce_blocks(); + constexpr uint32_t seconds_per_month = 30u * 24u * 60u * 60u; + BOOST_REQUIRE_EQUAL(success(), push_system(config::system_account_name, "setemitcfg"_n, + mvo()("cfg", mvo() + ("t1_allocation", int64_t{7'500'000'000'000'000LL}) + ("t2_allocation", int64_t{1'000'000'000'000'000LL}) + ("t3_allocation", int64_t{100'000'000'000'000LL}) + ("t1_duration", 12u * seconds_per_month) + ("t2_duration", 24u * seconds_per_month) + ("t3_duration", 36u * seconds_per_month) + ("min_claimable", int64_t{10'000'000'000LL}) + ("t5_distributable", int64_t{375'000'000'000'000'000LL}) + ("t5_floor", int64_t{125'000'000'000'000'000LL}) + ("target_annual_decay_bps", uint16_t(6940)) + ("annual_initial_emission", int64_t{563'150'000'000'000LL} * 365) + ("annual_max_emission", int64_t{3'000'000'000'000'000LL} * 365) + ("annual_min_emission", int64_t{100'000'000'000'000LL} * 365) + ("compute_bps", uint16_t(4000)) + ("capex_bps", uint16_t(2000)) + ("governance_bps", uint16_t(1000)) + ("producer_bps", uint16_t(7000)) + ("batch_op_bps", uint16_t(3000)) + ("standby_end_rank", uint32_t(28)) + ("epoch_log_retention_count", uint32_t(8640)) + ("pay_cadence_epochs", uint16_t(1))))); + BOOST_REQUIRE_EQUAL(success(), push_system(config::system_account_name, "initt5"_n, + mvo()("start_time", time_point_sec(control->head().block_time())))); + produce_blocks(); + } + + /// Cross one epoch boundary and advance. epoch_duration_sec is 60 in this + /// fixture (bootstrap_for_dispatch); 124 half-second blocks = 62s crosses + /// it. advance is pushed with the epoch contract's own authority (advance + /// accepts sysio.msgch OR sysio.epoch post-genesis). Each successful + /// advance fires the real inline maintenance chain — chklocks, + /// pruneuwreqs(MAX_UWREQ_PRUNE_PER_EPOCH), drainfwq, buildenv — exactly as + /// in production. + void age_one_epoch() { + produce_blocks(124); + BOOST_REQUIRE_EQUAL(success(), + push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "advance"_n, mvo())); + } + abi_serializer msgch_abi, opreg_abi, uwrit_abi, epoch_abi, reserv_abi, authex_abi, chains_abi, roa_abi, token_abi; @@ -2315,7 +2396,8 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_bounds_rows_per_epoch, sysio_dispatch_tester) { // default 5-WIRE floor and the revert fee have their own dedicated cases below. BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 0))); + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 0) + ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); // A funded from-WIRE swap user (plain account, no ROA policy). create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, @@ -2416,7 +2498,8 @@ BOOST_FIXTURE_TEST_CASE(swapfromwire_enforces_min_amount, sysio_dispatch_tester) constexpr uint64_t LOWERED_FLOOR = 1'000'000; BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", LOWERED_FLOOR)("fromwire_revert_fee_bps", 10))); + ("min_fromwire_amount", LOWERED_FLOOR)("fromwire_revert_fee_bps", 10) + ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); BOOST_REQUIRE_EQUAL( error("assertion failure with message: swapfromwire: wire_amount below the configured minimum"), swap(LOWERED_FLOOR - 1)); @@ -2441,7 +2524,8 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_charges_revert_fee_on_caller_fault, sysio_dispa register_wire_depot(); // depot registered => the drain reaches the variance check BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", REVERT_FEE_BPS))); + ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", REVERT_FEE_BPS) + ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, /*include_code=*/true, /*include_roa_policy=*/false); @@ -2496,7 +2580,8 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_full_refund_on_system_caused_revert, sysio_disp // empty, which is a system-caused revert (the registry, not the caller's parameters). BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", 100))); + ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", 100) + ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, /*include_code=*/true, /*include_roa_policy=*/false); @@ -2528,4 +2613,344 @@ BOOST_FIXTURE_TEST_CASE(drainfwq_full_refund_on_system_caused_revert, sysio_disp "rewardbkt"_n, "rewardbkt"_n).empty()); } FC_LOG_AND_RETHROW() } +// ═════════════════════════════════════════════════════════════════════════ +// SEC-129 / WSA-223 — UWREQ lifecycle: expiry is enforced by triggers. +// +// `expires_at_epoch` is passive row data; the `pruneuwreqs` sweep inlined in +// every real `sysio.epoch::advance` is the only thing that evaluates it. The +// cases below drive genuine advances (emissions gate satisfied) and assert +// the full lifecycle: PENDING deadline → EXPIRED + refund + compaction → +// retention → erased; CONFIRMED → COMPLETED (chklocks) → retention → erased; +// the per-epoch budget; and the rcrdcommit row-growth rails. +// ═════════════════════════════════════════════════════════════════════════ + +// A PENDING uwreq whose underwriter race never resolves survives pre-deadline +// advances untouched, is expired by the first advance at/past its deadline +// (EXPIRED + payload compaction), and is erased once retention elapses. +BOOST_FIXTURE_TEST_CASE(uwreq_pending_timeout_expires_then_retention_erases, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + // Pending timeout 2 epochs + retention 1 epoch — the shortest schedule + // that still proves the pre-deadline advance is a no-op for the row. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() + ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) + ("uwreq_pending_timeout_epochs", 2)("uwreq_retention_epochs", 1))); + constexpr uint64_t ATT_ID = 9100; + setup_eth_to_sol_uwreq(ATT_ID); // PENDING, no commits, deadline = 0 + 2 + enable_epoch_advancement(); + + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); + BOOST_REQUIRE(req["attestation_inbound_data"].as_string().size() > 0); + } + + // Epoch 1 < deadline 2 — the row is inert data; the sweep leaves it alone. + age_one_epoch(); + BOOST_REQUIRE_EQUAL(1u, current_epoch()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", + get_uwreq(ATT_ID)["status"].as_string()); + + // Epoch 2 == deadline — the same advance's inline sweep expires it. + age_one_epoch(); + BOOST_REQUIRE_EQUAL(2u, current_epoch()); + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE(!req.is_null()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_EXPIRED", req["status"].as_string()); + BOOST_REQUIRE(req["settled_at_ms"].as_uint64() > 0); + // Terminal compaction: the inbound attestation copy is gone; the + // compact audit metadata (codes, amounts, timestamps) is retained. + BOOST_REQUIRE_EQUAL(0u, req["attestation_inbound_data"].as_string().size()); + // Retention window re-stamped: epoch 2 + 1. + BOOST_REQUIRE_EQUAL(3u, req["expires_at_epoch"].as()); + } + + // Epoch 3 — retention elapsed; the row is erased outright. + age_one_epoch(); + BOOST_REQUIRE_EQUAL(3u, current_epoch()); + BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); +} FC_LOG_AND_RETHROW() } + +// A queued swap-from-WIRE whose race never resolves refunds the user's FULL +// escrow at expiry (expiry is not a caller-controlled revert cause — no +// revert fee) and follows the same EXPIRED → retention → erased lifecycle. +BOOST_FIXTURE_TEST_CASE(uwreq_from_wire_pending_timeout_refunds_escrow, + sysio_dispatch_tester) { try { + constexpr uint64_t DEPOT_ORIGIN_ID_0 = 0x8000000000000000ull; + constexpr uint64_t ESCROW = 1'000'000; + const auto WIRE_SYM = symbol(9, "WIRE"); + + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); + register_wire_depot(); + // Non-zero revert fee configured on purpose: the full refund below proves + // the expiry path is fee-exempt even when a fee is configured. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() + ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 100) + ("uwreq_pending_timeout_epochs", 1)("uwreq_retention_epochs", 1))); + + // Created + funded before the full system contract lands on `sysio`, the + // same way every other from-WIRE case provisions its user. + create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, + /*include_code=*/true, /*include_roa_policy=*/false); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, + "transfer"_n, mvo()("from", "sysio")("to", "swapuser") + ("quantity", "10.000000000 WIRE")("memo", "fund swap user"))); + const int64_t funded = + get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount(); + + enable_epoch_advancement(); + + // target == wire_amount with 100% tolerance prices within variance against + // the balanced 1e12/1e12 reserve, so the drain emplaces the uwreq (no + // refund at drain) — same recipe as drainfwq_bounds_rows_per_epoch. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, + "swapfromwire"_n, mvo() + ("user", "swapuser") + ("wire_amount", ESCROW) + ("dst_chain_code", codename_mvo("ETH")) + ("dst_token_code", codename_mvo("ETH")) + ("dst_reserve_code", codename_mvo("PRIMARY")) + ("target_amount", uint64_t{1'000'000}) + ("target_tolerance_bps", uint32_t{10000}) + ("recipient_kind", ChainKind::CHAIN_KIND_EVM) + ("recipient_addr", std::vector(20, '\x0a')))); + BOOST_REQUIRE_EQUAL(funded - int64_t(ESCROW), + get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); + + // Advance #1: drainfwq (inline, post-increment) creates the PENDING uwreq + // at epoch 1 with deadline 1 + 1 = 2. + age_one_epoch(); + { + const auto req = get_uwreq(DEPOT_ORIGIN_ID_0); + BOOST_REQUIRE(!req.is_null()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); + BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); + } + + // Advance #2 (epoch 2 == deadline): the sweep expires the row and refunds + // the FULL escrow via reserv::refundwire. + age_one_epoch(); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_EXPIRED", + get_uwreq(DEPOT_ORIGIN_ID_0)["status"].as_string()); + BOOST_REQUIRE_EQUAL(funded, + get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); + + // Advance #3 (epoch 3): retention elapsed — erased. + age_one_epoch(); + BOOST_REQUIRE(get_uwreq(DEPOT_ORIGIN_ID_0).is_null()); +} FC_LOG_AND_RETHROW() } + +// A settled swap's row is compacted at COMPLETED (chklocks closes the +// challenge window) and erased once retention elapses — the full happy-path +// lifecycle driven end to end by real advances. Winner selection itself +// zeroes the deadline (the lock window owns a CONFIRMED row). +BOOST_FIXTURE_TEST_CASE(uwreq_completed_row_erased_after_retention, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_wire_token_and_reserves(); + BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); + // 60s challenge window (== one epoch) + 1-epoch retention; the pending + // timeout stays clear of the race so only the lock machinery drives it. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() + ("fee_bps", 10)("collateral_lock_duration_ms", 60'000u) + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) + ("uwreq_pending_timeout_epochs", 5)("uwreq_retention_epochs", 1))); + enable_epoch_advancement(); + + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t primary = fc::slug_name{"PRIMARY"}.value; + const uint64_t secondary = fc::slug_name{"SECOND"}.value; + constexpr uint64_t ATT_ID = 9200; + + // Same-chain double-leg winner recipe as swap_same_token_legs_exact_balance_wins. + BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 200)); + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, primary, /*src_amount*/ 100, + eth, eth, secondary, /*dst_amount*/ 100, + /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", + make_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary))); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", + make_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary))); + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", req["status"].as_string()); + // Winner selection cleared the deadline — chklocks owns the row now. + BOOST_REQUIRE_EQUAL(0u, req["expires_at_epoch"].as()); + } + + // Advance #1 (62s elapsed > the 60s lock window): chklocks sweeps both + // locks, flips COMPLETED, stamps retention 1 + 1 = 2, and clears the + // remaining heavy payloads. The same advance's sweep (epoch 1 < 2) must + // NOT touch the freshly-completed row. + age_one_epoch(); + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE(!req.is_null()); + BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", req["status"].as_string()); + BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); + BOOST_REQUIRE_EQUAL(0u, req["attestation_inbound_data"].as_string().size()); + for (const auto& c : req["commits_by"].get_array()) { + BOOST_REQUIRE_EQUAL(0u, c["source_uic_bytes"].as_string().size()); + BOOST_REQUIRE_EQUAL(0u, c["dest_uic_bytes"].as_string().size()); + } + } + + // Advance #2 (epoch 2): retention elapsed — erased. Steady state: the + // table carries nothing from a fully-settled swap. + age_one_epoch(); + BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); +} FC_LOG_AND_RETHROW() } + +// The per-epoch budget bounds the sweep's work: with more due rows than +// MAX_UWREQ_PRUNE_PER_EPOCH (32), one advance handles exactly 32 and the +// backlog drains the next epoch — advance's CPU stays bounded, nothing is +// lost, nothing is double-handled. +BOOST_FIXTURE_TEST_CASE(pruneuwreqs_budget_bounds_rows_per_epoch, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + // Retention kept long (100) so the second advance only expires the + // leftover PENDING row instead of competing with 32 fresh erases. + BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() + ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) + ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) + ("uwreq_pending_timeout_epochs", 1)("uwreq_retention_epochs", 100))); + constexpr uint64_t FIRST_ATT_ID = 9300; + constexpr uint32_t ROWS = 33; // MAX_UWREQ_PRUNE_PER_EPOCH + 1 + setup_eth_to_sol_uwreq(FIRST_ATT_ID); // registers SOLANA + reserves once + const uint64_t eth = fc::slug_name{"ETH"}.value; + const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; + const uint64_t sol_token = fc::slug_name{"SOL"}.value; + const uint64_t primary = fc::slug_name{"PRIMARY"}.value; + for (uint32_t i = 1; i < ROWS; ++i) { + const auto sr = encode_swap_request( + ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), + eth, eth, primary, 100, sol_chain, sol_token, primary, 100, + 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); + BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(FIRST_ATT_ID + i, eth, sr)); + if (i % 8 == 7) produce_blocks(); // spread across blocks (per-block CPU) + } + produce_blocks(); + enable_epoch_advancement(); + + // Advance #1 (epoch 1 == every row's deadline): exactly the budgeted 32 + // rows are expired; one stays PENDING for the next epoch. + age_one_epoch(); + uint32_t expired = 0, pending = 0; + for (uint32_t i = 0; i < ROWS; ++i) { + const auto req = get_uwreq(FIRST_ATT_ID + i); + BOOST_REQUIRE(!req.is_null()); + const auto st = req["status"].as_string(); + if (st == "UNDERWRITE_REQUEST_STATUS_EXPIRED") ++expired; + else if (st == "UNDERWRITE_REQUEST_STATUS_PENDING") ++pending; + } + BOOST_REQUIRE_EQUAL(32u, expired); + BOOST_REQUIRE_EQUAL(1u, pending); + + // Advance #2 drains the backlog: every row is now EXPIRED. + age_one_epoch(); + expired = 0; + for (uint32_t i = 0; i < ROWS; ++i) { + const auto req = get_uwreq(FIRST_ATT_ID + i); + BOOST_REQUIRE(!req.is_null()); + if (req["status"].as_string() == "UNDERWRITE_REQUEST_STATUS_EXPIRED") ++expired; + } + BOOST_REQUIRE_EQUAL(ROWS, expired); +} FC_LOG_AND_RETHROW() } + +// ── rcrdcommit row-growth rails (SEC-129 / WSA-223) ── + +// An oversized UIC payload is refused at the door: no candidate entry, no +// stored bytes, no throw. At the cap, the commit records normally. +BOOST_FIXTURE_TEST_CASE(rcrdcommit_oversized_uic_leg_is_dropped, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + constexpr uint64_t ATT_ID = 9400; + setup_eth_to_sol_uwreq(ATT_ID); + const uint64_t eth = fc::slug_name{"ETH"}.value; + + // One byte past MAX_UIC_LEG_BYTES (2048) — dropped with no mutation. + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", + std::vector(2049, '\x01'))); + BOOST_REQUIRE_EQUAL(0u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); + + // At the cap the commit records (bytes are stored verbatim; nothing + // decodes them until winner selection, which this single leg never arms). + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", + std::vector(2048, '\x01'))); + BOOST_REQUIRE_EQUAL(1u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); +} FC_LOG_AND_RETHROW() } + +// The candidate roster is capped at MAX_UWREQ_CANDIDATES (32): the 33rd +// distinct ACTIVE underwriter is refused with no row growth, while an +// existing candidate still updates its entry at the cap (dedupe, not append). +BOOST_FIXTURE_TEST_CASE(rcrdcommit_candidate_cap_bounds_row, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + constexpr uint64_t ATT_ID = 9500; + setup_eth_to_sol_uwreq(ATT_ID); + const uint64_t eth = fc::slug_name{"ETH"}.value; + + // Provision 33 ACTIVE underwriters (register + meet the 1-unit ETH/ETH + // minimum from bootstrap_for_dispatch's opreg config). UWRIT_OP stays out + // of this roster. + std::vector uws; + for (uint32_t i = 0; i < 33; ++i) { + std::string s = "uwcap"; + s += static_cast('a' + i / 26); + s += static_cast('a' + i % 26); + uws.emplace_back(s); + } + create_accounts(uws); + produce_blocks(); + // Blocks are produced along the way — 33 registrations + credits + 33 + // commits in one block would exhaust the per-block billable CPU. + for (uint32_t i = 0; i < uws.size(); ++i) { + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo() + ("account", uws[i].to_string()) + ("type", OperatorType::OPERATOR_TYPE_UNDERWRITER) + ("is_bootstrapped", false))); + BOOST_REQUIRE_EQUAL(success(), depositinle_credit(uws[i], "ETH", "ETH", 1'000)); + if (i % 8 == 7) produce_blocks(); + } + produce_blocks(); + + // 32 distinct candidates record; the 33rd is refused at the cap. + for (uint32_t i = 0; i < 32; ++i) { + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, uws[i], eth, "ETH", "ETH", "PRIMARY", + std::vector{1, 2, 3})); + if (i % 8 == 7) produce_blocks(); + } + BOOST_REQUIRE_EQUAL(32u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, uws[32], eth, "ETH", "ETH", "PRIMARY", + std::vector{1, 2, 3})); + { + const auto req = get_uwreq(ATT_ID); + BOOST_REQUIRE_EQUAL(32u, req["commits_by"].get_array().size()); + for (const auto& c : req["commits_by"].get_array()) { + BOOST_REQUIRE(c["underwriter"].as_string() != uws[32].to_string()); + } + } + + // An EXISTING candidate still updates its entry at the cap. + BOOST_REQUIRE_EQUAL(success(), + rcrdcommit_direct(ATT_ID, uws[0], eth, "ETH", "ETH", "PRIMARY", + std::vector{4, 5, 6})); + BOOST_REQUIRE_EQUAL(32u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.uwrit_tests.cpp b/contracts/tests/sysio.uwrit_tests.cpp index 4562144166..338dae9d6d 100644 --- a/contracts/tests/sysio.uwrit_tests.cpp +++ b/contracts/tests/sysio.uwrit_tests.cpp @@ -81,15 +81,19 @@ class sysio_uwrit_tester : public tester { } } - action_result setconfig(uint32_t fee_bps = 10, - uint64_t collateral_lock_duration_ms = 43'200'000, - uint64_t min_fromwire_amount = 5'000'000'000ull, - uint32_t fromwire_revert_fee_bps = 10) { + action_result setconfig(uint32_t fee_bps = 10, + uint64_t collateral_lock_duration_ms = 43'200'000, + uint64_t min_fromwire_amount = 5'000'000'000ull, + uint32_t fromwire_revert_fee_bps = 10, + uint32_t uwreq_pending_timeout_epochs = 10, + uint32_t uwreq_retention_epochs = 10) { return push_uwrit_action(UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", fee_bps) - ("collateral_lock_duration_ms", collateral_lock_duration_ms) - ("min_fromwire_amount", min_fromwire_amount) - ("fromwire_revert_fee_bps", fromwire_revert_fee_bps) + ("fee_bps", fee_bps) + ("collateral_lock_duration_ms", collateral_lock_duration_ms) + ("min_fromwire_amount", min_fromwire_amount) + ("fromwire_revert_fee_bps", fromwire_revert_fee_bps) + ("uwreq_pending_timeout_epochs", uwreq_pending_timeout_epochs) + ("uwreq_retention_epochs", uwreq_retention_epochs) ); } @@ -124,6 +128,8 @@ BOOST_FIXTURE_TEST_CASE(setconfig_basic, sysio_uwrit_tester) { try { BOOST_REQUIRE_EQUAL(43'200'000u, cfg["collateral_lock_duration_ms"].as_uint64()); BOOST_REQUIRE_EQUAL(5'000'000'000u, cfg["min_fromwire_amount"].as_uint64()); BOOST_REQUIRE_EQUAL(10, cfg["fromwire_revert_fee_bps"].as_uint64()); + BOOST_REQUIRE_EQUAL(10, cfg["uwreq_pending_timeout_epochs"].as_uint64()); + BOOST_REQUIRE_EQUAL(10, cfg["uwreq_retention_epochs"].as_uint64()); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(setconfig_writes_custom_lock_duration, sysio_uwrit_tester) { try { @@ -190,6 +196,57 @@ BOOST_FIXTURE_TEST_CASE(setconfig_accepts_max_revert_fee_and_custom_floor, sysio BOOST_REQUIRE_EQUAL(9999, cfg["fromwire_revert_fee_bps"].as_uint64()); } FC_LOG_AND_RETHROW() } +// ── SEC-129 / WSA-223: UWREQ lifecycle knobs ── +// +// A zero pending timeout would expire every uwreq the epoch it is created; a +// zero retention would erase terminal rows before the audit window they exist +// for; a near-UINT32_MAX value would wrap the `current_epoch + knob` deadline +// stamp to a tiny epoch index (instant expiry). setconfig rejects all three. + +BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_pending_timeout, sysio_uwrit_tester) { try { + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: uwreq_pending_timeout_epochs must be positive"), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/0) + ); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_retention, sysio_uwrit_tester) { try { + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: uwreq_retention_epochs must be positive"), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/10, /*retention*/0) + ); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(setconfig_rejects_lifecycle_epochs_over_ceiling, sysio_uwrit_tester) { try { + // The ceiling itself is accepted for both knobs; one epoch beyond it is + // rejected before the value could wrap a deadline stamp. + constexpr uint32_t ceiling = 1'000'000; + BOOST_REQUIRE_EQUAL(success(), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/ceiling, /*retention*/ceiling)); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: uwreq_pending_timeout_epochs exceeds the lifecycle ceiling"), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/ceiling + 1, /*retention*/ceiling) + ); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: uwreq_retention_epochs exceeds the lifecycle ceiling"), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/ceiling, /*retention*/ceiling + 1) + ); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(setconfig_roundtrips_lifecycle_epochs, sysio_uwrit_tester) { try { + BOOST_REQUIRE_EQUAL(success(), + setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, + /*revert_fee_bps*/10, /*pending_timeout*/3, /*retention*/7)); + auto cfg = get_uwconfig(); + BOOST_REQUIRE_EQUAL(3, cfg["uwreq_pending_timeout_epochs"].as_uint64()); + BOOST_REQUIRE_EQUAL(7, cfg["uwreq_retention_epochs"].as_uint64()); +} FC_LOG_AND_RETHROW() } + BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_lock_duration, sysio_uwrit_tester) { try { BOOST_REQUIRE_EQUAL( error("assertion failure with message: collateral_lock_duration_ms must be positive"), @@ -238,6 +295,31 @@ BOOST_FIXTURE_TEST_CASE(chklocks_noop_with_no_locks, sysio_uwrit_tester) { try { push_uwrit_action(UWRIT_ACCOUNT, "chklocks"_n, mvo())); } FC_LOG_AND_RETHROW() } +// ── pruneuwreqs — bounded UWREQ lifecycle sweep (SEC-129 / WSA-223) ── +// +// The enforcement trigger for `uw_request_t.expires_at_epoch`. Auth mirrors +// its sibling epoch-inline sweeps; the lifecycle behavior itself (PENDING +// timeout, terminal retention erase, per-epoch budget) needs real epoch +// movement and lives in sysio.dispatch_tests.cpp. + +BOOST_FIXTURE_TEST_CASE(pruneuwreqs_requires_epoch_or_self_auth, sysio_uwrit_tester) { try { + BOOST_REQUIRE(push_uwrit_action("uwrit.a"_n, "pruneuwreqs"_n, mvo() + ("max_rows", 10) + ).find("pruneuwreqs requires sysio.epoch or sysio.uwrit authority") != std::string::npos); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(pruneuwreqs_noop_with_empty_table, sysio_uwrit_tester) { try { + // Steady-state: nothing due, nothing to sweep — must be a clean no-op + // (it runs inside every epoch advance). + BOOST_REQUIRE_EQUAL(success(), + push_uwrit_action(UWRIT_ACCOUNT, "pruneuwreqs"_n, mvo()("max_rows", 10))); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(pruneuwreqs_zero_budget_is_noop, sysio_uwrit_tester) { try { + BOOST_REQUIRE_EQUAL(success(), + push_uwrit_action(UWRIT_ACCOUNT, "pruneuwreqs"_n, mvo()("max_rows", 0))); +} FC_LOG_AND_RETHROW() } + // ── drainfwq — epoch-boundary swap-from-WIRE queue drain ── BOOST_FIXTURE_TEST_CASE(drainfwq_requires_epoch_or_self_auth, sysio_uwrit_tester) { try {