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

backend payment optimism #1195

Merged
merged 129 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from 112 commits
Commits
Show all changes
129 commits
Select commit Hold shift + click to select a range
ae07513
wip backend optimism
huumn May 27, 2024
ed611e5
another inch
huumn May 28, 2024
40fc7f0
make action state transitions only happen once
huumn May 28, 2024
7e4bddf
another inch
huumn May 29, 2024
73987b0
almost ready for testing
huumn May 29, 2024
6964e08
use interactive txs
huumn May 30, 2024
778b0f4
another inch
huumn May 30, 2024
3e912cd
ready for basic testing
huumn May 31, 2024
d12507d
Merge branch 'master' into backend-optimism
huumn May 31, 2024
619c552
lint fix
huumn May 31, 2024
5254ed0
inches
huumn Jun 2, 2024
75e2cc8
wip item update
huumn Jun 3, 2024
978a5a0
merge master
huumn Jun 4, 2024
f670352
Merge branch 'master' into backend-optimism
huumn Jun 4, 2024
ac75b95
get item update to work
huumn Jun 4, 2024
493f44d
donate and downzap
huumn Jun 4, 2024
6d8eef0
Merge branch 'master' into backend-optimism
huumn Jun 4, 2024
87b9f03
inchy inch
huumn Jun 4, 2024
50cedc9
fix territory paid actions
huumn Jun 5, 2024
4fe3ed9
wip usePaidMutation
huumn Jun 5, 2024
23fb818
usePaidMutation error handling
huumn Jun 6, 2024
90606f2
PENDING_HELD and HELD transitions, gql paidAction return types
huumn Jun 7, 2024
5e7cb7c
mostly working pessimism
huumn Jun 7, 2024
7ff4e09
make sure invoice field is present in optimisticResponse
huumn Jun 7, 2024
926e43d
inches
huumn Jun 7, 2024
5f75d7a
show optimistic values to current me
huumn Jun 10, 2024
f89c74c
first pass at notifications and payment status reporting
huumn Jun 11, 2024
5bdb1c7
fix migration to have withdrawal hash
huumn Jun 11, 2024
9375c69
reverse optimism on payment failure
huumn Jun 12, 2024
43ee682
merge master
huumn Jun 12, 2024
3028d8c
Revert "Optimistic updates via pending sats in item context (#1229)"
huumn Jun 12, 2024
c130d62
Merge branch 'master' into backend-optimism
huumn Jun 12, 2024
f1704a6
add onCompleted to usePaidMutation
huumn Jun 12, 2024
c53c693
onPaid and onPayError for new comments
huumn Jun 12, 2024
3f9cecd
use 'IS DISTINCT FROM' for NULL invoiceActionState columns
huumn Jun 13, 2024
1f2501e
make usePaidMutation easier to read
huumn Jun 13, 2024
e0cd476
Merge branch 'master' into backend-optimism
huumn Jun 13, 2024
9b8734b
enhance invoice qr
huumn Jun 13, 2024
2f71db6
prevent actions on unpaid items
huumn Jun 13, 2024
8644eaa
allow navigation to action's invoice
huumn Jun 13, 2024
6ec0521
Merge branch 'master' into backend-optimism
huumn Jun 15, 2024
21ce1a2
retry create item
huumn Jun 16, 2024
1d52de6
start edit window after item is paid for
huumn Jun 16, 2024
35aa0d1
fix ux of retries from notifications
huumn Jun 17, 2024
3b56a3b
refine retries
huumn Jun 17, 2024
4961240
fix optimistic downzaps
huumn Jun 18, 2024
49dabf0
remember item updates can't be retried
huumn Jun 18, 2024
02b4723
store reference to action item in invoice
huumn Jun 18, 2024
d793f34
remove invoice modal layout shift
huumn Jun 18, 2024
c0e708c
fix destructuring
huumn Jun 18, 2024
47879f0
fix zap undos
huumn Jun 18, 2024
5da773d
make sure ItemAct is paid in aggregate queries
huumn Jun 18, 2024
f4bd252
dont toast on long press zap undo
huumn Jun 18, 2024
febcdc2
fix delete and remindme bots
huumn Jun 18, 2024
89c43a1
optimistic poll votes with retries
huumn Jun 19, 2024
09e093f
fix retry notifications and invoice item context
huumn Jun 19, 2024
54dc89d
fix pessimisitic typo
huumn Jun 19, 2024
e4e8791
item mentions and mention notifications
huumn Jun 19, 2024
c3de5b2
dont show payment retry on item popover
huumn Jun 19, 2024
5015a47
make bios work
huumn Jun 19, 2024
a4eca92
refactor paidAction transitions
huumn Jun 19, 2024
d4d4171
remove stray console.log
huumn Jun 19, 2024
cac416d
restore docker compose nwc settings
huumn Jun 20, 2024
09df8ed
add new todos
huumn Jun 20, 2024
25a5094
persist qr modal on post submission + unify item form submission
huumn Jun 20, 2024
48fe05b
fix post edit threshold
huumn Jun 20, 2024
5fc60a5
make bounty payments work
huumn Jun 20, 2024
05c8592
make job posting work
huumn Jun 21, 2024
8096ead
remove more store procedure usage ... document serialization concerns
huumn Jun 22, 2024
b051d54
dont use dynamic imports for paid action modules
huumn Jun 22, 2024
431964f
inline comment denormalization
huumn Jun 22, 2024
9a289bf
create item starts with median votes
huumn Jun 23, 2024
77cd1a3
fix potential of serialization anomalies in zaps
huumn Jun 23, 2024
c71dba5
Merge branch 'master' into backend-optimism
huumn Jun 23, 2024
2742a9a
dont trigger notification indicator on successful paid action invoices
huumn Jun 23, 2024
cf6ec84
ignore invoiceId on territory actions and add optimistic concurrency …
huumn Jun 24, 2024
c85ccd8
begin docs for paid actions
huumn Jun 24, 2024
f4401f9
better error toasts and fix apollo cache warnings
huumn Jun 24, 2024
7140494
small documentation enhancements
huumn Jun 24, 2024
2efab00
improve paid action docs
huumn Jun 25, 2024
fbf45b0
optimistic concurrency control for territory updates
huumn Jun 25, 2024
9a33e16
use satsToMsats and msatsToSats helpers
huumn Jun 25, 2024
6ae357b
explictly type raw query template parameters
huumn Jun 25, 2024
05a019b
improve consistency of nested relation names
huumn Jun 25, 2024
315c707
complete paid action docs
huumn Jun 25, 2024
0945121
Merge branch 'master' into backend-optimism
huumn Jun 25, 2024
8e8d82b
Merge branch 'master' into backend-optimism
huumn Jun 25, 2024
bc9638d
useEffect for canEdit on payment
huumn Jun 25, 2024
ef45185
make sure invoiceId is provided when required
huumn Jun 25, 2024
6b0a85c
don't return null when expecting array
huumn Jun 25, 2024
bfeec9c
remove buy credits
huumn Jun 25, 2024
91268e7
move verifyPayment to paidAction
huumn Jun 26, 2024
8b80cc4
fix comments invoicePaidAt time zone
huumn Jun 26, 2024
e18e9ba
close nwc connections once
huumn Jun 26, 2024
b117faf
grouped logs for paid actions
huumn Jun 26, 2024
a41c710
stop invoiceWaitUntilPaid if not attempting to pay
huumn Jun 26, 2024
6cf03e5
allow actionState to transition directly from HELD to PAID
huumn Jun 26, 2024
56b4ad2
make paid mutation wait until pessimistic are fully paid
huumn Jun 26, 2024
2b934ec
change button text when form submits/pays
huumn Jun 26, 2024
ad1705c
pulsing form submit button
huumn Jun 26, 2024
ffc304c
ignore me in notification indicator for territory subscription
huumn Jun 26, 2024
48fc06d
filter unpaid items from more queries
huumn Jun 27, 2024
d286cca
fix donation stike timing
huumn Jun 27, 2024
8bd7f08
fix pending poll vote
huumn Jun 27, 2024
30ef7b9
fix recent item notifcation padding
huumn Jun 27, 2024
f1d6065
no default form submitting button text
huumn Jun 27, 2024
e79807e
don't show paying on submit button on free edits
huumn Jun 27, 2024
5f690a8
fix territory autorenew with fee credits
huumn Jun 27, 2024
eb31dd9
reorg readme
huumn Jun 27, 2024
4123117
allow jobs to be editted forever
huumn Jun 27, 2024
7eedec7
fix image uploads
huumn Jun 27, 2024
252892d
more filter fixes for aggregate views
huumn Jun 27, 2024
e92aa0f
finalize paid action invoice expirations
huumn Jun 28, 2024
940fc6a
remove unnecessary async
huumn Jun 28, 2024
e6e9182
keep clientside cache normal/consistent
huumn Jun 28, 2024
4574c93
add more detail to paid action doc
huumn Jun 28, 2024
9ecb96c
improve paid action table
huumn Jun 28, 2024
37bc830
Merge branch 'master' into backend-optimism
huumn Jun 29, 2024
03ffdb9
remove actionType guard
huumn Jun 29, 2024
986f146
fix top territories
huumn Jun 29, 2024
2f9aad3
Merge branch 'master' into backend-optimism
huumn Jun 30, 2024
9f32529
typo api/paidAction/README.md
huumn Jun 30, 2024
8cd856c
typo components/use-paid-mutation.js
huumn Jun 30, 2024
22a968f
Apply suggestions from code review
huumn Jun 30, 2024
44cd419
encorporate ek feeback
huumn Jun 30, 2024
a66e27a
more ek suggestions
huumn Jun 30, 2024
fa84a3f
fix 'cost to post' hover on items
huumn Jun 30, 2024
01dad5f
Apply suggestions from code review
huumn Jul 1, 2024
e0daaec
fix paid item updates
huumn Jul 1, 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
165 changes: 165 additions & 0 deletions api/paidAction/README.md
huumn marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Paid Actions

