Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion .msggen.json
Original file line number Diff line number Diff line change
Expand Up @@ -3181,7 +3181,8 @@
"ListOffers.offers[].label": 6,
"ListOffers.offers[].offer_id": 1,
"ListOffers.offers[].single_use": 3,
"ListOffers.offers[].used": 5
"ListOffers.offers[].used": 5,
"ListOffers.offers[].used_count": 9
},
"ListoffersRequest": {
"ListOffers.active_only": 2,
Expand Down Expand Up @@ -11483,6 +11484,10 @@
"added": "pre-v0.10.1",
"deprecated": null
},
"ListOffers.offers[].used_count": {
"added": "pre-v0.10.1",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pre-v0.10.1 is reserved for fields that existed before versioning was introduced. This is a new field, so it needs the correct release tag. There's no time to include it in v26.06, so let's target the next one: v26.09.

"deprecated": null
},
"ListPays": {
"added": "pre-v0.10.1",
"deprecated": null
Expand Down
1 change: 1 addition & 0 deletions cln-grpc/proto/node.proto

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cln-grpc/src/convert.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cln-rpc/src/model.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 14 additions & 4 deletions contrib/msggen/msggen/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -24461,6 +24461,12 @@
"True if an associated invoice has been paid."
]
},
"used_count": {
"type": "u64",
"description": [
"The number of times this offer has been paid."
]
},
"label": {
"type": "string",
"description": [
Expand Down Expand Up @@ -24499,23 +24505,26 @@
"single_use": false,
"bolt12": "lno1qgsq000bolt210002100021000210002100021000210002100021000210002100021000210002100021000210002100021000210002100021000210002100021000",
"description": "Fish sale!",
"used": false
"used": false,
"used_count": 1
},
{
"offer_id": "offeridl22000002200000220000022000002200000220000022000002200000",
"active": true,
"single_use": false,
"bolt12": "lno1qgsq000bolt220002200022000220002200022000220002200022000220002200022000220002200022000220002200022000220002200022000220002200022000",
"description": "Coffee",
"used": false
"used": false,
"used_count": 1
},
{
"offer_id": "offeridl23000002300000230000023000002300000230000023000002300000",
"active": true,
"single_use": false,
"bolt12": "lno1qgsq000bolt230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000",
"description": "Movie ticket",
"used": false
"used": false,
"used_count": 1
}
]
}
Expand All @@ -24535,7 +24544,8 @@
"active": true,
"single_use": false,
"bolt12": "lno1qgsq000bolt230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000",
"used": false
"used": false,
"used_count": 1
}
]
}
Expand Down
1,592 changes: 796 additions & 796 deletions contrib/pyln-grpc-proto/pyln/grpc/node_pb2.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions contrib/pyln-testing/pyln/testing/grpc2py.py
Original file line number Diff line number Diff line change
Expand Up @@ -1698,6 +1698,7 @@ def listoffers_offers2py(m):
"offer_id": hexlify(m.offer_id), # PrimitiveField in generate_composite
"single_use": m.single_use, # PrimitiveField in generate_composite
"used": m.used, # PrimitiveField in generate_composite
"used_count": m.used_count, # PrimitiveField in generate_composite
})


