Skip to content

feat: add per-transaction amount cap policy denominated in XLM - #388

Merged
davedumto merged 1 commit into
Vellar-Wallet:dripsfrom
noevidence1017:contrib/issue-14-per-transfer-cap
Aug 31, 2026
Merged

feat: add per-transaction amount cap policy denominated in XLM#388
davedumto merged 1 commit into
Vellar-Wallet:dripsfrom
noevidence1017:contrib/issue-14-per-transfer-cap

Conversation

@noevidence1017

@noevidence1017 noevidence1017 commented Aug 31, 2026

Copy link
Copy Markdown

Summary

Implements the A2 safety rule: reject any single classified transfer whose amount exceeds a cap configured at construction time in stroops.

New standalone crate at contrib/contracts/per-transfer-cap. It is not a member of any Cargo or pnpm workspace, so it cannot affect the existing build.

closes #14
closes #349
closes #351

Design

Denominated in stroops, never fiat. The cap follows the existing contracts/policy-templates/spending-limit precedent and is a native-unit quantity. There is intentionally no USD option: a Soroban contract has no trustless price feed, so a fiat rule could only be enforced by trusting an oracle, which would make the policy's guarantee only as strong as that oracle and would let a stale or manipulated price move the effective limit. As the issue directs, fiat denomination is left as a separate design discussion rather than an oracle bolted into this rule.

Per transfer, not a running total. This is deliberately a different rule from the cumulative rolling-window allowance, and the module docs, the README, and a dedicated test all make the distinction explicit:

spending-limit (existing) this policy (A2)
Question "how much in total per window?" "how large may ONE transfer be?"
State persists a running spent total none, every call is judged fresh
Time resets on a rolling window no time dimension at all
Rejects the transfer crossing the total the transfer that is individually too big

With a cap of 10 XLM this policy permits 10 XLM three times in a row, because each is individually within the cap. Since Signature::Policy carries no secret, a per-transfer cap used alone does not bound total loss, so the docs state that this rule complements the cumulative policy rather than replacing it.

Deny-by-default. Only a well-formed SEP-41 transfer with a positive i128 amount is eligible to pass the cap check. A non-transfer function, a missing or non-i128 amount, a zero or negative amount, a non-contract context, a call targeting the wallet's own admin surface, an empty context list, and a context list longer than MAX_CONTEXT_EVALUATION_LIMIT are all rejected. The over-long list is refused outright rather than truncated, so no entry can slip past the cap by sitting beyond the evaluated bound.

Arithmetic. The cap check is a pure comparison on values that are range-checked before use, so there is no accumulation and therefore no overflow path. A cap of i128::MAX against an amount of i128::MAX is exercised in the tests, and the release profile keeps overflow-checks = true as defence in depth, matching the audited workspace.

Configuration. Written once by the constructor and never mutated (no setter, since a cap the holder can raise in place guarantees nothing), and each instance is bound to a single wallet that both install and policy__ enforce.

Tests

25 tests, all passing via cargo test. The four cases the issue requires:

Case Test
Under the cap is allowed allows_transfer_under_cap
Exactly at the cap is allowed allows_transfer_exactly_at_cap
Over the cap is rejected rejects_transfer_over_cap
Unclassifiable is rejected, not allowed rejects_unclassifiable_interaction

Plus the per-transfer versus cumulative distinction (cap_applies_per_transfer_not_cumulatively, cap_is_not_affected_by_elapsed_time, allows_batch_whose_sum_exceeds_cap_but_each_transfer_does_not), i128::MAX boundary behaviour, the deny-by-default paths, and wallet-binding and install enforcement.

test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

prettier --check passes for the files CI checks. Generated test_snapshots/ are not committed: .prettierignore only excludes contracts/**/test_snapshots/, not contrib/, so committing them would fail format:check. This matches contrib/contracts/safety-policy, which also commits source only.

Note on the two other issues assigned to me

I looked into #349 and #351 and could not find the code either one describes on drips. Rather than invent a change, I am reporting what I found so a maintainer can confirm before anything is removed or deprecated.

#351 (deprecate legacy /v0 verification endpoints). There are no /v0 routes in the repository. services/verification-service/src/server.ts exposes only unversioned routes:

  • POST /verification/submit
  • GET /verification/:contractId
  • GET /verification/:contractId/status

A repository-wide search for /v0 returns no matches outside pnpm-lock.yaml. There is no endpoint to attach a deprecation header to, and no docs/decisions.md exists to record a timeline in.

#349 (remove dead legacy account handler in wallet-service). services/wallet-service/src/server.ts registers six routes, none of which is a legacy account handler, and the service contains no occurrence of "legacy" or "deprecated":

  • POST /wallet/create, POST /wallet/connect, POST /wallet/submit
  • GET /wallet/session/:id, GET /wallet/sessions, DELETE /wallet/session/:id

Both issues also target services/, which CONTRIBUTING.md and contrib/README.md place outside the contributor sandbox, so per rule 3 they need a maintainer to make the change or to explicitly widen scope. Happy to pick either up if you can point me at the intended code or widen the scope on the issue.

Unrelated pre-existing issue

While running the A1 crate to check my work against it, contrib/contracts/safety-policy does not compile under cargo test (22 errors: its test file uses Address::generate without importing the soroban_sdk::testutils::Address trait). This is pre-existing on drips from #386 and untouched by this PR, but flagging it since that crate's tests cannot currently run.

Implements the A2 safety rule: reject any single classified transfer whose
amount exceeds a cap configured at construction time in stroops.

The cap is denominated in native units only. A Soroban contract has no
trustless price feed, so a fiat-denominated rule could only be enforced by
trusting an oracle, which would make the guarantee only as strong as that
oracle. No USD option is provided.

This rule is deliberately distinct from the existing cumulative rolling-window
allowance in contracts/policy-templates/spending-limit. It holds no spend
state and has no time dimension: each transfer is judged individually, so
three at-cap transfers in a row all pass. Bounding total outflow remains the
cumulative policy's job, and the docs state that the two are complementary
rather than substitutes.

Enforcement is deny-by-default. Only a well-formed SEP-41 transfer with a
positive i128 amount is eligible to pass the cap check; a non-transfer call, a
missing or wrongly-typed amount, a non-contract context, a call targeting the
wallet's own admin surface, an empty context list, and a context list longer
than the evaluation bound are all rejected rather than allowed. Configuration
is immutable after construction and each instance is bound to one wallet.

The cap check is a pure comparison on range-checked values, so there is no
accumulation and no overflow path; a cap of i128::MAX against an amount of
i128::MAX is covered by a test, and the release profile keeps overflow-checks
enabled as defence in depth.

Tests cover the four required cases (under the cap, exactly at the cap, over
the cap, and an unclassifiable interaction rejected rather than allowed) plus
the per-transfer versus cumulative distinction, boundary amounts, and the
deny-by-default and wallet-binding paths. 25 tests pass.
@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

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

@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@noevidence1017 is attempting to deploy a commit to the david's projects Team on Vercel.

A member of the Team first needs to authorize it.

@davedumto
davedumto merged commit 2ffe211 into Vellar-Wallet:drips Aug 31, 2026
2 of 4 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.

2 participants