Skip to content

feat(subscription): optional cancellation reason and single-call charge settlement - #161

Open
sommydregent wants to merge 1 commit into
MMM-pay:mainfrom
sommydregent:issue-73-74-cancel-reason-and-split-debit
Open

sommydregent wants to merge 1 commit into
MMM-pay:mainfrom
sommydregent:issue-73-74-cancel-reason-and-split-debit

Conversation

@sommydregent

Copy link
Copy Markdown

Summary

Two related changes: how a mandate ends (#73) and how a charge settles (#74), both built on current main.

Replaces #160. That PR was opened against an older main, went 70 commits behind and turned unmergeable; its branch has been rebased and rewritten here with the same behaviour adapted to today's contracts (keeper tips, cancel_page, pinned event snapshots, auth-test gate). The old branch is deleted so only this PR tracks the work.

#73 — optional cancellation reason

cancel gave a merchant no signal about why a subscriber left. The new cancel_with_reason(subscriber, mandate_id, reason) performs exactly the same cancellation as cancel — same auth, same state change, same events — and adds a reason code to Cancelled, so churn is visible from chain data alone.

Code Constant Meaning
0 CANCEL_REASON_NONE No reason given (what cancel / cancel_page emit)
1 CANCEL_REASON_TOO_EXPENSIVE Too expensive
2 CANCEL_REASON_NOT_USING No longer using the service
3 CANCEL_REASON_MISSING_FEATURE Missing a needed feature
4 CANCEL_REASON_SERVICE_QUALITY Unhappy with the service
5 CANCEL_REASON_OTHER Something else

The code set is closed: reason > MAX_CANCEL_REASON returns the new InvalidCancelReason before any storage is touched, so a rejected call cannot alter a mandate and an indexer never has to handle an unknown code. The codes are exported as public constants. cancel is now that function called with CANCEL_REASON_NONE, cancel_page reports 0 per mandate it clears, and end_mandate is untouched (a merchant ending a mandate is not a subscriber cancellation).

#74 — one vault call per charge

charge called vault.debit once per payee (merchant share, then fee, then the optional keeper tip), repeating the vault's subscription auth check, Subscription read, balance read and balance write for the same total.

vault.debit_split(user, token, to_a, amount_a, to_b, amount_b) does that work once and then makes the same two token transfers. It emits the same one Debit per non-empty leg with the same running balances the sequential debits reported (first event: balance after amount_a; second: after both legs), so indexers see byte-identical data. amount_a must be positive, amount_b may be zero — a zero-fee settlement then has a single leg and emits a single Debit, exactly like the old single-call path — and the total is added with checked_add, because a wrap would under-charge the subscriber (new AmountOverflow).

charge and upgrade (which collects a prorated difference the same way) both settle merchant share + fee through one debit_split. The keeper tip is a third payee and keeps its own debit, so a tipped charge makes two vault calls instead of three.

Acceptance criteria

#73

  • Codes documented — doc-comment table on cancel_with_reason, the CANCEL_REASON_* constants, the Cancelled.reason field doc, and CHANGELOG.md.
  • Plain cancel emits 0 — plain_cancel_emits_reason_zero reads reason off the emitted event XDR (and cancel_page_reports_no_reason covers the bulk path).

#74

  • Measured resource reduction reported — below.
  • Same balances and events as before — charge_settles_merchant_and_fee_through_one_vault_call (two Debit events + merchant/fee/vault balances), a_zero_fee_charge_emits_one_debit (one Debit), a_tipped_charge_still_pays_three_payees (three Debits, tip still paid), plus the vault's own debit_split_pays_both_recipients_from_one_balance_update, which asserts both event balances. All pre-existing fee/tip/refund/upgrade tests are unchanged and green.

Measured resource reduction (#74)

Measured with the test host's cost estimator (Env::cost_estimate().budget()), mainnet resource limits enforced, identical world and state in both runs (real Stellar Asset Contract, funded subscriber, 1% fee, no keeper tip, PRICE = 10_000), settling the second charge of an active mandate. The only difference between the two builds is charge's payout block: two vault.debit calls versus one vault.debit_split.

charge payout path Instructions Memory (bytes)
before: 2 × vault.debit 778 914 263 926
after: 1 × vault.debit_split 723 604 253 409
saved per charge 55 310 (-7.10%) 10 517 (-3.98%)

Counts are deterministic on the test host. What is saved is one whole cross-contract invocation frame plus the duplicated auth check, Subscription read and balance read/write; the two token transfers (one per payee) remain, so this is the floor for a two-recipient settlement. A tipped charge saves one frame out of three instead of one out of two.

Tests

cargo test --all: 247 passed, 0 failed — 18 new:

  • subscription (11): reason read back off the event for cancel (0) and cancel_with_reason; every documented code accepted; MAX_CANCEL_REASON + 1 and u32::MAX rejected with InvalidCancelReason and the mandate left Active; stranger and merchant rejected with NotSubscriber; a wrong signer's auth rejected; double cancel rejected; cancel_page reports 0; charge emits two Debits; zero-fee charge emits one; tipped charge emits three and still pays all three payees.
  • vault (7): both legs paid from one balance update with sequential-looking event balances; zero second leg emits one Debit; combined-total check (neither leg alone exceeds the balance, the sum does, nothing moves); zero/negative legs; i128 overflow; and two negative auth tests — a specific wrong signer via MockAuth/MockAuthInvoke, and no auth at all.

Both new mutating entry points carry a negative auth test in the naming scripts/check_auth_tests.sh expects (cancel_with_reason_wrong_signer_rejected, debit_split_requires_the_subscription_auth).

The subscription crate pins event layouts: test_events.rs gains reason for cancelled and test_snapshots/events/cancelled.xdr is regenerated, so the XDR change is explicit and reviewable rather than silent.

Interface and event changes (downstream)

  • Breaking event change: Cancelled gains a trailing reason: u32. Follow-up issues are needed in payflow-backend (event decoder), payflow-frontend (optional reason picker on cancel) and payflow-docs (events reference + error-code page). Noted under Unreleased in CHANGELOG.md.
  • New error codes appended so existing ones keep their numbers: vault AmountOverflow = 11, subscription InvalidCancelReason = 27.
  • New public functions: Subscription::cancel_with_reason, Vault::debit_split (also added to contracts/subscription/src/interfaces.rs).
  • bindings/ (generated TypeScript clients) is derived from the contract specs; the Cancelled type there needs a regeneration follow-up.

Checklist (all run locally on this commit)

  • cargo fmt --all -- --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --all — 247 passed
  • ./scripts/check_auth_tests.sh — PASS
  • MSRV job: RUSTUP_TOOLCHAIN=1.91.0 cargo check --all --all-targets
  • stellar contract build with stellar-cli 27.0.0 — all three contracts build, and cancel_with_reason / debit_split appear in the built specs (payflow_subscription.wasm 52 489 B, payflow_vault.wasm 15 368 B, payflow_plan_registry.wasm 22 082 B)
  • wasm reproducibility: two clean builds produce identical SHA-256 (0a56decb… registry, 55c1c352… subscription, 54026786… vault)

Closes #73
Closes #74

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Malware and injection scan

0 blocking, 1 to review, 0 scanner error(s), 11 changed file(s).

Needs maintainer review

Rule Location Finding Excerpt
long-base64 contracts/subscription/test_snapshots/events/cancelled.xdr:2 Long base64 blob; encoded payloads are a common way to hide code. spec: 000000050000002c456d6974746564207768656e206120737562736372696265722063616e63656c732061206d616e646174652e000000000000000943616e63656c6c65640000000000000...

Scanned commit 0b2b05a. Full run and report

This pull request will not be merged automatically. A maintainer will review the findings; if they are false positives, they can merge it by hand. Pushing a new commit runs the scan again.

@github-actions github-actions Bot added the security-review Flagged by the malware scan; needs a maintainer label Oct 26, 2026
…ement

Subscribers could only cancel anonymously, so a merchant had no way to learn
why a mandate ended from chain data. `cancel_with_reason` performs exactly the
same cancellation as `cancel` and adds the reason to the `Cancelled` event; the
codes are a closed, documented set (`CANCEL_REASON_*`) and an out-of-range code
is rejected with `InvalidCancelReason` before storage is touched. `cancel` and
`cancel_page` report reason 0, so the event shape is constant.

`charge` paid the merchant and the protocol fee with two `debit` calls,
repeating the vault's authorization, `Subscription` read and balance
read/write for the same total. `vault.debit_split` does that work once and
then makes the same two token transfers, so balances and the per-payee `Debit`
events are unchanged, including the running balances; a zero fee leaves the
second leg empty. `charge` and `upgrade` use it, and a tipped charge makes two
vault calls instead of three.

The split total is added with `checked_add` so it can never wrap, reported as
the new `AmountOverflow`. Both new error variants are appended to their enums
so existing codes keep their numbers.

Closes MMM-pay#73
Closes MMM-pay#74
@sommydregent
sommydregent force-pushed the issue-73-74-cancel-reason-and-split-debit branch from 825b0be to 0b2b05a Compare October 26, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security-review Flagged by the malware scan; needs a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(subscription): pay merchant and fee in one vault call feat(subscription): optional cancellation reason

1 participant