Skip to content

feat: add allowances for automatic paykit payments - #1340

Open
ovitrif wants to merge 91 commits into
masterfrom
feat/paykit-allowances
Open

ovitrif wants to merge 91 commits into
masterfrom
feat/paykit-allowances

Conversation

@ovitrif

@ovitrif ovitrif commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1352
Twin: synonymdev/bitkit-ios#799
Refs:

This PR adds allowances: a payer sets a per-payment and a monthly limit for a Paykit contact, and that contact's requests within the limits are paid without asking.

Description

It builds against Paykit 0.1.0, the version master pins. That release includes the allowance stack (pubky/paykit-rs#158 to #161) and the shared identity runtime, where one Encrypted Link serves a contact's identity and the allowance is bound to the two identities instead of to a receiver folder.

  • Adds an Allowances tab to Subscriptions, with an empty state, the list and a Set Allowance sheet with per-payment and monthly USD limits, so a payer can let a contact's requests pay themselves
  • Sends the allowance over the existing private Paykit link and opens a review sheet on the receiver, who accepts or declines; either side can end an allowance from its detail sheet
  • Asks the payer for the same PIN or biometrics as Send before setting, accepting or ending an allowance when a PIN is required for payments, and changes nothing when the check is cancelled; the payee's answers and automatic payments never ask, since the checked grant is what authorises them
  • Binds each allowance to the contact's identity, so one grant covers every Paykit app of that contact and there is one entry per grant, with no per-link proposals
  • Pays an incoming request within the limits automatically, over Lightning only: the request is reserved in the SDK's allowance accounting before the send and reported back afterwards, so a kill and relaunch mid-payment never pays twice
  • Claims the request's execution for Bitkit before accepting it automatically, and saves this install as the accepting install first, so an automatic payment that fails stays a payable manual request on this install under the shared-state ownership rules
  • Pays the endpoint the request is bound to and names the paying app in the payment proof, as the shared runtime requires
  • Pays only bitcoin-denominated requests automatically; a request priced in another asset or with a conversion stays an ordinary Payment Request, so USDT and USD-priced requests keep the manual flow
  • Keeps a request manual when the payee offers only an on-chain endpoint, because an on-chain fee would fall outside every allowance limit
  • Caps the routing fee of an automatic Lightning payment at 1% of the request, since routing fees also fall outside the allowance's accounting
  • Pays automatically only under terms with both a per-payment and a monthly limit, so a contact's proposal without limits this wallet can show is never presented or accepted
  • Holds background Paykit delivery and contact import back while automatic payments run, as a manual payment submission does
  • Binds accepting an allowance to the received proposal's stream item, as accepting a Payment Request is
  • Keeps the allowance id of an automatic payment's pending proof in the payment state backup, so a proof restored from backup is still submitted with it
  • Leaves a request above the per-payment limit, over the monthly cap, or after an end as an ordinary Payment Request, with a Limit Reached notification for the payer and a Payment Executed notification for an automatic one
  • Tags automatically paid rows "Auto-paid" in the Payments tab and shows the amount paid automatically on the allowance detail
  • Waits for the payee's next private payment list instead of asking the payer when the previous list was used up, and holds automatic payments until the node has a usable channel, because both cases fell back to manual on regtest
  • Fixes automatic payments so an admitted payment finishes even when the refresh that started it is cancelled, because a cancelled send left the request accepted, unpaid and off the request sheet
  • Lets an explicit E2E homegate URL win on every backend, a dev-only change used to run the branch against a local Pubky homegate

Out of Scope

  • Allowance detail on the payee: "Paid automatically" shows $0.00, because the payer's wallet keeps the payment journal; Bitkit can sum the received payment proofs that carry the allowance id instead
  • Automatic payments: pay over bolt11 only; there is no liquidity check before paying, so a payer without usable outbound capacity falls back to a manual request
  • PaykitAllowanceExecutor.kt recovery: a send stopped by a process kill stays at SENDING, so its request neither pays nor asks; follow-up
  • PaykitAllowanceExecutor.kt recovery: it settles every open attempt in the identity-wide ledger, which assumes Bitkit is the only app paying from it; follow-up before another app shares the ledger
  • LightningRepo.kt sync(): a cancellation is recorded as a sync error and starts the retry loop instead of being rethrown; follow-up
  • Automatic payment latency: a covered request was paid about 40 seconds after it was sent in one review run; the payer already receives private messages on every 10-second poll, so the delay needs a timed run to locate; tracked in bug: automatic allowance payment takes about 40 seconds after the request arrives #1614 (review)
  • Allowances for USDT: a USDT request or limit is out of this PR and comes as a separate follow-up
  • Failed automatic send: the request is already accepted, so it falls back to a manual Payment Request and is not retried automatically
  • PaykitAllowanceStore.kt backup: the allowance journal and automatic-payment history are not in the wallet backup, so a restore loses them; tracked in bug: allowance history and open attempts are lost after a wallet restore #1583 (thread)
  • PaykitAllowanceExecutor.kt journal trim: totals and Auto-paid tags are rebuilt from the last 200 journal entries; tracked in bug: older auto-paid totals and tags disappear after 200 allowance payments #1584 (thread)
  • PaykitAllowanceExecutor.kt on-chain auto-pay: an on-chain request stays manual until fees count against the allowance limits; tracked in feat: pay allowance requests on-chain once fees count against the limits #1592 (thread)
  • PaykitAllowanceExecutor.kt recovery: a manual on-chain attempt whose send was released stays unknown in the ledger, since a release leaves no durable record to read; tracked in bug: a released on-chain allowance payment keeps holding allowance capacity #1585 (thread)

Design

Figma "Bitkit - Experimental New": Allowances empty state, Set Allowance, Allowances list. The receiver's review sheet and the detail sheet have no frame and follow the drawn film. Deliberate differences: the first tab stays "Overview", the list row's right column reads the monthly limit, and the copy typos are fixed.

Preview

Allowances empty Set Allowance Review on the payee Detail
Recording
side-by-side.mp4

QA Notes

Journeys

  • new set-and-accept.xml — the offer opens on the payee by itself and both rows turn Active
  • new auto-pay-under-limit.xml — a $2 request pays itself with only a Payment Executed notification
  • new above-limit-asks.xml — a $20 request arrives as an ordinary Payment Request
  • new monthly-cap-reached.xml — the third $4 request on a $10 cap raises Limit Reached and asks
  • new end-stops-auto-pay.xml — after either side ends it, the next request asks
  • new restart-never-pays-twice.xml — a kill right after the hand-off never leads to a second payment
  • new pin-gates-allowance-changes.xml — with a PIN required for payments, saving and ending ask for it, a cancel changes nothing, and the payee's accept and automatic payments never ask

Automated Checks

  • added PaykitAllowanceTest.kt — limits, terms, capacity and month anchoring of the allowance model, and status and capacity on real time while the subscription clock offset is set
  • added PaykitAllowanceExecutorTest.kt — reserve, hand-off, outcome recording and restart recovery of automatic payments, the execution claim before the automatic acceptance, and admission on real time while the subscription clock offset is set; a request in another asset or with only an on-chain endpoint stays manual before any acceptance; an uncertain Lightning send stays with the node; an unsaved journal stage stops the attempt before any send and gives back the consumed payment list; an on-chain attempt recovers as paid only on the wallet's positive evidence
  • added PaykitAllowancePayerTest.kt — a manual on-chain allowance payment counts as paid only when the wallet's send attempt for that request was accepted or observed, or a verified proof of its transaction is held
  • added PaykitAllowanceRepoTest.kt — admission decisions: automatic, manual, deferred and limit reached, one grant per contact identity, and coverage on real time while the subscription clock offset is set
  • added PrivatePaykitAllowancePaymentTest.kt — resolving the payee's request-bound private endpoint for an automatic payment
  • added AllowancesViewModelTest.kt — list, set, review and detail state; the payer's save and end wait for the PIN check when payments require it, a cancelled check changes nothing, and the payee's answers and the payer's decline never ask
  • updated PaykitPaymentRequestRepoTest.kt — an automatic acceptance saves this install as the owner and drops it on a definite failure
  • updated AppViewModelSendFlowTest.kt — the send flow with the allowance repo wired in
  • updated PaykitPaymentProofRepoTest.kt — payment proofs carry the allowance id and the paying app
  • updated PaykitPaymentStateBackupTest.kt — a pending proof keeps its allowance id through backup and restore
  • ran set-and-accept.xml, auto-pay-under-limit.xml, above-limit-asks.xml and end-stops-auto-pay.xml on two Android 15 emulators with fresh wallets against the Blocktank staging regtest LSP and the staging homegate, on 0b1f2664a, before the sync with master — all four pass; a $2 request paid itself over Lightning, a $20 request asked, and after an end the next request asked. Staging was slow: the automatic payment arrived about 5 minutes after the request, and two payment request sends timed out once before succeeding on retry. The review sheet opens on its own only when no intro sheet is on top, and the payer row of an ended allowance reads "Ended" where end-stops-auto-pay.xml expects "Ended · $0.00 paid automatically"; the journey text is not updated
  • not run: monthly-cap-reached.xml and restart-never-pays-twice.xml; the cap and the restart rules are covered by PaykitAllowanceExecutorTest.kt; not run yet: pin-gates-allowance-changes.xml, whose gate logic is covered by AllowancesViewModelTest.kt but whose PIN pad over the sheet is not checked on a device

@ovitrif ovitrif self-assigned this Sep 24, 2026
Comment thread app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt Fixed

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: one HIGH and one MEDIUM on fund limits, plus one LOW. All three are inline with failing tests.

Allowances pay a contact without a per-payment confirmation, so the bar here is that no request can take more than what the user saw and approved. I reviewed this as a funds change.

Checked and clean:

  • Concurrent or retried requests: the SDK rechecks capacity atomically at reservation.
  • Clock manipulation: the trusted-time watermark only moves forward.
  • Ended allowances: an ended or revoked allowance can't pay; lifecycle is checked at accept, reserve and begin.
  • Lightning invoice amount: must equal the request, or be amountless.
  • Crash recovery: SENDING is durable before an automatic send, and unjournaled attempts stay UNKNOWN with their capacity still counted.
  • Wipe: state is keyed per identity and wiped.
  • Migration: v2.5.1 has no allowance state.
  • Gating: allowances ride the Paykit UI flag, on by default, so they're user-facing from the next release.

Already tracked, not repeated: #1583 (journal backup), #1584 (durable totals), #1585 (released on-chain attempts).

Not covered by any of the 7 new journeys: a contact-proposed allowance, and on-chain auto-pay fees.

Perf-relevant for the device gate later: every Paykit refresh now runs paykitAllowanceRepo.refresh() and beginPaymentSubmission() even with no allowances. Every manual request payment adds ensureReconciled, reserveManualPayment and a keychain journal write before sending.

Device gate: not run (HIGH/MEDIUM open).

Comment thread app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt
Comment thread app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt Outdated
Comment thread app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt Outdated

@ben-kaufman ben-kaufman left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see the inline comments.

Comment thread app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt Outdated
@ovitrif

ovitrif commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed deada17, then dadaeb9 (master merged): fixes from the reviews by @jvsena42 and @ben-kaufman on e1e5d4e.

  • Terms this wallet would pay under need both a per-payment and a monthly limit, or they are never presented, listed or accepted; the capacity check fails closed when a limit is missing.
  • An automatic on-chain payment whose fee exceeds the amount, or cannot be estimated, is not sent and stays manual. Counting fees against the limits is tracked in feat: pay allowance requests on-chain once fees count against the limits #1592.
  • A manual on-chain allowance attempt is journaled as sending when reported, so a kill after broadcast recovers it as unknown, never failed.
  • The allowance sheet cancels a pending PIN check when it is dismissed or replaced.
  • Automatic Lightning payments carry the request's submission deadline into the final send check.

Unit tests and detekt pass locally.

@ovitrif

ovitrif commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 6e36ce4: automatic allowance payments now go over Lightning only, following @jvsena42's fee findings here and on synonymdev/bitkit-ios#799.

  • A request whose payee offers only an on-chain endpoint stays manual before anything is accepted or consumed, so no on-chain fee can fall outside the limits. This replaces the fee stopgap from deada17; on-chain auto-pay returns under feat: pay allowance requests on-chain once fees count against the limits #1592 once fees count against the limits.
  • An uncertain Lightning send now stays with the node, whose payment event records the outcome, instead of putting the request back on the manual flow, matching iOS.

Unit tests and detekt pass locally.

@ovitrif

ovitrif commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 9f7ca64: the payee's allowance row now shows the per-payment limit in the payer's short form (Active · $5 a payment, cents only below a dollar), so both rows read alike. Covered in AllowancesViewModelTest.kt; unit tests and detekt pass locally.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New finding after the fixes in deada17, 6e36ce4 and 9f7ca64.

The HIGH (uncapped proposal) and the LOW (SUBMITTED→FAILED) are fixed: from() drops payer terms without both ceilings and fits fails closed, and manual on-chain attempts journal at SENDING. On-chain auto-pay is gone, so the on-chain half of the MEDIUM is fixed too.

One MEDIUM remains, inline: Lightning routing fees on automatic payments are uncapped and uncounted.

Residual LOW, display only, not blocking: isBounded checks the raw strings, so a max like 0.000000001 that parses to null still shows "—" and can be accepted. fits never pays on it.

Device gate: not run (MEDIUM open).

Comment thread app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt Outdated
@ovitrif

ovitrif commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed f20cb57: automatic allowance Lightning sends cap their total routing fee at 1% of the requested amount, so a payee's route hint cannot take fees beyond the allowance. Manual sends are unchanged. Answers @jvsena42's routing-fee finding; unit tests and detekt pass locally.

@ovitrif
ovitrif requested a review from jvsena42 October 19, 2026 18:53
@ovitrif

ovitrif commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

@jvsena42 your re-review was on 9f7ca64, before the routing-fee fix: f20cb57 caps the total routing fee of automatic sends at 1% of the request (thread, resolved), matching synonymdev/bitkit-ios#799. Pushed a8d73c1 for the residual LOW: terms this wallet would pay under now need a per-payment and monthly limit that actually parse to sats (and a monthly anchor), so a dust max that reads as "—" is dropped and can no longer be accepted; covered in PaykitAllowanceRepoTest.kt. Unit tests and detekt pass locally.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code scan at a8d73c1: no HIGH/MEDIUM open. The routing-fee MEDIUM is fixed (thread answered). The new parse gate in PaykitAllowance.kt:105-111 only drops records, so it fails safe.

Device gate (partial): not run yet. Pending: the 7 journeys/allowances/ journeys cross-device with synonymdev/bitkit-ios#799, plus the perf check I noted earlier (allowance refresh on every Paykit refresh, and the extra writes before each manual request payment, measured against master). Approval follows the device run.

# Conflicts:
#	app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
#	app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
#	app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@ovitrif

ovitrif commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 5b0bfe8.

detekt, Android Lint, the Kotlin compiles with warnings as errors and the unit tests pass locally.

@ovitrif

ovitrif commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed f561e63: allowance accounting is now kept per contact, so another contact's payments under the same allowance id never fill this contact's cap (a false Limit Reached) or show in its paid-so-far total. The executor picks the allowance whose contact matches the request, capacity attempts carry the contact from the occurrence and usedSats filters on it, and auto-paid totals are keyed by contact and id. Answers @jvsena42's finding from synonymdev/bitkit-ios#799 (synonymdev/bitkit-ios#799 (comment)), reproduced here; his two tests are in PaykitAllowanceExecutorTest.kt and PaykitAllowanceRepoTest.kt. detekt, Android Lint, the Kotlin compiles and unit tests pass locally.

# Conflicts:
#	app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta at e7b4477: no issues found. Reviewed as a funds change. The merges da0719c, a04b91a and e7b4477 resolve conflicts cleanly. finishManualAllowancePayment is kept after the on-chain proof call, and master's direct-confirm path still goes through beginManualAllowancePayment.

Two issues found on the iOS twin (synonymdev/bitkit-ios#799) were also present here, and both are fixed:

  • Different contacts sharing an allowance id were merged into one entry, so Accept answered every member and the PIN gate followed the primary's role. Fixed in e7c076a:
    • PaykitAllowance.key is normalized(counterparty)/allowanceId;
    • group(containing, counterparty) matches the contact;
    • publish groups by group id, else by key, so respond and the PIN gate act on one contact's records;
    • presented ids use the key.
  • Automatic-payment accounting was keyed by the bare id. Fixed in f561e63: the executor lookup matches the contact, Attempt carries it and usedSats filters on it, and autoPaidSats is grouped by key.

The reproduction tests from those reviews are in PaykitAllowanceRepoTest / PaykitAllowanceExecutorTest unchanged. v2.5.1 has no allowance store, so the key change needs no migration. My earlier threads are all resolved, with fixes at head.

Device gate (partial): e7b4477 — not run yet. Pending: the 7 journeys/allowances/ journeys, cross-device with the iOS twin in both directions. Device results will follow in a separate review.

This branch has not been deployed

No deployments
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.

feat: add allowances for automatic paykit payments

5 participants