Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,8 @@ struct AppScene: View {
guard let identity = pubkyProfile.publicKey else { return }
await paykitPaymentRequestManager.refresh()
await paykitAllowanceManager.refresh()
let acceptedOffers = await paykitAllowanceManager.acceptOffersFromAllowers()
announceAcceptedAllowanceOffers(acceptedOffers)
let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests)
let adoptedAcceptances = paykitPaymentRequestManager.reloadAcceptedRequestIds()
if handledAutomatically || adoptedAcceptances {
Expand Down Expand Up @@ -1527,6 +1529,16 @@ struct AppScene: View {
}
}

private func announceAcceptedAllowanceOffers(_ offers: [PaykitAllowanceEntry]) {
for offer in offers {
let title = t("subscriptions__allowance_offer_accepted_title")
let description = t("subscriptions__allowance_offer_accepted_description", variables: ["name": contactName(offer.counterparty)])
PaykitAllowanceNotifier.post(title: title, body: description, fallback: {
app.toast(type: .success, title: title, description: description, accessibilityIdentifier: "AllowanceOfferAcceptedToast")
})
}
}

private func contactName(_ publicKey: String) -> String {
contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }?.displayName
?? PubkyPublicKeyFormat.displayTruncated(publicKey)
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1801,4 +1801,6 @@
"subscriptions__allowance_executed_description" = "Auto-pay sent {amount} to {name}";
"subscriptions__allowance_limit_title" = "Limit Reached";
"subscriptions__allowance_limit_description" = "A {amount} request from {name} is above your allowance. Review it to pay.";
"subscriptions__allowance_offer_accepted_title" = "Allowance Added";
"subscriptions__allowance_offer_accepted_description" = "{name} set up an allowance for you";
"subscriptions__allowance_auto_paid" = "Auto-paid";
5 changes: 5 additions & 0 deletions Bitkit/Services/PaykitAllowance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ struct PaykitAllowance: Identifiable, Hashable {
lifecycleState == .proposed && !isProposedByMe
}

/// A received proposal whose proposer took the allower role, which leaves this wallet the allowee. The money and the
/// decision are the allower's, so Bitkit accepts it without asking. A received proposal that makes this wallet the
/// allower (a payee asking for an allowance) is not an offer and waits for the user's decision.
var isOfferFromAllower: Bool { isAnswerable && role == .allowee }