Paid actions are actions that require payments to perform. Given that we support several payment flows, some of which require more than one round of communication either with LND or the client, and several paid actions, we have this plugin-like interface to easily add new paid actions.

## Payment Flows

There are three payment flows:

### Fee credits
The stacker has enough fee credits to pay for the action. This is the simplest flow and is similar to a normal request.

### Optimistic
For paid actions that support it, if the stacker doesn't have enough fee credits, we store the action in a `PENDING` state on the server, which is visible only to the stacker, then return a payment request to the client. The client then pays the invoice however and whenever they wish, and the server monitors payment progress. If the payment succeeds, the action is executed fully becoming visible to everyone. Otherwise, the client is notified the payment failed and the payment can be retried.

### Pessimistic
For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without storing the action. After the client pays the invoice, the client resends the action with proof of payment and action is executed fully. Pessimistic actions require the client to wait for the payment to complete before being visible to them and everyone else.

## Paid Action Interface

Each paid action is implemented in its own file in the `paidActions` directory. Each file exports a module with the following properties:
huumn marked this conversation as resolved.
Show resolved Hide resolved

### Boolean flags
- `anonable`: can be performed anonymously
- `supportsPessimism`: supports a pessimistic payment flow
- `supportsOptimism`: supports an optimistic payment flow

