Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(background): handle unpeered wallets; retry without waiting interval #465

Merged
merged 31 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f43c0a4
refactor: use events service for updating extension icon
sidvishnoi Jul 31, 2024
4ca474e
frameManager: get rid of `isFrameMonetized`
sidvishnoi Jul 31, 2024
18b3de5
remove `IS_FRAME_MONETIZED`
sidvishnoi Jul 31, 2024
5d5a6d5
Check if tab.id exists
raducristianpopa Jul 31, 2024
a4d162f
Merge branch 'main' into remove-is-tab-monetized-events
sidvishnoi Jul 31, 2024
35d6377
trigger indicator update more often for safety
sidvishnoi Jul 31, 2024
cad97dd
Update icon if all links are invalid
dianafulga Jul 31, 2024
23be9b3
Merge branch 'main' of https://github.com/interledger/web-monetizatio…
dianafulga Jul 31, 2024
6f618ad
Merge branch 'main' into df--unpeered-wallet
dianafulga Jul 31, 2024
cf44905
warning for otp
dianafulga Jul 31, 2024
e76b1cd
Merge branch 'df--unpeered-wallet' of https://github.com/interledger/…
dianafulga Jul 31, 2024
b63afce
handle review feedback; exclude invalid sessions from payments
sidvishnoi Aug 1, 2024
99a1b0f
Do not wait for the next payment if an error occurs
raducristianpopa Aug 1, 2024
93f6389
Add debug log
raducristianpopa Aug 1, 2024
96e1734
Merge branch 'main' into df--unpeered-wallet
sidvishnoi Aug 2, 2024
1badc99
add missing isInvalid check; refactor some utils
sidvishnoi Aug 2, 2024
226cad0
Retry outgoing payment on certain errors
raducristianpopa Aug 2, 2024
88bc63b
probe in same currency also (to know if peered)
sidvishnoi Aug 2, 2024
c4450c3
Log `isInvalid` on start()
raducristianpopa Aug 2, 2024
5a4fb8c
Merge branch 'main' into df--unpeered-wallet
sidvishnoi Aug 2, 2024
10ed3b3
remove stray hardcoded incomingPaymentId
sidvishnoi Aug 2, 2024
dc9c596
Do not split OTP amount if we cannot send to a wallet address (invalid)
raducristianpopa Aug 2, 2024
334d9df
Fix payable session logic
raducristianpopa Aug 2, 2024
bfdab4d
Add messages to locales
raducristianpopa Aug 2, 2024
98edc8e
Lint
raducristianpopa Aug 2, 2024
98e961d
Merge branch 'main' of https://github.com/interledger/web-monetizatio…
dianafulga Aug 6, 2024
d4f44e3
Merge branch 'main' of https://github.com/interledger/web-monetizatio…
dianafulga Aug 6, 2024
73db0c1
Add final message & hide all settings in home page
dianafulga Aug 6, 2024
76ebcf9
Apply suggestions from code review
raducristianpopa Aug 7, 2024
96703c7
Merge branch 'main' into df--unpeered-wallet
raducristianpopa Aug 7, 2024
b1d5779
Format
raducristianpopa Aug 7, 2024
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
10 changes: 10 additions & 0 deletions src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
"pay_error_notEnoughFunds": {
"message": "Not enough funds to facilitate payment."
},
"pay_error_invalidReceivers": {
"message": "At the moment, you can not pay this website.",
"description": "We cannot send money (probable cause: un-peered wallets)"
},
"pay_error_notMonetized": {
"message": "This website is not monetized."
},
"outOfFunds_error_title": {
"message": "Out of funds"
},
Expand Down Expand Up @@ -83,5 +90,8 @@
},
"connectWallet_error_invalidClient": {
"message": "Failed to connect. Please make sure you have added the public key to the correct wallet address."
},
"allInvalidLinks_state_text": {
"message": "At the moment, you can not pay this website."
}
}
1 change: 1 addition & 0 deletions src/background/services/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AmountValue, Storage, TabId } from '@/shared/types'
interface BackgroundEvents {
'open_payments.key_revoked': void
'open_payments.out_of_funds': void
'open_payments.invalid_receiver': { tabId: number }
'storage.rate_of_pay_update': { rate: string }
'storage.state_update': {
state: Storage['state']
Expand Down
40 changes: 30 additions & 10 deletions src/background/services/monetization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class MonetizationService {
payload.forEach((p) => {
const { requestId, walletAddress: receiver } = p

// Q: How does this impact client side apps/routing?
const existingSession = sessions.get(requestId)
if (existingSession) {
existingSession.stop()
Expand All @@ -97,7 +98,8 @@ export class MonetizationService {

this.events.emit('monetization.state_update', tabId)

const sessionsArr = this.tabState.getEnabledSessions(tabId)
const sessionsArr = this.tabState.getPayableSessions(tabId)
if (!sessionsArr.length) return
const rate = computeRate(rateOfPay, sessionsArr.length)

// Since we probe (through quoting) the debitAmount we have to await this call.
Expand Down Expand Up @@ -162,7 +164,7 @@ export class MonetizationService {
if (!rateOfPay) return

if (needsAdjustAmount) {
const sessionsArr = this.tabState.getEnabledSessions(tabId)
const sessionsArr = this.tabState.getPayableSessions(tabId)
this.events.emit('monetization.state_update', tabId)
if (!sessionsArr.length) return
const rate = computeRate(rateOfPay, sessionsArr.length)
Expand Down Expand Up @@ -232,17 +234,21 @@ export class MonetizationService {
async pay(amount: string) {
const tab = await getCurrentActiveTab(this.browser)
if (!tab || !tab.id) {
throw new Error('Could not find active tab.')
throw new Error('Unexpected error: could not find active tab.')
}
const sessions = this.tabState.getEnabledSessions(tab.id)
if (!sessions.length) {
throw new Error('This website is not monetized.')

const payableSessions = this.tabState.getPayableSessions(tab.id)
if (!payableSessions.length) {
if (this.tabState.getEnabledSessions(tab.id).length) {
throw new Error(this.t('pay_error_invalidReceivers'))
}
throw new Error(this.t('pay_error_notMonetized'))
}

const splitAmount = Number(amount) / sessions.length
const splitAmount = Number(amount) / payableSessions.length
// TODO: handle paying across two grants (when one grant doesn't have enough funds)
const results = await Promise.allSettled(
sessions.map((session) => session.pay(splitAmount))
payableSessions.map((session) => session.pay(splitAmount))
)

const totalSentAmount = results
Expand Down Expand Up @@ -280,6 +286,7 @@ export class MonetizationService {
this.onRateOfPayUpdate()
this.onKeyRevoked()
this.onOutOfFunds()
this.onInvalidReceiver()
}

private onRateOfPayUpdate() {
Expand All @@ -299,7 +306,7 @@ export class MonetizationService {
}

for (const tabId of tabIds) {
const sessions = this.tabState.getEnabledSessions(tabId)
const sessions = this.tabState.getPayableSessions(tabId)
if (!sessions.length) continue
const computedRate = computeRate(rate, sessions.length)
await this.adjustSessionsAmount(sessions, computedRate).catch((e) => {
Expand Down Expand Up @@ -327,6 +334,15 @@ export class MonetizationService {
})
}

private onInvalidReceiver() {
this.events.on('open_payments.invalid_receiver', async ({ tabId }) => {
if (this.tabState.tabHasAllSessionsInvalid(tabId)) {
this.logger.debug(`Tab ${tabId} has all sessions invalid`)
this.events.emit('monetization.state_update', tabId)
}
})
}

private stopAllSessions() {
for (const session of this.tabState.getAllSessions()) {
session.stop()
Expand Down Expand Up @@ -365,6 +381,9 @@ export class MonetizationService {
}
}
const isSiteMonetized = this.tabState.isTabMonetized(tab.id!)
const hasAllSessionsInvalid = this.tabState.tabHasAllSessionsInvalid(
tab.id!
)

return {
...dataFromStorage,
Expand All @@ -374,7 +393,8 @@ export class MonetizationService {
oneTime: oneTimeGrant?.amount,
recurring: recurringGrant?.amount
},
isSiteMonetized
isSiteMonetized,
hasAllSessionsInvalid
}
}

Expand Down
76 changes: 61 additions & 15 deletions src/background/services/paymentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,24 @@ import type { Logger } from '@/shared/logger'

const HOUR_MS = 3600 * 1000
const MIN_SEND_AMOUNT = 1n // 1 unit
const MAX_INVALID_RECEIVER_ATTEMPTS = 2

type PaymentSessionSource = 'tab-change' | 'request-id-reused' | 'new-link'
type IncomingPaymentSource = 'one-time' | 'continuous'

export class PaymentSession {
private rate: string
private active: boolean = false
/** Invalid receiver (providers not peered or other reasons) */
private isInvalid: boolean = false
private countInvalidReceiver: number = 0
private isDisabled: boolean = false
private incomingPaymentUrl: string
private incomingPaymentExpiresAt: number
private amount: string
private intervalInMs: number
private probingId: number
private shouldRetryImmediately: boolean = false

private interval: ReturnType<typeof setInterval> | null = null
private timeout: ReturnType<typeof setTimeout> | null = null
Expand Down Expand Up @@ -126,6 +131,12 @@ export class PaymentSession {
} else if (isNonPositiveAmountError(e)) {
amountToSend = BigInt(amountIter.next().value)
continue
} else if (isInvalidReceiverError(e)) {
this.markInvalid()
this.events.emit('open_payments.invalid_receiver', {
tabId: this.tabId
})
break
} else {
throw e
}
Expand All @@ -141,6 +152,10 @@ export class PaymentSession {
return this.isDisabled
}

get invalid() {
return this.isInvalid
}

disable() {
this.isDisabled = true
this.stop()
Expand All @@ -155,6 +170,11 @@ export class PaymentSession {
throw new Error('Method not implemented.')
}

private markInvalid() {
this.isInvalid = true
this.stop()
}

stop() {
this.active = false
this.clearTimers()
Expand Down Expand Up @@ -186,9 +206,9 @@ export class PaymentSession {

async start(source: PaymentSessionSource) {
this.debug(
`Attempting to start; source=${source} active=${this.active} disabled=${this.isDisabled}`
`Attempting to start; source=${source} active=${this.active} disabled=${this.isDisabled} isInvalid=${this.isInvalid}`
)
if (this.active || this.isDisabled) return
if (this.active || this.isDisabled || this.isInvalid) return
this.debug(`Session started; source=${source}`)
this.active = true

Expand Down Expand Up @@ -216,12 +236,12 @@ export class PaymentSession {
// Uncomment this after we perform the Rafiki test and remove the leftover
// code below.
//
// if (this.active && !this.isDisabled) {
// if (this.canContinuePayment) {
// this.timeout = setTimeout(() => {
// void this.payContinuous()
//
// this.interval = setInterval(() => {
// if (!this.active || this.isDisabled) {
// if (!this.canContinuePayment) {
// this.clearTimers()
// return
// }
Expand All @@ -232,26 +252,36 @@ export class PaymentSession {

// Leftover
const continuePayment = () => {
if (!this.active || this.isDisabled) return
if (!this.canContinuePayment) return
// alternatively (leftover) after we perform the Rafiki test, we can just
// skip the `.then()` here and call setTimeout recursively immediately
void this.payContinuous().then(() => {
this.timeout = setTimeout(() => {
continuePayment()
}, this.intervalInMs)
this.timeout = setTimeout(
() => {
continuePayment()
},
this.shouldRetryImmediately ? 0 : this.intervalInMs
)
})
}

if (this.active && !this.isDisabled) {
this.timeout = setTimeout(() => {
void this.payContinuous()
this.timeout = setTimeout(() => {
continuePayment()
}, this.intervalInMs)
if (this.canContinuePayment) {
this.timeout = setTimeout(async () => {
await this.payContinuous()
this.timeout = setTimeout(
() => {
continuePayment()
},
this.shouldRetryImmediately ? 0 : this.intervalInMs
)
}, waitTime)
}
}

private get canContinuePayment() {
return this.active && !this.isDisabled && !this.isInvalid
}

private async setIncomingPaymentUrl(reset?: boolean) {
if (this.incomingPaymentUrl && !reset) return

Expand Down Expand Up @@ -426,21 +456,37 @@ export class PaymentSession {
intervalInMs: this.intervalInMs
})
}
this.shouldRetryImmediately = false
} catch (e) {
if (isKeyRevokedError(e)) {
this.events.emit('open_payments.key_revoked')
} else if (isTokenExpiredError(e)) {
await this.openPaymentsService.rotateToken()
this.shouldRetryImmediately = true
} else if (isOutOfBalanceError(e)) {
const switched = await this.openPaymentsService.switchGrant()
if (switched === null) {
this.events.emit('open_payments.out_of_funds')
} else {
this.shouldRetryImmediately = true
}
} else if (isInvalidReceiverError(e)) {
if (Date.now() >= this.incomingPaymentExpiresAt) {
await this.setIncomingPaymentUrl(true)
this.shouldRetryImmediately = true
} else {
throw e
++this.countInvalidReceiver
if (
this.countInvalidReceiver >= MAX_INVALID_RECEIVER_ATTEMPTS &&
!this.isInvalid
) {
this.markInvalid()
this.events.emit('open_payments.invalid_receiver', {
tabId: this.tabId
})
} else {
this.shouldRetryImmediately = true
}
}
} else {
throw e
Expand Down
14 changes: 11 additions & 3 deletions src/background/services/tabEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,21 @@ export class TabEvents {
tabId: TabId,
isTabMonetized: boolean = tabId
? this.tabState.isTabMonetized(tabId)
: false,
hasTabAllSessionsInvalid: boolean = tabId
? this.tabState.tabHasAllSessionsInvalid(tabId)
: false
) => {
const { enabled, state } = await this.storage.get(['enabled', 'state'])
const { path, title, isMonetized } = this.getIconAndTooltip({
enabled,
state,
isTabMonetized
isTabMonetized,
hasTabAllSessionsInvalid
})

this.sendToPopup.send('SET_IS_MONETIZED', isMonetized)
this.sendToPopup.send('SET_ALL_SESSIONS_INVALID', hasTabAllSessionsInvalid)
await this.setIconAndTooltip(path, title, tabId)
}

Expand All @@ -124,15 +130,17 @@ export class TabEvents {
private getIconAndTooltip({
enabled,
state,
isTabMonetized
isTabMonetized,
hasTabAllSessionsInvalid
}: {
enabled: Storage['enabled']
state: Storage['state']
isTabMonetized: boolean
hasTabAllSessionsInvalid: boolean
}) {
let title = this.t('appName')
let iconData = ICONS.default
if (!isOkState(state)) {
if (!isOkState(state) || hasTabAllSessionsInvalid) {
iconData = enabled ? ICONS.enabled_warn : ICONS.disabled_warn
const tabStateText = this.t('icon_state_actionRequired')
title = `${title} - ${tabStateText}`
Expand Down
9 changes: 9 additions & 0 deletions src/background/services/tabState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,19 @@ export class TabState {
return [...this.getSessions(tabId).values()].filter((s) => !s.disabled)
}

getPayableSessions(tabId: TabId) {
return this.getEnabledSessions(tabId).filter((s) => !s.invalid)
}

isTabMonetized(tabId: TabId) {
return this.getEnabledSessions(tabId).length > 0
}

tabHasAllSessionsInvalid(tabId: TabId) {
const sessions = this.getEnabledSessions(tabId)
return sessions.length > 0 && sessions.every((s) => s.invalid)
}

getAllSessions() {
return [...this.sessions.values()].flatMap((s) => [...s.values()])
}
Expand Down
13 changes: 13 additions & 0 deletions src/background/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,17 @@ describe('getNextSendableAmount', () => {
'80'
])
})

it('from assetScale 2 to 2', () => {
expect(take(getNextSendableAmount(2, 2), 8)).toEqual([
'1',
'2',
'4',
'8',
'15',
'27',
'47',
'80'
])
})
})
Loading
Loading