-
-
Notifications
You must be signed in to change notification settings - Fork 113
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
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.
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?
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.
Did you test this with multiple zaps in-flight? I can consistently reproduce a transaction timeout if I zap more than three times in quick succession:
2024-11-19.05-41-10.mp4
I am not sure what is causing this, but my first guess would be that there is a race condition that results in a deadlock.
This shouldn't touch any of the new receiving code since I am zapping someone who has no wallets attached.
Maybe it helps if you focus on getting the sending part right before caring about the receiving part.
Also noticed an issue with QR payments since there, the invoice is not refreshed.
I didn’t look further into the receiving part because I am already lacking confidence in the sending part.
components/qr.js
Outdated
break | ||
} catch (e) { | ||
console.log(e?.message) | ||
} |
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 will attempt to pay the same invoice again. So afaict, this will require the same retry logic as in usePaidMutation
.
Since the logic in there isn't easily extractable to apply it here (as it is written, it requires to be in the scope of usePaidMutation
), I think we need to rewrite the retry logic so we can reuse it here or QR codes can't use sender fallbacks.
attempt++ | ||
} else { | ||
// SN didn't receive the payment, so the sender must have failed | ||
senderWallet.failed = true |
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.
Tbh, I am surprised this works to mark the wallet as failed in the array above. Apparently array.find()
returns a reference if it's not a primitive value.
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.
yeah, well technically speaking Javascript is natively oop and objects are always passed as reference, like in most oop languages that don't have pointers, copying the memory every time would be pretty bad.
components/use-paid-mutation.js
Outdated
await invoiceHelper.cancel(invoice) | ||
console.log('old invoice canceled') | ||
} catch (err) { | ||
console.error('could not cancel old invoice', err) |
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.
You're awaiting cancelInvoice
but you're catching all errors here so you're actually never sure if the invoice was canceled.
You do check in the backend if the invoice was canceled so refreshInvoice
will throw but it looks weird to me to let the backend tell you that the cancel failed on your next request when you can already "handle" that case here. Handle in quotation marks since I think we cannot recover from a failed cancel.
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 not sure, won't this throw if the invoice was cancelled twice or is expired too?
IMO it is better to not lock out the user from retrying an action just because we can't cancel an invoice, since the action could still be in the failing state for other reasons.
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.
Canceling the previous invoice is pretty important since we don't want them to accidentally pay us twice or lose money. This code doesn't run exclusively for p2p zaps where we wouldn't be able to settle incoming invoices ourselves without the receiver settling first; this runs for every paid action. Our node would absolutely settle an old invoice even though they already paid for the action if the payment wasn't stolen on route already if it's the same invoice.
so the problem is exactly as you noticed:
Ok, i understand now this is reintroducing #1558 in the qr.js code.
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.
So, this is an issue for optimistic actions, that can fail after the invoice is settled, but not for pessimistic actions that use hodl invoice and set the FAILED state on settling, is this correct?
I don't think it is a deadlock, but the action (the lnd node?) being slow. EDIT: At a second glance, only the actionState change needs the rollback, so i've moved this code inside the smaller transactions in |
Ok, i understand now this is reintroducing #1558 in the qr.js code. We don't know where this invoice is coming from in the qr logic, so how do we ask for another one? |
Why do we not know where the invoice is coming from? Yes, the client can't tell but the backend can, that's why you need the same refresh logic.
Okay for now but I don't understand the argument about "to keep this code flexible". What is flexible about this code? The problem is that your retry code is not flexible enough to easily apply it here. |
I am very confused. If this QR code is used in use-paid-mutation, what is the point of trying to pay with attached wallets if this is shown only after all the attached wallets fail?
My argument is that i was under the impression that this code was to use attached wallets to pay non paidActions invoices (eg. a withdraw or something), and we would need a retry mechanism that is specific for what we want to pay. |
I am saying that qr.js contains code to pay with an attached wallet and thus it probably makes sense to also have fallbacks here for a consistent experience. I am not saying that we should use the code in qr.js in use-paid-mutation but the other way around. Maybe we don't need paying from attached wallets for QR codes anymore if a QR code is indeed only shown if all wallets already failed in all cases, but I would need to check myself first. All I saw is that there was a loop that would pay the same invoice again. |
From what i can tell, the QR code wallet payment is used only in |
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