static func isMonthly(_ period: Paykit.AllowancePeriod) -> Bool {
period.kind() == "anchored" && period.every() == 1 && period.unit() == "month"
}
Expand Down
27 changes: 26 additions & 1 deletion Bitkit/Services/PaykitAllowanceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ final class PaykitAllowanceManager {
@ObservationIgnored private let canPayNow: @MainActor () -> Bool
@ObservationIgnored private var identity: String?
@ObservationIgnored private var isProcessingRequests = false
@ObservationIgnored private var isAcceptingOffers = false
@ObservationIgnored private var manualRequestIds: [PaykitPaymentRequest.ID: Int] = [:]

init(
Expand Down Expand Up @@ -220,6 +221,30 @@ final class PaykitAllowanceManager {
await refresh()
}

/// Accepts the offers that make this wallet the allowee, and returns the ones now active so the caller can tell the
/// user. A failed accept stays unanswered and is tried again on the next refresh.
func acceptOffersFromAllowers() async -> [PaykitAllowanceEntry] {
guard identity != nil, !isAcceptingOffers else { return [] }
let offers = entries.filter { entry in
let answerable = entry.allowances.filter(\.isAnswerable)
return !answerable.isEmpty && answerable.allSatisfy(\.isOfferFromAllower)
}
guard !offers.isEmpty else { return [] }

isAcceptingOffers = true
defer { isAcceptingOffers = false }
var accepted: [PaykitAllowanceEntry] = []
for offer in offers {
do {
try await accept(offer)
accepted.append(offer)
} catch {
Logger.warn("Failed to accept an allowance offer: \(error)", context: "PaykitAllowance")
}
}
return accepted
}

private func respond(to entry: PaykitAllowanceEntry, _ response: (PaykitAllowance) async throws -> Paykit.AllowanceRecord) async throws {
isWorking = true
defer { isWorking = false }
Expand All @@ -241,7 +266,7 @@ final class PaykitAllowanceManager {

func proposalForPresentation() -> PaykitAllowanceEntry? {
entries.first { entry in
entry.allowances.contains(where: \.isAnswerable) &&
entry.allowances.contains(where: { $0.isAnswerable && !$0.isOfferFromAllower }) &&
!entry.allowances.contains { localState.presentedProposalIds.contains($0.allowanceId) }
}
}
Expand Down
74 changes: 73 additions & 1 deletion BitkitTests/PaykitAllowanceExecutorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,73 @@ final class PaykitAllowanceExecutorTests: XCTestCase {
XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:45:00Z")))
}

// MARK: Incoming offers

@MainActor
func testManagerAcceptsAnOfferFromTheAllowerWithoutAReviewSheet() async throws {
let harness = AllowanceHarness()
// The proposer took the allower role, so this wallet is the allowee.
try await harness.sdk.setRecords([
Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false),
])
let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
await manager.activate(identity: Fixtures.identityKey)
XCTAssertNil(manager.proposalForPresentation(), "An offer from the allower never opens the review sheet")

let accepted = await manager.acceptOffersFromAllowers()

XCTAssertEqual(accepted.map(\.counterparty), [Fixtures.counterpartyKey])
let acceptedIds = await harness.sdk.acceptedAllowanceIds
XCTAssertEqual(acceptedIds, [Fixtures.walletAllowanceId])
XCTAssertEqual(manager.entries.first?.status(at: Fixtures.now), .active)
XCTAssertEqual(manager.entries.first?.role, .allowee)
XCTAssertEqual(manager.entries.first?.canEnd, true, "The allowee can still end it")
XCTAssertNil(manager.proposalForPresentation())

let acceptedAgain = await manager.acceptOffersFromAllowers()
XCTAssertTrue(acceptedAgain.isEmpty, "An accepted offer is not accepted or announced twice")
let acceptedIdsAfter = await harness.sdk.acceptedAllowanceIds
XCTAssertEqual(acceptedIdsAfter, [Fixtures.walletAllowanceId])
}

@MainActor
func testManagerLeavesAnAskFromTheAlloweeForTheReviewSheet() async throws {
let harness = AllowanceHarness()
// The proposer took the allowee role, so this wallet is the allower and pays: the user decides.
try await harness.sdk.setRecords([
Fixtures.record(localRole: .allower, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false),
])
let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
await manager.activate(identity: Fixtures.identityKey)

let accepted = await manager.acceptOffersFromAllowers()

XCTAssertTrue(accepted.isEmpty)
let acceptedIds = await harness.sdk.acceptedAllowanceIds
XCTAssertTrue(acceptedIds.isEmpty)
XCTAssertFalse(harness.log.entries.contains("acceptAllowance"))
XCTAssertEqual(manager.proposalForPresentation()?.counterparty, Fixtures.counterpartyKey)
XCTAssertEqual(manager.entries.first?.status(at: Fixtures.now), .awaitingMyAnswer)
}

@MainActor
func testManagerNeverAcceptsAProposalItSentOrOneAlreadyAnswered() async throws {
let harness = AllowanceHarness()
let terms = try Fixtures.standardTerms()
try await harness.sdk.setRecords([
Fixtures.record(allowanceId: "sent", localRole: .allower, state: .proposed, terms: terms, proposedByMe: true),
Fixtures.record(allowanceId: "declined", localRole: .allowee, state: .rejected, terms: terms, proposedByMe: false),
Fixtures.record(allowanceId: "ended", localRole: .allowee, state: .ended, terms: terms, proposedByMe: false),
])
let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
await manager.activate(identity: Fixtures.identityKey)

let accepted = await manager.acceptOffersFromAllowers()

XCTAssertTrue(accepted.isEmpty)
XCTAssertFalse(harness.log.entries.contains("acceptAllowance"))
}

// MARK: Helpers

private static func waitUntil(timeout: TimeInterval = 5, _ condition: () -> Bool) async {
Expand Down Expand Up @@ -1061,6 +1128,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling {
private var manualReservation: Paykit.PaymentAttemptDecision?
private var beginDecision: Paykit.PaymentAttemptDecision?
private var acceptError: Error?
private(set) var acceptedAllowanceIds: [String] = []
private(set) var evaluatedTrustedTimes: [String] = []
private(set) var selections: [Paykit.AllowanceSelectionInput] = []
private(set) var acceptedEndpointIdentifiers: [String] = []
Expand Down Expand Up @@ -1135,7 +1203,11 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling {

func acceptAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord {
log.append("acceptAllowance")
throw AllowanceMockError.unsupported
guard let index = records.firstIndex(where: { $0.allowanceId == allowanceId })
else { throw AllowanceMockError.unsupported }
records[index].state = .accepted
acceptedAllowanceIds.append(allowanceId)
return records[index]
}

func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord {
Expand Down
20 changes: 18 additions & 2 deletions BitkitTests/PaykitAllowanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,21 @@ final class PaykitAllowanceTests: XCTestCase {

// MARK: Grouping

func testOnlyAReceivedProposalFromTheAllowerIsAnOffer() {
typealias Fixtures = PaykitAllowanceFixtures
let fromAllower = Fixtures.allowance(role: .allowee, state: .proposed, isProposedByMe: false)
XCTAssertTrue(fromAllower.isOfferFromAllower)
XCTAssertTrue(fromAllower.isAnswerable)

let fromAllowee = Fixtures.allowance(role: .allower, state: .proposed, isProposedByMe: false)
XCTAssertFalse(fromAllowee.isOfferFromAllower)
XCTAssertTrue(fromAllowee.isAnswerable)

XCTAssertFalse(Fixtures.allowance(role: .allower, state: .proposed, isProposedByMe: true).isOfferFromAllower)
XCTAssertFalse(Fixtures.allowance(role: .allowee, state: .proposed, isProposedByMe: true).isOfferFromAllower)
XCTAssertFalse(Fixtures.allowance(role: .allowee, state: .accepted, isProposedByMe: false).isOfferFromAllower)
}

func testEntryPrimaryIsTheGrantsAllowance() {
let allowance = Fixtures.allowance(allowanceId: Fixtures.walletAllowanceId)

Expand Down Expand Up @@ -420,13 +435,14 @@ enum PaykitAllowanceFixtures {
perPaymentMaxSats: UInt64? = 5000,
monthlyLimitSats: UInt64? = 50000,
monthlyAnchor: Date? = septemberAnchor,
expiresAt: Date? = nil
expiresAt: Date? = nil,
isProposedByMe: Bool? = nil
) -> PaykitAllowance {
PaykitAllowance(
id: PaykitAllowance.ID(counterparty: counterparty, allowanceId: allowanceId),
role: role,
lifecycleState: state,
isProposedByMe: role == .allower,
isProposedByMe: isProposedByMe ?? (role == .allower),
perPaymentMaxSats: perPaymentMaxSats,
monthlyLimitSats: monthlyLimitSats,
monthlyAnchor: monthlyAnchor,
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/841.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allowances now activate automatically on the receiving wallet: when a contact sets up an allowance for you, Bitkit accepts it and lets you know.
17 changes: 10 additions & 7 deletions journeys/allowances/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Allowances journeys

Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a
contact, the payee accepts it, requests within the limits are paid without asking, a request above a
contact, the payee's wallet accepts it by itself, requests within the limits are paid without asking, a request above a
limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey
pins the one rule that must never break: a payment interrupted mid-flight is never paid twice.

Expand All @@ -14,8 +14,8 @@ requests). Automatic payments pay the payee's private Lightning invoice first an
address otherwise, so the payer needs a spending balance above 50,000 sats with a usable channel,
and the payee needs receiving capacity; fund both through the staging LSP.

Allow notifications for Bitkit on the payer when it asks: the "Payment Executed" and "Limit
Reached" events post a local notification when they can, and fall back to an in-app toast that the
Allow notifications for Bitkit on both instances when it asks: the payer's "Payment Executed" and
"Limit Reached" events and the payee's "Allowance Added" event post a local notification when they can, and fall back to an in-app toast that the
UI snapshot cannot see.

The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey
Expand All @@ -40,15 +40,18 @@ killed right after handing the payment to the node never paid twice after relaun
`AllowancePerPaymentStop-<index>` and `AllowanceMonthlyStop-<index>`, `AllowanceSummary`,
`AllowanceSave`
- List row: `AllowanceRow-<allowanceId>` with its status line `AllowanceRowStatus`
- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`,
`AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline`
- Offer notification (payee): the toast `AllowanceOfferAcceptedToast` when notifications are off
- Review sheet: `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`,
`AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline`; it opens only on the allower's wallet
for an allowance the payee asked for, never for the payer's own offer
- Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus`
- Payments tab: `Tab-payments`, `PaymentRequestRequestPayment`,
`PaymentRequestRow-<paymentRequestId>-one-time` whose subtitle ends with "· Auto-paid" for an
automatic payment
- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet`

The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is
needed to reach it. The file names, journey names and step prose match
The payee's wallet accepts the payer's offer by itself once the offer has arrived, which can take a
while between contacts that have never exchanged a private message, and posts "<payer> set up an
allowance for you"; no review sheet opens and no tap is needed. The file names, journey names and step prose match
[`bitkit-android/journeys/allowances`](https://github.com/synonymdev/bitkit-android/tree/master/journeys/allowances);
only the identifier annotations and the kill, relaunch and notification mechanics differ.
14 changes: 7 additions & 7 deletions journeys/allowances/set-and-accept.xml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<journey name="Set And Accept Allowance">
<description>
The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer
open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an
answer"; after it, both sides show the allowance as Active. The other allowance journeys start
from the Active state this one ends in.
The payer sets an allowance for a contact from the Allowances tab, and the payee's wallet accepts
the offer by itself: no review sheet opens and the payee taps nothing. The payee gets a "&lt;payer&gt;
set up an allowance for you" notification, and both sides show the allowance as Active. Until the
payee's wallet has received the offer, the payer's row reads "Waiting for an answer". The other
allowance journeys start from the Active state this one ends in.
</description>
<actions>
<action>Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel</action>
Expand All @@ -18,9 +19,8 @@
<action>Tap Save Allowance (id "AllowanceSave")</action>
<action>Verify the sheet dismisses and the list shows one row for the payee (id "AllowanceRow-&lt;allowanceId&gt;") whose status line (id "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right</action>
<action>Switch to the payee instance and bring Bitkit to the foreground</action>
<action>Verify the Allowance review sheet (id "AllowanceReview") opens on its own within a few seconds, headed "&lt;payer&gt; offers an allowance", with the payer's contact card (id "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (id "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (id "AllowanceMonthlyValue")</action>
<action>Tap Accept (id "AllowanceAccept")</action>
<action>Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit</action>
<action>Verify no Allowance review sheet (id "AllowanceReview") opens on the payee and that a notification "Allowance Added" reading "&lt;payer&gt; set up an allowance for you" arrives (or, with notifications off, the toast with id "AllowanceOfferAcceptedToast")</action>
<action>On the payee, open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit, with no Accept or Decline step</action>
<action>Switch back to the payer instance and verify its row now reads "Active · $5 a payment"</action>
</actions>
</journey>