Repository navigation
feat(contract): add rate limiting, dead man's switch, and packed state (#714) - #822
Merged
Abdulazeem-code merged 2 commits intoOct 5, 2026
Conversation
Abdulazeem-code#714) Brings the contract up to date with the Abdulazeem-code#716 rate limiter and the dead man's switch, repairs the auth regression in route_payment, and implements the struct packing requested in Abdulazeem-code#714. Security fix: route_payment had lost its sender.require_auth(), so anyone could route funds out of any address. Validation now runs before auth (preserving the rollback-panic fix from b849e9a) and auth is enforced again. Rate limiting (Abdulazeem-code#716): check_rate_limit is enforced on both route_payment and route_payments, one invocation slot per payment, charged after auth so only authorized senders are counted. A whitelisted sender bypasses it. The window is keyed on the ledger sequence, so it rolls over when the next ledger seals with no time bookkeeping. Note the cap can only bound successful payments: a function that returns Err has its whole invocation rolled back by the host, counter included. Dead man's switch: set_backup_admin_internal / remove_backup_admin / claim_admin / ping. A backup admin can claim admin rights once the primary has been silent for the configured timeout. Deliberately neither timelocked nor blocked by a freeze: if the primary admin is gone there is nobody left to run the queue, and a freeze must not be able to brick recovery. Routing a payment also counts as a heartbeat. Abdulazeem-code#714 struct packing, on the two records that actually reach the ledger: - DataKey::RateLimitCounter: 24 -> 8 bytes. It reuses the BytesN<24> UserSpending layout from Abdulazeem-code#519 (u64 window + i128 count) even though neither field needs that width: the window marker is a ledger sequence, which Env::ledger().sequence() already returns as u32, and the counter is bounded by a u32 RateLimitConfig. Now two u32s. - pausable_token Allowance: contracttype struct -> BytesN<20> (i128 amount + u32 expiry), dropping the XDR struct discriminant and per-field map framing from one persistent entry per (owner, spender) pair. Allowance stays a contracttype because allowance() returns it to callers. Payment and UserSpending are left alone: neither is persisted, and UserSpending was already packed by Abdulazeem-code#519. Packing is only safe if lossless, so the round trips are pinned by tests: exact byte width, the full u32 and i128 ranges including i128::MIN, field independence, and big-endian layout, plus a test asserting the value actually on the ledger is the packed form. Also: - pausable_token did not compile: it lacked the [patch.crates-io] ethnum override payment_router already carries, so stock ethnum 1.5.0 failed with E0512 on a mem::transmute in error.rs. The crate is in neither CI nor the Dockerfile, which is why this went unnoticed. - .husky/pre-commit called npx lint-staged, which intermittently fails with "No matching version found for undefined@lint-staged" when it consults the registry instead of the local install. Call the binary directly. The hook was also checked in with CRLF endings, making `if ...; then\r` a syntax error; .gitattributes only covered *.sh, never the extensionless hooks. - eslint.config.mjs: the root run aborted with ERR_MODULE_NOT_FOUND because ESLint discovered payment-dashboard/eslint.config.js and could not resolve that config's plugins. Exclude subprojects that are linted separately. Bindings regenerated for the new ABI. Verified: payment_router 74 passed / 0 failed, pausable_token 24 passed / 0 failed, clippy --all-targets --all-features -- -D warnings clean on both. Closes Abdulazeem-code#714
|
@billcode124 is attempting to deploy a commit to the Abdulazeem's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@billcode124 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Owner
|
Resolve conflict And fix failed checks |
Owner
|
Fix failed check amd resolve conflicts |
Owner
|
Kindly Resolve conflicts |
Owner
|
Kindly resolve conflicts |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the struct packing requested in #714, and brings the contract up to
date with the #716 rate limiter and the dead man's switch.
Security fix
route_paymenthad lost itssender.require_auth(), meaning anyone could routefunds out of any address. Validation now runs before auth — preserving the
rollback-panic fix from
b849e9a, since arequire_authabort rolls back thewhole invocation and would turn ordinary validation errors into an opaque host
panic — and auth is enforced again.
#714 struct packing
Applied to the two records that actually reach the ledger:
DataKey::RateLimitCounterBytesN<24>BytesN<8>(twou32s)pausable_tokenAllowancecontracttypestructBytesN<20>RateLimitCounter: 24 → 8 bytes. It reused theBytesN<24>UserSpendinglayout from Optimize contract storage by compressing user spending history #519 (
u64window +i128count) even though neither field needsthat width. The window marker is a ledger sequence, which
Env::ledger().sequence()already returns asu32, and the counter is boundedby a
u32RateLimitConfig. A 67% reduction on one persistent entry peractive sender.
Allowance→BytesN<20>(i128amount +u32expiry), dropping the XDRstruct discriminant and per-field map framing from one persistent entry per
(owner, spender)pair.Allowanceremains a#[contracttype]becauseallowance()returns it to callers and it appears in the generated bindings.PaymentandUserSpendingare deliberately untouched: neither is persisted(so packing them would only shrink calldata, not the ledger), and
UserSpendingwas already packed by #519.Serialization robustness
Packing is only safe if lossless, so the round trips are pinned by tests rather
than trusted: exact byte width, the full
u32andi128ranges includingi128::MIN, field independence (a max-width field must not corrupt itsneighbour), big-endian layout asserted on raw bytes, and a test reading the
value back off the ledger to confirm the write path stores the packed form.
Rate limiting (#716)
check_rate_limitis enforced on bothroute_paymentandroute_payments— oneinvocation slot per payment, charged after auth so only authorized senders are
counted, with whitelisted senders bypassing it. The window is keyed on the ledger
sequence, so it rolls over when the next ledger seals with no time bookkeeping.
Worth being explicit about the limit of this control: it can only bound
successful payments. A contract function returning
Errhas its entireinvocation rolled back by the host, counter included, so a rejected attempt costs
the spammer the transaction fee but leaves no state behind. It must not be relied
on to throttle failing traffic.
Dead man's switch
set_backup_admin_internal/remove_backup_admin/claim_admin/ping. Abackup admin can claim admin rights once the primary has been silent for the
configured timeout. Deliberately neither timelocked nor blocked by a contract
freeze: if the primary admin is gone there is nobody left to execute the queue,
and a freeze must not be able to brick recovery. Routing a payment also counts as
a heartbeat.
Incidental fixes
pausable_tokendid not compile at all. It lacked the[patch.crates-io]ethnum override that
payment_routeralready carries, so stockethnum 1.5.0failed with
E0512on amem::transmuteinerror.rs. The crate is inneither CI nor the Dockerfile, which is why this went unnoticed.
.husky/pre-commitcallednpx lint-staged, which intermittently failswith
No matching version found for undefined@lint-stagedwhen it consults theregistry instead of the local install. Now calls the binary directly. The hook
was also checked in with CRLF endings, which made
if ...; then\ra syntaxerror;
.gitattributesonly covered*.sh, never the extensionless hooks.eslint.config.mjs: the root lint run aborted withERR_MODULE_NOT_FOUNDbecause ESLint discovered
payment-dashboard/eslint.config.jsand could notresolve that config's plugins. Excludes subprojects that are linted separately.
packages/typesfor the new ABI.Verification
payment_router: 74 passed / 0 failedpausable_token: 24 passed / 0 failedcargo clippy --all-targets --all-features -- -D warningsclean on bothcargo build --target wasm32-unknown-unknown --releasesucceedsCloses #714