-
-
Notifications
You must be signed in to change notification settings - Fork 112
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
receiver and sender fallback #1523
base: master
Are you sure you want to change the base?
Conversation
8b22e03
to
e647d2e
Compare
on hold until #1507 is merged, i will rebase any change on top of the new code |
c36b04e
to
860fab0
Compare
} | ||
await waitForQrPayment(invoice, null, { persistOnNavigate, waitFor }) | ||
return { invoice, response } | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
anons gets the qr code right away
707826c
to
994b69d
Compare
api/paidAction/index.js
Outdated
} | ||
context.me = context.me ? await context.models.user.findUnique({ where: { id: context.me.id } }) : undefined | ||
context.actionAttempt = context.actionAttempt ?? 0 | ||
context.forceInternal = context.forceInternal ?? false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This replaces forceFeeCredits and it is currently used by things like auto territory renewal to force the code to use only internal payments (fee credits and reward sats).
The difference between forceInternal and the old forceFeeCredits is that forceInternal allows the code to use reward sats too, not only fee credits.
api/paidAction/index.js
Outdated
context.me = context.me ? await context.models.user.findUnique({ where: { id: context.me.id } }) : undefined | ||
context.actionAttempt = context.actionAttempt ?? 0 | ||
context.forceInternal = context.forceInternal ?? false | ||
context.prioritizeInternal = context.prioritizeInternal ?? false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Contrary to forceInternal, this will just prioritize reward sats and fee credits.
This is used by the client side code after every sending wallet failed to pay every receiving wallet, so it will try to do an internal transaction, if this fails too they payment will fallback to the other methods in order, this gives another invoice that is then presented to the user with a qr code.
SELECT 1 | ||
FROM "ItemAct" | ||
WHERE "ItemAct"."invoiceId" = "Invoice".id | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when we have a failed invoiced action that it is then retried successfully with a fee credit or reward sats payment, we have this situation where there is a failed invoice that used to be bound to this action but it is not anymore.
This check is to exclude it from notifications. Maybe there are other ways, but i am unsure.
setInnerResult(r => addPayError(e, r)) | ||
}) | ||
|
||
if (wait) await p |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i've de-duplicated the code, but the logic should be the same
Rebased to master
now this is taken into account. The paid mutation returns a retriable flag and if the payment fails the client queries the paidAction invoiceForward's withdrawl status, at this point it has all the data it needs to determine which side is failing when the mutation returns a withdrawl status that is set but not CONFIRMED
if the mutation doesn't have a withdrawl status
if the fee credit fails: the client will fallback to the qr code using the last Note: this is not explicit in the code, but it is an effect of the logic: if we previously tried all the receivers and the forward failed for all of them, we will have an attempt counter high enough to exceed the number of receiving wallets, this will skip the p2p method, as it should, since there is no way to pay, even by qr code. |
api/paidAction/index.js
Outdated
console.log(`trying payment method ${paymentMethod}`) | ||
switch (paymentMethod) { | ||
case PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT: { | ||
if (!me || (me.msats ?? 0n) < cost) break // if anon or low balance skip |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The original code that looks for violates check
was there for a reason. The way you've rewritten this suppresses errors that aren't related to payments.
The switch statement is nice, but be careful causing regressions by changing code that you don't understand - and more importantly, code that isn't directly related to your changes.
Stuff like this is what causes me to do full rewrites.
68bf879
to
171dcfd
Compare
171dcfd
to
03ec61f
Compare
952d705
to
a016427
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't run the code yet, but this is my review so far from looking at most of the code
if (forceInternal) { | ||
// we keep only the payment methods that qualify as internal payments | ||
if (!me) { | ||
throw new Error('user must be logged in to use internal payments') | ||
} | ||
const forcedPaymentMethods = [] | ||
// reset the supported payment methods to only include internal methods | ||
// that are supported by the action | ||
if (context.supportedPaymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT)) { | ||
forcedPaymentMethods.push(PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT) | ||
} | ||
// TODO: add reward sats | ||
// ... | ||
if (forcedPaymentMethods.length === 0) { | ||
throw new Error('action does not support internal payments') | ||
} | ||
context.supportedPaymentMethods = forcedPaymentMethods | ||
} else if (prioritizeInternal) { | ||
// prefer internal payment methods over the others (if they are supported) | ||
const priority = { | ||
[PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT]: -2 | ||
// add other internal methods here | ||
} | ||
context.supportedPaymentMethods = context.supportedPaymentMethods.sort((a, b) => { | ||
return priority[a] - priority[b] | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reward sats don't need to be a separate payment method since they can only be used if you pay with fee credits (they get converted into ccs just-in-time).
I think if you add reward sats as a separate payment method, that would mean that they can also be used for p2p zaps which is not what we want.
This means that forcedPaymentMethods
doesn't have to be an array, it will always only contain FEE_CREDIT
.
This means I also think the distinction between "internal payment method" and "fee credits" isn't needed. Internal always means fee credits.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it seems like they will be implemented as a separated payment method in ex-nov-5 pr, that's why i made the distinction
https://github.com/stackernews/stacker.news/blob/nov-5/api/paidAction/itemUpdate.js#L9-L13
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mhh, I see. @huumn, does it make sense to add REWARD_SATS
as a payment method? I understood it as an "implementation detail" of the FEE_CREDIT
payment method where they are converted into CCs just-in-time. In that case, isn't it unnecessary as a separate payment method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I looked through the sending code in detail now and tested the fallback using LNbits that fails and NWC that succeeds. I noticed that the client cancels the invoice. Is there a specific reason for that?
I still need to test more, especially the receiving part.
components/qr.js
Outdated
const usableWallets = wallets.filter(w => !w.def.isAvailable || w.def.isAvailable()) | ||
.filter(w => w.config?.enabled && canSend(w))[0] | ||
if (automated && usableWallets.length > 0) { | ||
for (const wallet of usableWallets) { | ||
if (automated && wallets.length > 0) { | ||
for (const wallet of wallets) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am sorry, I was wrong here. The code I told you would run in useWallets
does actually not run in useWallets
but in useWallet
. I confused them.
However, this means there is something else wrong: the wallets returned in useWallets
do not contain sendPayment
so this will fail with TypeError: wallet.sendPayment is not a function
. You need to call useWallet
to have access to this function. Additionally, usableWallets
is not an array because of the [0]
at the end.
Since you have similar code in usePaidMutation
(where it works since you do call useWallet
there), I think it makes sense to create a new hook useEnabledWallets
which then returns an array of wallets you can use here and in usePaidMutation
.
Again, sorry for the confusion. 🙈
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No worries, this part of the code has been rebased so many times that I've lost track of the changes as well
components/use-paid-mutation.js
Outdated
const senderWallets = walletDefs | ||
.map(w => useWallet(w.def.name)) | ||
.filter(w => !w.def.isAvailable || w.def.isAvailable()) | ||
.filter(w => w.config?.enabled && canSend(w)).map(w => { | ||
return { ...w, failed: false } | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See comment in
https://github.com/stackernews/stacker.news/pull/1523/commits/4e96d5d2a80e1cfba65f1976b857c904722b5a94
about a new useEnabledWallets
hook so we don't have to maintain this code at several places
had to use a code block for the URL since else Github automatically converts it into a link to the commit without the context of the PR and the comment ...
components/use-paid-mutation.js
Outdated
await refreshInvoice() | ||
if (!invoice) { | ||
setInnerResult(r => addPayError(new Error('You must be logged in'), r)) | ||
throw new Error('You must be logged in') | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this code isn't needed. Afaict, refreshInvoice
is always called where invoiceUsed = false
and with an invoice so if (!invoice)
is also not needed.
components/poll.js
Outdated
@@ -103,7 +103,7 @@ function PollResult ({ v, progress }) { | |||
) | |||
} | |||
|
|||
export function usePollVote ({ query = POLL_VOTE, itemId }) { | |||
export function usePollVote ({ query = POLL_VOTE, itemId, ...options }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is ...options
needed? I don't see usePollVote
called with anything else than query
or itemId
.
actionId | ||
if (failedInvoice.actionState !== 'FAILED') { | ||
// you should cancel the invoice before retrying the action! | ||
throw new Error(`actions is not in a retriable state: ${failedInvoice.actionState}`) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does the client have to cancel the invoice? Can't we do it here?
Canceling on the client is an additional round-trip and the code might be simpler if retrying implicitly cancels the existing invoice.
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I did consider implementing it like you've suggested but i've kept it this way because it is what the original code does.
But thinking about this in more depth, i think you are right, and that probably the transaction isolation should be serializable, because there is a race condition here (and in the original code), where two transactions could start with a committed 'failed' invoice state, but then end up with two different results.
What do you think?
The PR implements fallbacks on receiver, lnurlp for attached wallets and does some refactoring to the wallet interface.
Closes #1496
Closes #1494
partially solves #1558 by cancelling and creating a new invoice before showing the qr code
Changelog:
Overview
This PR enhances payment reliability between sender and receiver wallets by adding fallback mechanisms.
The first part ensures that if a receiver’s wallet fails to generate a valid and wrappable invoice, the system will automatically attempt to create the invoice with the next available wallet, repeating this process until a valid invoice is obtained.
The second part addresses situations where a sender might have difficulty paying the invoice. Instead of simply trying a single payment path, the PR introduces a retry system that cycles through different sender-receiver wallet pairs. This way, if the first pair can’t connect, the system will increment an attempt counter and try another receiver wallet to find a working path. This approach helps avoid payment failures caused by both wallets being online but unable to connect directly.
If these attempts still don’t succeed, the system will prioritize fee credits or reward sats. ie. if a p2p payment was prioritized initially by the action but fails, the system will reprioritize internal payment options. This prioritization allows the payment to go through automatically if sufficient funds are available. If these methods do not work, the system will fallback to the next paymentMethod in the action, and generate a new invoice, which the client displays as a QR code for the user to complete the payment manually.
P2P preference flow
When a paid action is tried, the serverside code will go through the payment methods supported by the action, following the priority order. This behavior can be mutated by passing:
forceInternal
: to use only the internal payment methods that are supported by the action (currently only FEE_CREDIT)prioritizeInternal
: to move the internal payment methods at the beginning in the priority list, making them the preferable methods while keeping all the others as fallbacks.The paid action mutation returns a
retriable
flag that signals the sender it can retry the action usingretryPaidMutation
without changing anything, but just by increasing theattempt
counter. This means the server side code has other ways to make the the transaction happen that were not tried yet. In the scope of this PR it means it has more p2p attachments to try.Whenever a payment fails, the clientside code queries the paidAction to check if there is an invoiceForward's withdrawl status
attempt
counter and retry ifretriable
is true, to let the serverside code use the next p2p attachment on the receiving sidefailed
and retry with the next sender without increasing theattempt
counter. This in turn will make the serverside code reuse the same receiving attachment.If all the senders have failed or the retriable flag is false (all the receiver have failed), the clientside code will fallback to retry the payment using
prioritizeInternal
to hopefully complete it with FEE_CREDITS, if this fails too an invoice will be returned and passed to the qr code payment.Note: this is not explicit in the code, but it is an effect of the logic: if we previously tried all the receivers and the forward failed for all of them, we will have an attempt counter high enough to exceed the number of receiving wallets, this will skip the p2p method, as it should, since there is no way to pay, even by qr code.
Non P2P flow (not internal priority)
The paid actions is tried, the response is returned with a retriable flag always set to false and the invoice to pay.
The clientside code tries to pay with every sender, once all the senders have failed it fallbacks to use
prioritizeInternal
, if this fails, an invoice is returned for the qr codeNon P2P flow (internal priority)
The paid actions is tried, the response is returned with a retriable flag always set to false and the invoice to pay.
The clientside code tries to pay with every sender, once all the senders have failed it fallbacks to use
prioritizeInternal
(FIXME: this is useless, it attempts the same thing twice, it should just skip instead)if this fails, an invoice is returned for the qr code.
Note on invoice reuse
Every time a new attempt is made, the old invoice is cancelled and a new invoice is regenerated
Tests
setup
stacker1:
[good, bad]
[]
stacker2:
[bad, good]
[]
stacker3:
[bad,bad]
[]
stacker4:
[]
[good, bad]
stacker5:
[]
[bad, good]
stacker6:
[bad]
[bad, bad]
stacker7:
[]
[]
stacker8:
[]
[]
Zap
receive
post result
LNURLP
Territory