#### Functions

All functions have the following signature: `function(args: Object, context: Object): Promise`

- `getCost`: returns the cost of the action in msats as a `BigInt`
- `perform`: performs the action
- returns: an object with the result of the action as defined in the `graphql` schema
- if the action supports optimism and an `invoiceId` is provided, the action should be performed optimistically
- any action data that needs to be hidden while it's pending, should store in its rows a `PENDING` state along with its `invoiceId`
- it can optionally store in the invoice with the `invoiceId` the `actionId` to be able to link the action with the invoice regardless of retries
- `onPaid`: called when the action is paid
- if the action does not support optimism, this function is optional
- this function should be used to mark the rows created in `perform` as `PAID` and perform any other side effects of the action (like notifications or denormalizations)
- `onFail`: called when the action fails
- if the action does not support optimism, this function is optional
- this function should be used to mark the rows create in `perform` as `FAILED`
huumn marked this conversation as resolved.
Show resolved Hide resolved
- `retry`: called when the action is retried with any new invoice information
- return: an object with the result of the action as defined in the `graphql` schema (same as `perform`)
- this function is called when an optimistic action is retried
- it's passed the original `invoiceId` and the `newInvoiceId`
- this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING`
- `describe`: returns a description as a string of the action
- for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description

#### Function arguments

`args` contains the arguments for the action as defined in the `graphql` schema. If the action is optimistic or pessimistic, `args` will contain an `invoiceId` field which can be stored alongside the paid action's data. If this is a call to `retry`, `args` will contain the original `invoiceId` and `newInvoiceId` fields.

`context` contains the following fields:
- `user`: the user performing the action (null if anonymous)
- `cost`: the cost of the action in msats as a `BigInt`
- `tx`: the current transaction (for anything that needs to be done atomically with the payment)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client

## `IMPORTANT: transaction isolation`

We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies).

### This is a big deal
1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that.
2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction:
- independent statements
- `WITH` queries (CTEs) in the same statement
- subqueries in the same statement

### How to handle it
1. take row level locks on the rows you read, using something like a `SELECT ... FOR UPDATE` statement
- NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read.
- read about row level locks available in postgres: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
2. check that the data you read is still valid before writing it back to the database i.e. optimistic concurrency control
- NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read.
3. avoid having to read data from one row to modify the data of another row all together

### Example
huumn marked this conversation as resolved.
Show resolved Hide resolved

Let's say you are aggregating total sats for an item from a table `zaps` and updating the total sats for that item in another table `item_zaps`. Two 100 sat zaps are requested for the same item at the same time in two concurrent transactions. The total sats for the item should be 200, but because of the way `read committed` works, the following statements lead to a total sats of 100:

*the statements here are listed in the order they are executed, but each transaction is happening concurrently*

#### Incorrect

```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1;
-- total_sats is 100
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1;
-- total_sats is still 100, because transaction 1 hasn't committed yet
-- transaction 1
UPDATE item_zaps SET sats = total_sats WHERE item_id = 1;
-- sets sats to 100
-- transaction 2
UPDATE item_zaps SET sats = total_sats WHERE item_id = 1;
-- sets sats to 100
COMMIT;
-- transaction 1
COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200
```

Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions doesn't know to exist yet.

#### Subqueries are still incorrect

```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1;
-- item_zaps.sats is 100
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1;
-- item_zaps.sats is still 100, because transaction 1 hasn't committed yet
-- transaction 1
COMMIT;
-- transaction 2
COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200
```

Note that while the `UPDATE` transaction 2's update statement will block until transaction 1 commits, the subquery is computed before it blocks and is not re-evaluated after the block.

#### Correct

```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
-- transaction 1
UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1;
-- transaction 2
UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1;
COMMIT;
-- transaction 1
COMMIT;
-- item_zaps.sats is 200
```

The above works because `UPDATE` takes a lock on the rows it's updating, so transaction 2 will block until transaction 1 commits, and once transaction 2 is unblocked, it will re-evaluate the `sats` value of the row it's updating.

#### More resources
- https://stackoverflow.com/questions/61781595/postgres-read-commited-doesnt-re-read-updated-row?noredirect=1#comment109279507_61781595
- https://www.cybertec-postgresql.com/en/transaction-anomalies-with-select-for-update/

From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED):
> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client.

From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350):
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.
26 changes: 26 additions & 0 deletions api/paidAction/buyCredits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// XXX we don't use this yet ...
// it's just showing that even buying credits
// can eventually be a paid action

import { USER_ID } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'

export const anonable = false
export const supportsPessimism = false
export const supportsOptimism = true
huumn marked this conversation as resolved.
Show resolved Hide resolved

export async function getCost ({ amount }) {
return satsToMsats(amount)
}

export async function onPaid ({ invoice }, { tx }) {
return await tx.users.update({
where: { id: invoice.userId },
data: { balance: { increment: invoice.msatsReceived } }
huumn marked this conversation as resolved.
Show resolved Hide resolved
})
}

export async function describe ({ amount }, { models, me }) {
const user = await models.user.findUnique({ where: { id: me?.id ?? USER_ID.anon } })
return `SN: buying credits for @${user.name}`
}
25 changes: 25 additions & 0 deletions api/paidAction/donate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { USER_ID } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'

export const anonable = true
export const supportsPessimism = true
export const supportsOptimism = false

export async function getCost ({ sats }) {
return satsToMsats(sats)
}

export async function perform ({ sats }, { me, tx }) {
await tx.donation.create({
data: {
sats,
userId: me?.id || USER_ID.anon
huumn marked this conversation as resolved.
Show resolved Hide resolved
}
})

return { sats }
}

export async function describe (args, context) {
return 'SN: donate to rewards pool'
}
79 changes: 79 additions & 0 deletions api/paidAction/downZap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { msatsToSats, satsToMsats } from '@/lib/format'

export const anonable = false
export const supportsPessimism = false
export const supportsOptimism = true

export async function getCost ({ sats }) {
return satsToMsats(sats)
}

export async function perform ({ invoiceId, sats, id: itemId }, { me, cost, tx }) {
itemId = parseInt(itemId)

let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}

const itemAct = await tx.itemAct.create({
data: { msats: cost, itemId, userId: me.id, act: 'DONT_LIKE_THIS', ...invoiceData }
})

const [{ path }] = await tx.$queryRaw`SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'DONT_LIKE_THIS', path, actId: itemAct.id }
}
huumn marked this conversation as resolved.
Show resolved Hide resolved

export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const [{ id, path }] = await tx.$queryRaw`
SELECT "Item".id, ltree2text(path) as path
FROM "Item"
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
return { id, sats: msatsToSats(cost), act: 'DONT_LIKE_THIS', path }
}

export async function onPaid ({ invoice, actId }, { tx }) {
let itemAct
if (invoice) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } })
} else if (actId) {
itemAct = await tx.itemAct.findUnique({ where: { id: actId } })
} else {
throw new Error('No invoice or actId')
}

const msats = BigInt(itemAct.msats)
const sats = msatsToSats(msats)

// denormalize downzaps
await tx.$executeRaw`
WITH zapper AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}
huumn marked this conversation as resolved.
Show resolved Hide resolved
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
)
UPDATE "Item"
SET "weightedDownVotes" = "weightedDownVotes" + (zapper.trust * zap.log_sats)
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
}

export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}

export async function describe ({ itemId, sats }, { cost, actionId }) {
return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}
Loading
Loading