Expand Down
18 changes: 14 additions & 4 deletions doc/schemas/listoffers.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@
"True if an associated invoice has been paid."
]
},
"used_count": {
"type": "u64",
"description": [
"The number of times this offer has been paid."
]
},
"label": {
"type": "string",
"description": [
Expand Down Expand Up @@ -129,23 +135,26 @@
"single_use": false,
"bolt12": "lno1qgsq000bolt210002100021000210002100021000210002100021000210002100021000210002100021000210002100021000210002100021000210002100021000",
"description": "Fish sale!",
"used": false
"used": false,
"used_count": 1
Comment on lines +138 to +139
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four examples show "used": false alongside "used_count": 1.
For a "single_use": false offer that has been paid once, used remaining false is technically correct, but pairing it with "used_count": 1 is confusing to readers.
Either use "used_count": 0 for unused offers, or add a dedicated example that clearly shows a paid offer with both fields set correctly.

},
{
"offer_id": "offeridl22000002200000220000022000002200000220000022000002200000",
"active": true,
"single_use": false,
"bolt12": "lno1qgsq000bolt220002200022000220002200022000220002200022000220002200022000220002200022000220002200022000220002200022000220002200022000",
"description": "Coffee",
"used": false
"used": false,
"used_count": 1
},
{
"offer_id": "offeridl23000002300000230000023000002300000230000023000002300000",
"active": true,
"single_use": false,
"bolt12": "lno1qgsq000bolt230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000",
"description": "Movie ticket",
"used": false
"used": false,
"used_count": 1
}
]
}
Expand All @@ -165,7 +174,8 @@
"active": true,
"single_use": false,
"bolt12": "lno1qgsq000bolt230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000230002300023000",
"used": false
"used": false,
"used_count": 1
}
]
}
Expand Down
4 changes: 4 additions & 0 deletions lightningd/offer.c
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ static struct command_result *json_listoffers(struct command *cmd,
json_populate_offer(response,
offer_id, b12,
label, status, force_paths);
u64 used_count = wallet_offer_count_payments(wallet, offer_id);
json_add_u64(response, "used_count", used_count);
description = offer_description_from_b12(tmpctx, cmd->ld, b12);
if (description)
json_add_stringn(response, "description", description, tal_bytelen(description));
Expand All @@ -198,6 +200,8 @@ static struct command_result *json_listoffers(struct command *cmd,
json_populate_offer(response,
&id, b12,
label, status, force_paths);
u64 used_count = wallet_offer_count_payments(wallet, offer_id);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're passing offer_id instead of &id here. Since we're inside the else block (meaning offer_id was not provided), this would pass NULL to a function annotated with NON_NULL_ARGS(1,2), so every offer in the list-all path would get a count of 0 (or worse).
Should be &id.

json_add_u64(response, "used_count", used_count);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation here is inconsistent with a mix of spaces and tabs. Please align it with the surrounding code (tabs only).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're adding this field between json_populate_offer() output and description. Consider moving it after description so the JSON output is consistent between versions.

description = offer_description_from_b12(tmpctx, cmd->ld, b12);
if (description)
json_add_stringn(response, "description", description, tal_bytelen(description));
Expand Down
36 changes: 36 additions & 0 deletions tests/test_pay.py
Original file line number Diff line number Diff line change
Expand Up @@ -7329,3 +7329,39 @@ def test_createproof_include(node_factory, bitcoind):
# Unknown name is an error.
with pytest.raises(RpcError, match=r'Unknown field name'):
l1.rpc.call('createproof', {'invstring': inv, 'include': ['no_such_field']})

def test_offer_used_count(node_factory, bitcoind):
"""Verify that the used_count counter increments correctly for BOLT12 offers"""
l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True,
opts=[{},
{'dev-allow-localhost': None},
{'dev-allow-localhost': None}])

# Create a BOLT12 offer on the recipient node (l3)
offer = l3.rpc.offer('5000msat', 'used_count test')
offer_id = offer['offer_id']

# Verify that the initial usage counter on l3 is equal to 0
offers = l3.rpc.listoffers(offer_id=offer_id)['offers']
assert len(offers) == 1
assert 'used_count' in offers[0], "Error: used_count field is missing from listoffers response!"
assert offers[0]['used_count'] == 0
Comment on lines +7347 to +7348
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's drop the custom message string. The in check is redundant since the == 0 assertion on the next line would already produce a KeyError if the key were missing. Plain assert is the project convention here.

Suggested change
assert 'used_count' in offers[0], "Error: used_count field is missing from listoffers response!"
assert offers[0]['used_count'] == 0
assert offers[0]['used_count'] == 0


# The sender node (l1) requests an invoice based on l3's offer
inv = l1.rpc.fetchinvoice(offer['bolt12'])['invoice']

# Pay the invoice from l1 using xpay
l1.rpc.xpay(inv)

# Verify that l3's database counter increments to 1 after successful settlement
offers = l3.rpc.listoffers(offer_id=offer_id)['offers']
assert offers[0]['used_count'] == 1

# Perform a second payment to ensure the aggregate COUNT(*) functions properly
inv2 = l1.rpc.fetchinvoice(offer['bolt12'])['invoice']
l1.rpc.xpay(inv2)

# Verify that the counter scales up to 2
offers = l3.rpc.listoffers(offer_id=offer_id)['offers']
assert offers[0]['used_count'] == 2

22 changes: 22 additions & 0 deletions wallet/wallet.c
Original file line number Diff line number Diff line change
Expand Up @@ -6057,6 +6057,28 @@ char *wallet_offer_find(const tal_t *ctx,
return bolt12;
}

u64 wallet_offer_count_payments(struct wallet *w,
const struct sha256 *offer_id)
{
struct db_stmt *stmt;
u64 count = 0;

stmt = db_prepare_v2(w->db, SQL("SELECT COUNT(*)"
" FROM invoices"
" WHERE local_offer_id = ?"
" AND state = 1;"));
Comment on lines +6066 to +6069
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This executes one SELECT COUNT(*) per offer, which means a node with 1000 offers will fire 1001 queries on every listoffers call. Consider computing all counts in a single query with GROUP BY local_offer_id, then looking up each offer's count from the result.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace: make check-source will catch this. Please remove the space after ));..


db_bind_sha256(stmt, offer_id);
Comment on lines +6069 to +6071
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded 1 is fragile and doesn't self-document intent. We have a enum constant for PAID.

Suggested change
" AND state = 1;"));
db_bind_sha256(stmt, offer_id);
" AND state = ?;"));
db_bind_sha256(stmt, offer_id);
db_bind_int(stmt, invoice_status_in_db(PAID));

db_query_prepared(stmt);

if (db_step(stmt)) {
count = db_col_u64(stmt, "COUNT(*)");
}

tal_free(stmt);
return count;
}

struct db_stmt *wallet_offer_id_first(struct wallet *w, struct sha256 *offer_id)
{
struct db_stmt *stmt;
Expand Down
14 changes: 14 additions & 0 deletions wallet/wallet.h
Original file line number Diff line number Diff line change
Expand Up @@ -1504,6 +1504,20 @@ char *wallet_offer_find(const tal_t *ctx,
bool *force_paths)
NON_NULL_ARGS(1,2,3);

/**
* Count the number of successful payments for a given offer.
* @w: the wallet
* @offer_id: the merkle root of the offer to check (must be unique)
*
* Scans the database invoices table to find all settled (paid) invoices
* that are associated with the specified @offer_id.
*
* Returns the total count of completed payments as a 64-bit unsigned integer.
*/
u64 wallet_offer_count_payments(struct wallet *w,
const struct sha256 *offer_id);
NON_NULL_ARGS(1,2);
Comment on lines +1517 to +1519
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should remove the ; between the function declaration and NON_NULL_ARGS.

Suggested change
u64 wallet_offer_count_payments(struct wallet *w,
const struct sha256 *offer_id);
NON_NULL_ARGS(1,2);
u64 wallet_offer_count_payments(struct wallet *w,
const struct sha256 *offer_id)
NON_NULL_ARGS(1,2);


/**
* Iterate through all the offers.
* @w: the wallet
Expand Down