Skip to content

feat(contract): add rate limiting, dead man's switch, and packed state (#714) - #822

Merged
Abdulazeem-code merged 2 commits into
Abdulazeem-code:mainfrom
billcode124:feature/contract-struct-packing
Oct 5, 2026
Merged

Abdulazeem-code merged 2 commits into
Abdulazeem-code:mainfrom
billcode124:feature/contract-struct-packing

Conversation

@billcode124

Copy link
Copy Markdown
Contributor

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.

Note on scope: the struct packing in #714 could not land on its own. The
largest win (the RateLimitCounter repack) only exists because the rate
limiter stores that record, so this necessarily bundles the rate limiter and
the dead man's switch with the packing work. The Horizon polling backend work
that was sitting on this branch has been removed and is not part of this PR.

Also worth reviewing on its own: the route_payment auth fix below is a
security fix and may deserve a faster path than this PR.

Security fix

route_payment had lost its sender.require_auth(), meaning anyone could route
funds out of any address. Validation now runs before auth — preserving the
rollback-panic fix from b849e9a, since a require_auth abort rolls back the
whole 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:

Record Before After
DataKey::RateLimitCounter BytesN<24> BytesN<8> (two u32s)
pausable_token Allowance contracttype struct BytesN<20>
  • RateLimitCounter: 24 → 8 bytes. It reused the BytesN<24> UserSpending
    layout from Optimize contract storage by compressing user spending history #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. A 67% reduction on one persistent entry per
    active sender.
  • Allowance → 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 remains a #[contracttype] because
    allowance() returns it to callers and it appears in the generated bindings.

Payment and UserSpending are deliberately untouched: neither is persisted
(so packing them would only shrink calldata, not the ledger), and
UserSpending was 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 u32 and i128 ranges including
i128::MIN, field independence (a max-width field must not corrupt its
neighbour), 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_limit is enforced on both route_payment and route_payments — one
invocation 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 Err has its entire
invocation 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. A
backup 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_token did not compile at all. It lacked the [patch.crates-io]
    ethnum override that 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. Now calls the binary directly. The hook
    was also checked in with CRLF endings, which made if ...; then\r a syntax
    error; .gitattributes only covered *.sh, never the extensionless hooks.
  • eslint.config.mjs: the root lint run aborted with ERR_MODULE_NOT_FOUND
    because ESLint discovered payment-dashboard/eslint.config.js and could not
    resolve that config's plugins. Excludes subprojects that are linted separately.
  • Regenerated packages/types for the new ABI.

Verification

  • payment_router: 74 passed / 0 failed
  • pausable_token: 24 passed / 0 failed
  • cargo clippy --all-targets --all-features -- -D warnings clean on both
  • cargo build --target wasm32-unknown-unknown --release succeeds

Closes #714

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
@vercel

vercel Bot commented Sep 26, 2026

Copy link
Copy Markdown

@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.

@drips-wave

drips-wave Bot commented Sep 26, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@Abdulazeem-code

Copy link
Copy Markdown
Owner

Resolve conflict And fix failed checks

@Abdulazeem-code

Copy link
Copy Markdown
Owner

Fix failed check amd resolve conflicts

@Abdulazeem-code

Copy link
Copy Markdown
Owner

Kindly Resolve conflicts

@Abdulazeem-code

Copy link
Copy Markdown
Owner

Kindly resolve conflicts

@Abdulazeem-code
Abdulazeem-code merged commit 6352118 into Abdulazeem-code:main Oct 5, 2026
9 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Smart Contracts: Optimize Struct Packing to Reduce Ledger Footprint

2 participants