Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
8bd3504
chore: build paykit from the local allowances stack
ovitrif Sep 24, 2026
df25bea
chore: let an explicit e2e homegate url win on every backend
ovitrif Sep 24, 2026
eed5280
feat: add the allowance model, sdk calls and repo contract
ovitrif Sep 24, 2026
1efc749
feat: keep rc55 paykit state readable across the allowance sdk
ovitrif Sep 24, 2026
4d5c4e6
feat: pay covered paykit requests through allowances
ovitrif Sep 24, 2026
feeb0b6
feat: add the allowances tab and allowance sheets
ovitrif Sep 24, 2026
f13ccf3
feat: run allowances from the app lifecycle and send flow
ovitrif Sep 24, 2026
7bf7640
fix: wait for the payee's next payment list instead of asking the payer
ovitrif Sep 24, 2026
3d7e2af
fix: hold automatic payments until the node's channels are usable
ovitrif Sep 24, 2026
8ac84fc
fix: notify allowance payments and limits over the request sheet
ovitrif Sep 24, 2026
e099551
docs: add the allowance journeys
ovitrif Sep 24, 2026
faca50b
chore: add changelog fragment
ovitrif Sep 24, 2026
4dc9055
fix: extend an allowance to a contact link that links after the grant
ovitrif Sep 25, 2026
145330c
fix: finish an allowance payment once it starts
ovitrif Sep 26, 2026
d701ee5
feat: share paykit state across apps
ben-kaufman Oct 1, 2026
2bba381
chore: rename changelog fragment
ben-kaufman Oct 1, 2026
5f959d0
docs: clarify paykit integration contracts
ben-kaufman Oct 1, 2026
354471e
fix: tighten paykit attribution and cleanup reporting
ben-kaufman Oct 1, 2026
d465536
test: cover paykit key generation and rotation
ben-kaufman Oct 1, 2026
bca62da
fix: guard paykit execution and cache contact backfills
ben-kaufman Oct 1, 2026
ae0764a
fix: prune completed paykit acceptance records
ben-kaufman Oct 1, 2026
d2c5a03
fix: preserve paykit payment recovery and contact choices
ben-kaufman Oct 1, 2026
bef908f
refactor: simplify paykit contact backfill
ben-kaufman Oct 1, 2026
d2525a5
chore: update Paykit to rc59
ben-kaufman Oct 1, 2026
2077863
chore: stack allowances on the shared paykit runtime
ovitrif Oct 1, 2026
af7c2bf
fix: preserve private sharing settings during contact cleanup
ben-kaufman Oct 1, 2026
0b131e4
Merge branch 'master' into codex/paykit-shared-runtime-local-20260930
ben-kaufman Oct 1, 2026
b657fd0
refactor: bind allowances to the shared paykit identity
ovitrif Oct 1, 2026
0b1f266
fix: keep an automatically accepted request payable on this install
ovitrif Oct 1, 2026
455bb90
test: align request presentation with shared paykit
ben-kaufman Oct 1, 2026
6d42b5a
test: align private paykit settings stub
ben-kaufman Oct 1, 2026
c80b941
chore: sync allowances with the shared paykit runtime
ovitrif Oct 1, 2026
b55a79d
docs: align the end allowance journey with the ended row
ovitrif Oct 1, 2026
5aa675e
style: order the allowance imports
ovitrif Oct 1, 2026
16d44b5
fix: satisfy detekt in the allowance acceptance
ovitrif Oct 1, 2026
1adde2a
fix: reduce paykit sync overhead and retry session setup
ben-kaufman Oct 1, 2026
b929cce
chore: sync allowances with the shared paykit runtime
ovitrif Oct 1, 2026
5fb0193
chore: merge master into paykit shared runtime
ben-kaufman Oct 2, 2026
c642d32
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
7026f86
fix: keep private message sync off frequent request polls
ben-kaufman Oct 2, 2026
f7338f5
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
3733d2a
feat: share paykit state across apps
ben-kaufman Oct 1, 2026
ce9910c
chore: rename changelog fragment
ben-kaufman Oct 1, 2026
44fae4b
docs: clarify paykit integration contracts
ben-kaufman Oct 1, 2026
ffb3755
fix: tighten paykit attribution and cleanup reporting
ben-kaufman Oct 1, 2026
c4fb1db
test: cover paykit key generation and rotation
ben-kaufman Oct 1, 2026
925256b
fix: guard paykit execution and cache contact backfills
ben-kaufman Oct 1, 2026
963d3ea
fix: prune completed paykit acceptance records
ben-kaufman Oct 1, 2026
5266e83
fix: preserve paykit payment recovery and contact choices
ben-kaufman Oct 1, 2026
011c673
refactor: simplify paykit contact backfill
ben-kaufman Oct 1, 2026
81c1865
chore: update Paykit to rc59
ben-kaufman Oct 1, 2026
1f41d51
fix: preserve private sharing settings during contact cleanup
ben-kaufman Oct 1, 2026
8f05585
test: align request presentation with shared paykit
ben-kaufman Oct 1, 2026
0af40d5
test: align private paykit settings stub
ben-kaufman Oct 1, 2026
6125336
fix: reduce paykit sync overhead and retry session setup
ben-kaufman Oct 1, 2026
14d5383
fix: keep private message sync off frequent request polls
ben-kaufman Oct 2, 2026
573123b
chore: sync allowances with the rewritten shared paykit runtime
ovitrif Oct 2, 2026
8237983
chore: sync allowances with the rewritten shared paykit runtime
ovitrif Oct 2, 2026
56dbcf7
fix: pay allowances only to unused on-chain addresses
ovitrif Oct 2, 2026
a2f33ad
test: pin that allowances ignore the subscription clock offset
ovitrif Oct 2, 2026
cda2de2
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
0e4e239
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
3472b26
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
44af685
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
de33cd3
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
681f0fb
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
a63faa4
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
9879897
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
1962193
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
c71b8df
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
07b5b6f
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
b7f4c8b
chore: sync allowances with the shared paykit runtime
ovitrif Oct 2, 2026
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
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ val e2eLocalHostEnv = providers.environmentVariable("E2E_LOCAL_HOST").orElse("10
val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL")
.orElse(e2eLocalHostEnv.map { "http://$it:6288" })
val e2eHomeserverPubkyEnv = providers.environmentVariable("E2E_HOMESERVER_PUBKY").orElse("")
val e2eHomegateOverrideEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("")
val geoEnv = envFlag("GEO", default = true)
val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false)
val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false")
Expand Down Expand Up @@ -325,6 +326,7 @@ androidComponents {
buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField())
buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField())
buildConfigFields.put("E2E_HOMESERVER_PUBKY", e2eHomeserverPubkyEnv.stringField())
buildConfigFields.put("E2E_HOMEGATE_OVERRIDE", e2eHomegateOverrideEnv.stringField())
buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField())
buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField())
buildConfigFields.put("GEO", geoEnv.booleanField())
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ class Keychain @Inject constructor(
PAYKIT_PENDING_PAYMENT_PROOFS,
PAYKIT_ACCEPTED_PAYMENT_REQUESTS,
PAYKIT_PRESENTED_PAYMENT_REQUESTS,
PAYKIT_ALLOWANCE_STATE,
PUBKY_SECRET_KEY,
SHARED_PUBKY_SOURCE,
}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ internal object Env {

val homegateUrl: String
get() {
// An explicit E2E_HOMEGATE_URL wins on every backend, as on iOS: demos run their own homegate.
if (isE2eTest && BuildConfig.E2E_HOMEGATE_OVERRIDE.isNotBlank()) {
return BuildConfig.E2E_HOMEGATE_OVERRIDE
}
if (isLocalE2eBackend) {
return e2eHomegateUrl
}
Expand Down
269 changes: 269 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
package to.bitkit.repositories

import androidx.compose.runtime.Immutable
import com.synonym.paykit.AllowanceAccountingHistory
import com.synonym.paykit.AllowanceAmountRange
import com.synonym.paykit.AllowanceLifecycleState
import com.synonym.paykit.AllowanceLocalRole
import com.synonym.paykit.AllowancePeriod
import com.synonym.paykit.AllowancePeriodLimit
import com.synonym.paykit.AllowanceRecord
import com.synonym.paykit.AllowanceTerms
import com.synonym.paykit.PaymentExecutionMode
import com.synonym.paykit.PaymentExecutionStatus
import kotlinx.serialization.Serializable
import to.bitkit.models.safe
import java.math.BigDecimal
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant

/**
* An Allowance between this wallet's identity and one contact identity, built from the SDK record.
* Eligibility always runs on real time.
*/
@Immutable
data class PaykitAllowance(
val id: Id,
val role: Role,
val lifecycleState: AllowanceLifecycleState,
val isProposedByMe: Boolean,
val perPaymentMaxSats: ULong?,
val monthlyLimitSats: ULong?,
val monthlyAnchor: Instant?,
val activeFrom: Instant? = null,
val expiresAt: Instant? = null,
val allowedPaymentEndpointIdentifiers: List<String>? = null,
val lastEventAt: Instant? = null,
) {
@Serializable
data class Id(
val counterparty: String,
val allowanceId: String,
)

@Serializable
enum class Role { ALLOWER, ALLOWEE }

enum class Status {
/** Sent by this wallet; the other side has not answered yet. */
AWAITING_ANSWER,

/** Received; this wallet must accept or decline. */
AWAITING_MY_ANSWER,
ACTIVE,
NOT_YET_ACTIVE,
EXPIRED,
DECLINED,
ENDED,
CONFLICTED,
}

val counterparty: String get() = id.counterparty
val allowanceId: String get() = id.allowanceId

/** The payer side: this wallet pays the counterparty's requests automatically. */
val isAllower: Boolean get() = role == Role.ALLOWER

val canEnd: Boolean
get() = when (lifecycleState) {
AllowanceLifecycleState.ACCEPTED -> true
AllowanceLifecycleState.PROPOSED -> isProposedByMe
else -> false
}

val isAnswerable: Boolean get() = lifecycleState == AllowanceLifecycleState.PROPOSED && !isProposedByMe

fun status(now: Instant): Status = when (lifecycleState) {
AllowanceLifecycleState.PROPOSED -> if (isProposedByMe) Status.AWAITING_ANSWER else Status.AWAITING_MY_ANSWER
AllowanceLifecycleState.ACCEPTED -> when {
expiresAt != null && now >= expiresAt -> Status.EXPIRED
activeFrom != null && now < activeFrom -> Status.NOT_YET_ACTIVE
else -> Status.ACTIVE
}
AllowanceLifecycleState.REJECTED -> Status.DECLINED
AllowanceLifecycleState.ENDED -> Status.ENDED
AllowanceLifecycleState.CONFLICTED, AllowanceLifecycleState.UNKNOWN -> Status.CONFLICTED
}

companion object {
fun from(record: AllowanceRecord): PaykitAllowance? {
val role = when (record.localRole) {
AllowanceLocalRole.ALLOWER -> Role.ALLOWER
AllowanceLocalRole.ALLOWEE -> Role.ALLOWEE
else -> return null
}
val terms = record.terms ?: return null
if (terms.asset() != PaykitIssuerInterop.BITCOIN_ASSET) return null
val monthly = terms.periodLimits().firstOrNull { isMonthly(it.period()) }
return PaykitAllowance(
id = Id(record.counterparty, record.allowanceId),
role = role,
lifecycleState = record.state,
isProposedByMe = record.proposalOutboundMessageId != null,
perPaymentMaxSats = terms.perPaymentAmount()?.let { satsFromBitcoinAmount(it.maximum()) },
monthlyLimitSats = monthly?.amountLimit()?.let(::satsFromBitcoinAmount),
monthlyAnchor = monthly?.period()?.anchor()?.let(PaykitAllowanceTime::parse),
activeFrom = terms.activeFrom()?.let(PaykitAllowanceTime::parse),
expiresAt = terms.expiresAt()?.let(PaykitAllowanceTime::parse),
allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers(),
lastEventAt = record.lastEventAt?.let(PaykitAllowanceTime::parse),
)
}

fun isMonthly(period: AllowancePeriod): Boolean =
period.kind() == "anchored" && period.every() == 1uL && period.unit() == "month"

/** BTC decimal string to sats; unlike [toPaykitSats] a zero amount is valid here. */
fun satsFromBitcoinAmount(amount: String): ULong? {
if (amount.split('.').all { part -> part.all { it == '0' } }) return 0uL
return amount.toPaykitSats()
}
}
}

/** One grant as the user sees it: the Allowance with a contact identity and the limits picked for it. */
@Immutable
data class PaykitAllowanceEntry(
val id: String,
val allowances: List<PaykitAllowance>,
val limits: PaykitAllowanceLimits?,
) {
/** An accepted Allowance before any other. */
val primary: PaykitAllowance
get() = allowances.firstOrNull { it.lifecycleState == AllowanceLifecycleState.ACCEPTED } ?: allowances.first()
val counterparty: String get() = primary.counterparty
val role: PaykitAllowance.Role get() = primary.role
val perPaymentMaxSats: ULong? get() = primary.perPaymentMaxSats
val monthlyLimitSats: ULong? get() = primary.monthlyLimitSats
val canEnd: Boolean get() = allowances.any { it.canEnd }
val isAnswerable: Boolean get() = allowances.any { it.isAnswerable }

fun status(now: Instant): PaykitAllowance.Status = primary.status(now)
}

/** Limits picked in USD on the Set Allowance sheet, converted once to whole-sat BTC terms. */
@Serializable
@Immutable
data class PaykitAllowanceLimits(
val perPaymentUsd: Int,
val monthlyUsd: Int,
val perPaymentSats: ULong,
val monthlySats: ULong,
) {
/**
* Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays.
*/
fun terms(monthAnchor: Instant, allowedPaymentEndpointIdentifiers: List<String>): AllowanceTerms {
val perPayment = AllowanceAmountRange(minimum = "0", maximum = perPaymentSats.toBitcoinDecimal())
val month = AllowancePeriod(
kind = "anchored",
every = 1uL,
unit = "month",
anchor = PaykitAllowanceTime.format(monthAnchor),
)
val monthly = AllowancePeriodLimit(
amountLimit = monthlySats.toBitcoinDecimal(),
paymentCountLimit = null,
period = month,
)
return AllowanceTerms(
asset = PaykitIssuerInterop.BITCOIN_ASSET,
perPaymentAmount = perPayment,
periodLimits = listOf(monthly),
lifetimeAmountLimit = null,
activeFrom = null,
expiresAt = null,
allowedPaymentEndpointIdentifiers = allowedPaymentEndpointIdentifiers,
)
}

companion object {
/** Slider stops on the Set Allowance sheet, in whole US dollars. */
val PER_PAYMENT_STOPS_USD = listOf(1, 5, 10, 20, 50)

/** Slider stops on the Set Allowance sheet, in whole US dollars. */
val MONTHLY_STOPS_USD = listOf(10, 50, 100, 200, 500)
}
}

internal fun ULong.toBitcoinDecimal(): String =
BigDecimal(toString()).movePointLeft(8).stripTrailingZeros().toPlainString()

object PaykitAllowanceTime {
private val utc = ZoneOffset.UTC

fun format(instant: Instant): String =
DateTimeFormatter.ISO_INSTANT.format(instant.toJavaInstant().truncatedTo(ChronoUnit.MILLIS))

fun parse(value: String): Instant? = runCatching { Instant.parse(value) }.getOrNull()

/** First instant of the UTC calendar month that contains [instant]. */
fun monthStart(containing: Instant): Instant = ZonedDateTime.ofInstant(containing.toJavaInstant(), utc)
.withDayOfMonth(1)
.truncatedTo(ChronoUnit.DAYS)
.toInstant()
.toKotlinInstant()

/**
* The anchored monthly window `[start, end)` that contains [instant]. Anchors on a day that a short month lacks
* clamp to that month's last day, as the spec's anchored-period arithmetic does.
*/
fun monthlyWindow(anchor: Instant, containing: Instant): Pair<Instant, Instant> {
val anchorTime = ZonedDateTime.ofInstant(anchor.toJavaInstant(), utc)
val dateTime = ZonedDateTime.ofInstant(containing.toJavaInstant(), utc)
// Every boundary counts from the original anchor, so a clamped February never shortens later months.
val boundary = { index: Long -> anchorTime.plusMonths(index).toInstant().toKotlinInstant() }
var index = (dateTime.year - anchorTime.year) * 12L + dateTime.monthValue - anchorTime.monthValue
while (boundary(index) > containing) index--
while (boundary(index + 1) <= containing) index++
return boundary(index) to boundary(index + 1)
}
}

/**
* Wallet-side capacity preflight. The SDK checks capacity only after automatic Acceptance, so Bitkit sums this
* Allowance's live automatic attempts in the current month first and keeps an over-cap request on the manual flow.
*/
object PaykitAllowanceCapacity {
data class Attempt(
val allowanceId: String,
val amountSats: ULong,
val admittedAt: Instant,
val isLive: Boolean,
)

fun usedSats(allowanceId: String, attempts: List<Attempt>, anchor: Instant, now: Instant): ULong {
val (start, end) = PaykitAllowanceTime.monthlyWindow(anchor, now)
return attempts
.filter { it.allowanceId == allowanceId && it.isLive && it.admittedAt >= start && it.admittedAt < end }
.fold(0uL) { total, attempt -> total.safe() + attempt.amountSats.safe() }
}

fun fits(amountSats: ULong, allowance: PaykitAllowance, attempts: List<Attempt>, now: Instant): Boolean {
val perPaymentMax = allowance.perPaymentMaxSats
if (perPaymentMax != null && amountSats > perPaymentMax) return false
val monthlyLimit = allowance.monthlyLimitSats ?: return true
val anchor = allowance.monthlyAnchor ?: return true
return usedSats(allowance.allowanceId, attempts, anchor, now).safe() + amountSats.safe() <= monthlyLimit
}

fun attempts(history: AllowanceAccountingHistory): List<Attempt> = history.occurrences
.flatMap { it.attempts }
.mapNotNull { attempt ->
if (attempt.mode != PaymentExecutionMode.AUTOMATIC) return@mapNotNull null
val allowanceId = attempt.allowanceId ?: return@mapNotNull null
val admittedAt = PaykitAllowanceTime.parse(attempt.admittedAt) ?: return@mapNotNull null
val amountSats = PaykitAllowance.satsFromBitcoinAmount(attempt.amount.value()) ?: return@mapNotNull null
Attempt(
allowanceId = allowanceId,
amountSats = amountSats,
admittedAt = admittedAt,
isLive = attempt.status != PaymentExecutionStatus.FAILED,
)
}
}
Loading
Loading