@nano133/late-returns 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +27 -0
- package/ai/SPEC.md +6 -0
- package/dist/firestoreStore.d.ts +5 -0
- package/dist/firestoreStore.js +30 -2
- package/dist/index.d.ts +17 -0
- package/dist/index.js +25 -1
- package/dist/memoryStore.d.ts +1 -0
- package/dist/memoryStore.js +22 -1
- package/dist/types.d.ts +28 -3
- package/package.json +1 -1
- package/src/firestoreStore.ts +27 -2
- package/src/index.ts +35 -2
- package/src/memoryStore.ts +20 -2
- package/src/types.ts +26 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- `perSenderPerDay` (off by default): at most that many returns per sender per
|
|
6
|
+
UTC day. Later payments from that sender that day are recorded as "kept"
|
|
7
|
+
(reason "limit"), marked used, and never sent; they don't count toward
|
|
8
|
+
`perDay`. For a site that doesn't want a flood of payments from one address
|
|
9
|
+
to cost it a return each.
|
|
10
|
+
- `releaseKept({ rpc, store }, hash, by)`: sends a kept payment back after all
|
|
11
|
+
(once, with the next run). The sender is read again from your node.
|
|
12
|
+
- `ReturnStore`: `record()` takes an optional `SenderLimit`, a store says
|
|
13
|
+
`senderLimit: true` and has `release()`. `memoryStore` and `firestoreStore`
|
|
14
|
+
have both; a store of your own without them works as before, unless you turn
|
|
15
|
+
`perSenderPerDay` on (then the run stops with a clear error).
|
|
16
|
+
- `firestoreStore`: a sender's daily counter is `sender-<day>-<hash>` in `days`
|
|
17
|
+
(or `meta`): no address in it, and a `deleteAt` for a TTL policy.
|
|
18
|
+
|
|
3
19
|
## 0.2.0
|
|
4
20
|
|
|
5
21
|
- `firestoreStore`: `mark` names every collection that gets a return's "used"
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ to its sender:
|
|
|
30
30
|
| Never a payment an open checkout can still claim | your `isClaimable()` |
|
|
31
31
|
| Never a payment from these senders | `never` (your own wallets, an admin's top-ups) |
|
|
32
32
|
| At most | 3 new returns per run (`perRun`), 30 per day (`perDay`) |
|
|
33
|
+
| At most, per sender | no limit (`perSenderPerDay`, off by default) |
|
|
33
34
|
|
|
34
35
|
Each return is recorded together with a "used" mark for the payment, so a
|
|
35
36
|
late claim can't also take it. The wallet then receives exactly that payment
|
|
@@ -87,6 +88,32 @@ await runLateReturns({
|
|
|
87
88
|
Your claim code must refuse a payment whose hash is already in a `used`
|
|
88
89
|
collection; then a payment that went back can never also pay a checkout.
|
|
89
90
|
|
|
91
|
+
### A limit per sender
|
|
92
|
+
|
|
93
|
+
Someone can flood your wallet with small wrong payments, and each one costs
|
|
94
|
+
you a return (two blocks). Set `perSenderPerDay` to stop that:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
await runLateReturns({ /* … */, perSenderPerDay: 10 });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A sender's first 10 late payments in a UTC day go back. From the 11th that
|
|
101
|
+
day, a payment is **kept**: its record has `status: "kept"` and
|
|
102
|
+
`reason: "limit"`, it is marked used (no checkout can claim it), and it is
|
|
103
|
+
never sent. Kept payments don't count toward `perDay`, so one sender can't
|
|
104
|
+
use up the day's returns for everyone else. Say so in your terms.
|
|
105
|
+
|
|
106
|
+
To send a kept payment back after all (a real customer who made mistakes):
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { releaseKept } from "@nano133/late-returns";
|
|
110
|
+
await releaseKept({ rpc, store }, blockHash, "admin@example.com"); // "released" | "not-kept" | "missing"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
It goes back with the next run, exactly once. `firestoreStore` keeps each
|
|
114
|
+
sender's daily count in a document `sender-<day>-<hash of the address>` with a
|
|
115
|
+
`deleteAt` date: add a TTL policy on `deleteAt` for that collection.
|
|
116
|
+
|
|
90
117
|
### Your own database
|
|
91
118
|
|
|
92
119
|
Implement `ReturnStore` (see `src/types.ts`). The one hard rule: `record()`
|
package/ai/SPEC.md
CHANGED
|
@@ -86,6 +86,12 @@ Pick one:
|
|
|
86
86
|
- `record()` is one transaction: unless a return exists for the hash, the
|
|
87
87
|
payment is used, or today's count reached the limit, create the return
|
|
88
88
|
(status `pending`), mark the payment used, add one to today's count.
|
|
89
|
+
- Optional (only for `perSenderPerDay`): `record()` takes a third argument
|
|
90
|
+
`{ key, max }`. When the sender's count for `key` is already `max`, create
|
|
91
|
+
the return with status `kept` (reason `limit`) and mark the payment used,
|
|
92
|
+
without counting the day; otherwise count the sender too. Set
|
|
93
|
+
`senderLimit: true` and add `release(hash, to, by)`, which turns a `kept`
|
|
94
|
+
return into `pending` in one transaction.
|
|
89
95
|
- `lock(ttl)` is one row/document `{ owner, until }`. `take()` succeeds only
|
|
90
96
|
when it is free or expired. Every `update()` runs in a transaction that
|
|
91
97
|
first checks `owner` and `until > now`, and throws `LockLost` otherwise.
|
package/dist/firestoreStore.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { type ReturnStore } from "./types.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* A store in Firestore (firebase-admin).
|
|
5
5
|
*
|
|
6
|
+
* With perSenderPerDay, each sender's daily count is a document
|
|
7
|
+
* "sender-<day>-<hash of the address>" in `days` (or `meta`), with a
|
|
8
|
+
* `deleteAt` two days after its day: add a TTL policy on `deleteAt` for that
|
|
9
|
+
* collection to remove old counters.
|
|
10
|
+
*
|
|
6
11
|
* - `returns`: the collection for return records, one document per payment hash.
|
|
7
12
|
* - `used`: the collections where your checkouts record a claimed payment
|
|
8
13
|
* (one document per payment hash). A payment with a document in any of them
|
package/dist/firestoreStore.js
CHANGED
|
@@ -3,6 +3,11 @@ import { LockLost } from "./types.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* A store in Firestore (firebase-admin).
|
|
5
5
|
*
|
|
6
|
+
* With perSenderPerDay, each sender's daily count is a document
|
|
7
|
+
* "sender-<day>-<hash of the address>" in `days` (or `meta`), with a
|
|
8
|
+
* `deleteAt` two days after its day: add a TTL policy on `deleteAt` for that
|
|
9
|
+
* collection to remove old counters.
|
|
10
|
+
*
|
|
6
11
|
* - `returns`: the collection for return records, one document per payment hash.
|
|
7
12
|
* - `used`: the collections where your checkouts record a claimed payment
|
|
8
13
|
* (one document per payment hash). A payment with a document in any of them
|
|
@@ -30,16 +35,26 @@ export function firestoreStore(db, opts) {
|
|
|
30
35
|
const snaps = await Promise.all(used.map((c) => c.doc(H).get()));
|
|
31
36
|
return snaps.some((s) => s.exists);
|
|
32
37
|
},
|
|
33
|
-
|
|
38
|
+
senderLimit: true,
|
|
39
|
+
async record(r, day, sender) {
|
|
34
40
|
const H = r.hash.toUpperCase();
|
|
35
41
|
const ref = returns.doc(H);
|
|
36
42
|
const dayRef = days.doc(`returns-${day.key}`);
|
|
43
|
+
const senderRef = sender ? days.doc(`sender-${sender.key}`) : null;
|
|
37
44
|
return db.runTransaction(async (tx) => {
|
|
38
|
-
const [ret, d, ...marks] = await Promise.all([tx.get(ref), tx.get(dayRef), ...used.map((c) => tx.get(c.doc(H)))]);
|
|
45
|
+
const [ret, d, s, ...marks] = await Promise.all([tx.get(ref), tx.get(dayRef), senderRef ? tx.get(senderRef) : Promise.resolve(null), ...used.map((c) => tx.get(c.doc(H)))]);
|
|
39
46
|
if (ret.exists)
|
|
40
47
|
return "exists";
|
|
41
48
|
if (marks.some((m) => m.exists))
|
|
42
49
|
return "used";
|
|
50
|
+
const sent = s?.data()?.n ?? 0;
|
|
51
|
+
if (sender && sent >= sender.max) {
|
|
52
|
+
// Over the sender's limit: kept (marked used so no checkout claims it), not sent, not counted in the day.
|
|
53
|
+
for (const c of mark)
|
|
54
|
+
tx.set(c.doc(H), { return: H, at: Date.now() });
|
|
55
|
+
tx.set(ref, { ...r, hash: H, status: "kept", reason: "limit", day: day.key, createdAt: Date.now() });
|
|
56
|
+
return "kept";
|
|
57
|
+
}
|
|
43
58
|
const n = d.data()?.n ?? 0;
|
|
44
59
|
if (n >= day.max)
|
|
45
60
|
return "limit";
|
|
@@ -47,9 +62,22 @@ export function firestoreStore(db, opts) {
|
|
|
47
62
|
tx.set(c.doc(H), { return: H, at: Date.now() });
|
|
48
63
|
tx.set(ref, { ...r, hash: H, status: "pending", createdAt: Date.now() });
|
|
49
64
|
tx.set(dayRef, { n: n + 1 });
|
|
65
|
+
// A sender's counter holds no address; deleteAt lets a Firestore TTL policy remove it after the day.
|
|
66
|
+
if (senderRef)
|
|
67
|
+
tx.set(senderRef, { n: sent + 1, deleteAt: new Date(Date.parse(`${day.key}T00:00:00Z`) + 2 * 86_400_000) });
|
|
50
68
|
return "created";
|
|
51
69
|
});
|
|
52
70
|
},
|
|
71
|
+
release: (hash, to, by) => db.runTransaction(async (tx) => {
|
|
72
|
+
const ref = returns.doc(hash.toUpperCase());
|
|
73
|
+
const r = (await tx.get(ref)).data();
|
|
74
|
+
if (!r)
|
|
75
|
+
return "missing";
|
|
76
|
+
if (r.status !== "kept")
|
|
77
|
+
return "not-kept";
|
|
78
|
+
tx.update(ref, { status: "pending", to, releasedBy: by, releasedAt: Date.now() });
|
|
79
|
+
return "released";
|
|
80
|
+
}),
|
|
53
81
|
async get(hash) {
|
|
54
82
|
const d = await returns.doc(hash.toUpperCase()).get();
|
|
55
83
|
return d.exists ? d.data() : null;
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,14 @@ export type LateReturnOptions = {
|
|
|
28
28
|
perRun?: number;
|
|
29
29
|
/** New returns per day (UTC). Default 30. */
|
|
30
30
|
perDay?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Returns per sender per day (UTC). Off by default (no limit). When set, a
|
|
33
|
+
* sender's later payments that day are kept: recorded with status "kept"
|
|
34
|
+
* and reason "limit", marked used, never sent unless you call releaseKept().
|
|
35
|
+
* Kept payments don't count toward `perDay`. Needs a store with
|
|
36
|
+
* `senderLimit` (memoryStore and firestoreStore have it).
|
|
37
|
+
*/
|
|
38
|
+
perSenderPerDay?: number;
|
|
31
39
|
/**
|
|
32
40
|
* Also look at the wallet's recent receives, not only its waiting payments.
|
|
33
41
|
* Turn on if something else receives into this wallet (a payout run does).
|
|
@@ -46,6 +54,15 @@ export type LateReturnOptions = {
|
|
|
46
54
|
};
|
|
47
55
|
/** Finds late payments and records their returns (it sends nothing). Returns how many it recorded. */
|
|
48
56
|
export declare function findLateReturns(o: LateReturnOptions): Promise<number>;
|
|
57
|
+
/** A sender's counter for a day: the day and a hash of the address (the address itself is not stored in it). */
|
|
58
|
+
export declare const senderKey: (day: string, address: string) => string;
|
|
59
|
+
/**
|
|
60
|
+
* Releases a kept payment: it goes back to its sender with the next run
|
|
61
|
+
* (exactly once, like any return). The sender is read again from your node
|
|
62
|
+
* (block_info), so this works even if your site replaced the stored address.
|
|
63
|
+
* `by` records who released it. A payment that isn't kept is left alone.
|
|
64
|
+
*/
|
|
65
|
+
export declare function releaseKept(o: Pick<LateReturnOptions, "rpc" | "store">, hash: string, by: string): Promise<"released" | "not-kept" | "missing">;
|
|
49
66
|
/** Sends the pending returns back, each exactly once. Returns how many it sent. Needs the lock. */
|
|
50
67
|
export declare function settleReturns(o: LateReturnOptions, lock: ReturnType<ReturnStore["lock"]>): Promise<number>;
|
|
51
68
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { nanoPrefix } from "./address.js";
|
|
2
3
|
import { sendOnce } from "./sendOnce.js";
|
|
3
4
|
import { LockLost } from "./types.js";
|
|
@@ -36,6 +37,9 @@ export async function findLateReturns(o) {
|
|
|
36
37
|
const d = defaults(o);
|
|
37
38
|
const never = new Set([o.signer.address, ...(o.never ?? [])].map(nanoPrefix));
|
|
38
39
|
const day = { key: new Date(d.now).toISOString().slice(0, 10), max: d.perDay };
|
|
40
|
+
const perSender = o.perSenderPerDay;
|
|
41
|
+
if (perSender !== undefined && !o.store.senderLimit)
|
|
42
|
+
throw new Error("perSenderPerDay needs a store with senderLimit (memoryStore and firestoreStore have it)");
|
|
39
43
|
let made = 0;
|
|
40
44
|
for (const [hash, amount] of await candidates(o, d.minRaw)) {
|
|
41
45
|
if (made >= d.perRun)
|
|
@@ -55,7 +59,7 @@ export async function findLateReturns(o) {
|
|
|
55
59
|
continue;
|
|
56
60
|
if (await o.isClaimable({ hash, amount, from, seenAt }))
|
|
57
61
|
continue;
|
|
58
|
-
const res = await o.store.record({ hash, amount, to: from, by: "auto" }, day);
|
|
62
|
+
const res = await o.store.record({ hash, amount, to: from, by: "auto" }, day, perSender === undefined ? undefined : { key: senderKey(day.key, from), max: perSender });
|
|
59
63
|
if (res === "limit")
|
|
60
64
|
break;
|
|
61
65
|
if (res === "created")
|
|
@@ -63,6 +67,26 @@ export async function findLateReturns(o) {
|
|
|
63
67
|
}
|
|
64
68
|
return made;
|
|
65
69
|
}
|
|
70
|
+
/** A sender's counter for a day: the day and a hash of the address (the address itself is not stored in it). */
|
|
71
|
+
export const senderKey = (day, address) => `${day}-${createHash("sha256").update(nanoPrefix(address)).digest("hex").slice(0, 32)}`;
|
|
72
|
+
/**
|
|
73
|
+
* Releases a kept payment: it goes back to its sender with the next run
|
|
74
|
+
* (exactly once, like any return). The sender is read again from your node
|
|
75
|
+
* (block_info), so this works even if your site replaced the stored address.
|
|
76
|
+
* `by` records who released it. A payment that isn't kept is left alone.
|
|
77
|
+
*/
|
|
78
|
+
export async function releaseKept(o, hash, by) {
|
|
79
|
+
if (!o.store.release)
|
|
80
|
+
throw new Error("this store can't release kept payments (it has no release())");
|
|
81
|
+
const H = hash.toUpperCase();
|
|
82
|
+
const r = await o.store.get(H);
|
|
83
|
+
if (!r)
|
|
84
|
+
return "missing";
|
|
85
|
+
if (r.status !== "kept")
|
|
86
|
+
return "not-kept";
|
|
87
|
+
const info = await o.rpc({ action: "block_info", hash: H, json_block: "true" });
|
|
88
|
+
return o.store.release(H, nanoPrefix(info.block_account), by);
|
|
89
|
+
}
|
|
66
90
|
/** Sends the pending returns back, each exactly once. Returns how many it sent. Needs the lock. */
|
|
67
91
|
export async function settleReturns(o, lock) {
|
|
68
92
|
const d = defaults(o);
|
package/dist/memoryStore.d.ts
CHANGED
package/dist/memoryStore.js
CHANGED
|
@@ -8,11 +8,15 @@ import { LockLost } from "./types.js";
|
|
|
8
8
|
export function memoryStore(used = new Set()) {
|
|
9
9
|
const returns = new Map();
|
|
10
10
|
const days = new Map();
|
|
11
|
+
/** Returns per sender per day (key: senderKey()). */
|
|
12
|
+
const senders = new Map();
|
|
11
13
|
let held = null;
|
|
12
14
|
const store = {
|
|
13
15
|
returns,
|
|
14
16
|
used,
|
|
15
17
|
days,
|
|
18
|
+
senders,
|
|
19
|
+
senderLimit: true,
|
|
16
20
|
/** Tests only: makes the current lock expire now. */
|
|
17
21
|
expireLock: () => {
|
|
18
22
|
if (held)
|
|
@@ -21,20 +25,37 @@ export function memoryStore(used = new Set()) {
|
|
|
21
25
|
async isUsed(hash) {
|
|
22
26
|
return used.has(hash.toUpperCase());
|
|
23
27
|
},
|
|
24
|
-
async record(r, day) {
|
|
28
|
+
async record(r, day, sender) {
|
|
25
29
|
const hash = r.hash.toUpperCase();
|
|
26
30
|
if (returns.has(hash))
|
|
27
31
|
return "exists";
|
|
28
32
|
if (used.has(hash))
|
|
29
33
|
return "used";
|
|
34
|
+
const s = sender ? (senders.get(sender.key) ?? 0) : 0;
|
|
35
|
+
if (sender && s >= sender.max) {
|
|
36
|
+
used.add(hash);
|
|
37
|
+
returns.set(hash, { ...r, hash, status: "kept", reason: "limit", day: day.key, createdAt: Date.now() });
|
|
38
|
+
return "kept";
|
|
39
|
+
}
|
|
30
40
|
const n = days.get(day.key) ?? 0;
|
|
31
41
|
if (n >= day.max)
|
|
32
42
|
return "limit";
|
|
33
43
|
used.add(hash);
|
|
34
44
|
returns.set(hash, { ...r, hash, status: "pending", createdAt: Date.now() });
|
|
35
45
|
days.set(day.key, n + 1);
|
|
46
|
+
if (sender)
|
|
47
|
+
senders.set(sender.key, s + 1);
|
|
36
48
|
return "created";
|
|
37
49
|
},
|
|
50
|
+
async release(hash, to, by) {
|
|
51
|
+
const r = returns.get(hash.toUpperCase());
|
|
52
|
+
if (!r)
|
|
53
|
+
return "missing";
|
|
54
|
+
if (r.status !== "kept")
|
|
55
|
+
return "not-kept";
|
|
56
|
+
Object.assign(r, { status: "pending", to, releasedBy: by, releasedAt: Date.now() });
|
|
57
|
+
return "released";
|
|
58
|
+
},
|
|
38
59
|
async get(hash) {
|
|
39
60
|
const r = returns.get(hash.toUpperCase());
|
|
40
61
|
return r ? structuredClone(r) : null;
|
package/dist/types.d.ts
CHANGED
|
@@ -57,10 +57,17 @@ export type ReturnRecord = {
|
|
|
57
57
|
amount: string;
|
|
58
58
|
/** Where it goes back to: the payment's sender. */
|
|
59
59
|
to: string;
|
|
60
|
-
|
|
60
|
+
/** "kept": over the sender's daily limit (perSenderPerDay); it waits, unsent, until releaseKept(). */
|
|
61
|
+
status: "pending" | "returned" | "kept";
|
|
61
62
|
/** "auto" for the scan, or who asked (an admin). */
|
|
62
63
|
by: string;
|
|
63
64
|
createdAt: number;
|
|
65
|
+
/** Why a payment was kept ("limit"), and the UTC day it counted on. */
|
|
66
|
+
reason?: string | null;
|
|
67
|
+
day?: string | null;
|
|
68
|
+
/** Who released a kept payment, and when. */
|
|
69
|
+
releasedBy?: string | null;
|
|
70
|
+
releasedAt?: number | null;
|
|
64
71
|
/** The receive of this payment, once the wallet has it ("earlier" when an earlier run received it). */
|
|
65
72
|
recv?: string | null;
|
|
66
73
|
send?: SendIntent | null;
|
|
@@ -68,7 +75,12 @@ export type ReturnRecord = {
|
|
|
68
75
|
error?: string | null;
|
|
69
76
|
tries?: number;
|
|
70
77
|
};
|
|
71
|
-
export type RecordResult = "created" | "exists" | "used" | "limit";
|
|
78
|
+
export type RecordResult = "created" | "exists" | "used" | "limit" | "kept";
|
|
79
|
+
/** A sender's daily limit for record(): `key` names the sender and the day (it holds no address), `max` the returns it may have. */
|
|
80
|
+
export type SenderLimit = {
|
|
81
|
+
key: string;
|
|
82
|
+
max: number;
|
|
83
|
+
};
|
|
72
84
|
/** The wallet's lock: one run at a time. Every money write proves it still holds the lock. */
|
|
73
85
|
export interface Lock {
|
|
74
86
|
/** Takes the lock, or returns false when another run holds it. */
|
|
@@ -88,6 +100,11 @@ export interface ReturnStore {
|
|
|
88
100
|
* In one atomic step: unless a return exists for the hash, the payment is
|
|
89
101
|
* used, or today's count reached `day.max`, create the pending return, mark
|
|
90
102
|
* the payment used (so no checkout can claim it later) and count it.
|
|
103
|
+
*
|
|
104
|
+
* With `sender` (only when the store has `senderLimit`): a payment whose
|
|
105
|
+
* sender already has `sender.max` returns today is recorded as "kept"
|
|
106
|
+
* instead (marked used, not sent, not counted in `day`), and the result is
|
|
107
|
+
* "kept". Otherwise the sender's count goes up with the day's.
|
|
91
108
|
*/
|
|
92
109
|
record(r: {
|
|
93
110
|
hash: string;
|
|
@@ -97,7 +114,15 @@ export interface ReturnStore {
|
|
|
97
114
|
}, day: {
|
|
98
115
|
key: string;
|
|
99
116
|
max: number;
|
|
100
|
-
}): Promise<RecordResult>;
|
|
117
|
+
}, sender?: SenderLimit): Promise<RecordResult>;
|
|
118
|
+
/** True when record() takes a SenderLimit, and release() exists (perSenderPerDay needs both). */
|
|
119
|
+
senderLimit?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* A kept payment goes back after all: in one atomic step, a "kept" record
|
|
122
|
+
* becomes "pending" (sent by the next run), going to `to`. Anything else is
|
|
123
|
+
* left alone.
|
|
124
|
+
*/
|
|
125
|
+
release?(hash: string, to: string, by: string): Promise<"released" | "not-kept" | "missing">;
|
|
101
126
|
get(hash: string): Promise<ReturnRecord | null>;
|
|
102
127
|
pending(limit: number): Promise<ReturnRecord[]>;
|
|
103
128
|
/** A lock for one run, held for `ttlMs` unless renewed. */
|
package/package.json
CHANGED
package/src/firestoreStore.ts
CHANGED
|
@@ -5,6 +5,11 @@ import { LockLost, type Lock, type RecordResult, type ReturnRecord, type ReturnS
|
|
|
5
5
|
/**
|
|
6
6
|
* A store in Firestore (firebase-admin).
|
|
7
7
|
*
|
|
8
|
+
* With perSenderPerDay, each sender's daily count is a document
|
|
9
|
+
* "sender-<day>-<hash of the address>" in `days` (or `meta`), with a
|
|
10
|
+
* `deleteAt` two days after its day: add a TTL policy on `deleteAt` for that
|
|
11
|
+
* collection to remove old counters.
|
|
12
|
+
*
|
|
8
13
|
* - `returns`: the collection for return records, one document per payment hash.
|
|
9
14
|
* - `used`: the collections where your checkouts record a claimed payment
|
|
10
15
|
* (one document per payment hash). A payment with a document in any of them
|
|
@@ -32,22 +37,42 @@ export function firestoreStore(db: Firestore, opts: { returns: string; used: str
|
|
|
32
37
|
const snaps = await Promise.all(used.map((c) => c.doc(H).get()));
|
|
33
38
|
return snaps.some((s) => s.exists);
|
|
34
39
|
},
|
|
35
|
-
|
|
40
|
+
senderLimit: true,
|
|
41
|
+
async record(r, day, sender): Promise<RecordResult> {
|
|
36
42
|
const H = r.hash.toUpperCase();
|
|
37
43
|
const ref = returns.doc(H);
|
|
38
44
|
const dayRef = days.doc(`returns-${day.key}`);
|
|
45
|
+
const senderRef = sender ? days.doc(`sender-${sender.key}`) : null;
|
|
39
46
|
return db.runTransaction(async (tx) => {
|
|
40
|
-
const [ret, d, ...marks] = await Promise.all([tx.get(ref), tx.get(dayRef), ...used.map((c) => tx.get(c.doc(H)))]);
|
|
47
|
+
const [ret, d, s, ...marks] = await Promise.all([tx.get(ref), tx.get(dayRef), senderRef ? tx.get(senderRef) : Promise.resolve(null), ...used.map((c) => tx.get(c.doc(H)))]);
|
|
41
48
|
if (ret.exists) return "exists";
|
|
42
49
|
if (marks.some((m) => m.exists)) return "used";
|
|
50
|
+
const sent = (s?.data()?.n as number | undefined) ?? 0;
|
|
51
|
+
if (sender && sent >= sender.max) {
|
|
52
|
+
// Over the sender's limit: kept (marked used so no checkout claims it), not sent, not counted in the day.
|
|
53
|
+
for (const c of mark) tx.set(c.doc(H), { return: H, at: Date.now() });
|
|
54
|
+
tx.set(ref, { ...r, hash: H, status: "kept", reason: "limit", day: day.key, createdAt: Date.now() } satisfies ReturnRecord);
|
|
55
|
+
return "kept";
|
|
56
|
+
}
|
|
43
57
|
const n = (d.data()?.n as number | undefined) ?? 0;
|
|
44
58
|
if (n >= day.max) return "limit";
|
|
45
59
|
for (const c of mark) tx.set(c.doc(H), { return: H, at: Date.now() });
|
|
46
60
|
tx.set(ref, { ...r, hash: H, status: "pending", createdAt: Date.now() } satisfies ReturnRecord);
|
|
47
61
|
tx.set(dayRef, { n: n + 1 });
|
|
62
|
+
// A sender's counter holds no address; deleteAt lets a Firestore TTL policy remove it after the day.
|
|
63
|
+
if (senderRef) tx.set(senderRef, { n: sent + 1, deleteAt: new Date(Date.parse(`${day.key}T00:00:00Z`) + 2 * 86_400_000) });
|
|
48
64
|
return "created";
|
|
49
65
|
});
|
|
50
66
|
},
|
|
67
|
+
release: (hash, to, by) =>
|
|
68
|
+
db.runTransaction(async (tx) => {
|
|
69
|
+
const ref = returns.doc(hash.toUpperCase());
|
|
70
|
+
const r = (await tx.get(ref)).data() as ReturnRecord | undefined;
|
|
71
|
+
if (!r) return "missing";
|
|
72
|
+
if (r.status !== "kept") return "not-kept";
|
|
73
|
+
tx.update(ref, { status: "pending", to, releasedBy: by, releasedAt: Date.now() });
|
|
74
|
+
return "released";
|
|
75
|
+
}),
|
|
51
76
|
async get(hash) {
|
|
52
77
|
const d = await returns.doc(hash.toUpperCase()).get();
|
|
53
78
|
return d.exists ? (d.data() as ReturnRecord) : null;
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { nanoPrefix } from "./address.js";
|
|
2
3
|
import { sendOnce } from "./sendOnce.js";
|
|
3
4
|
import { LockLost, type BlockInfo, type ReturnStore, type Rpc, type Signer } from "./types.js";
|
|
@@ -20,7 +21,10 @@ export { publicKeyOf } from "./address.js";
|
|
|
20
21
|
// checkouts can still claim the amount (isClaimable);
|
|
21
22
|
// - never one sent by an address in `never` (your own wallets, an admin who
|
|
22
23
|
// tops the wallet up);
|
|
23
|
-
// - at most `perRun` new returns per run and `perDay` per day
|
|
24
|
+
// - at most `perRun` new returns per run and `perDay` per day;
|
|
25
|
+
// - with `perSenderPerDay` (off by default): at most that many returns per
|
|
26
|
+
// sender per day. Later payments from that sender that day are kept: recorded
|
|
27
|
+
// as "kept", marked used, not sent, until you release one (releaseKept).
|
|
24
28
|
//
|
|
25
29
|
// Each return is recorded together with a "used" mark for the payment, so a
|
|
26
30
|
// late claim can't also take it. The wallet then receives exactly that
|
|
@@ -47,6 +51,14 @@ export type LateReturnOptions = {
|
|
|
47
51
|
perRun?: number;
|
|
48
52
|
/** New returns per day (UTC). Default 30. */
|
|
49
53
|
perDay?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Returns per sender per day (UTC). Off by default (no limit). When set, a
|
|
56
|
+
* sender's later payments that day are kept: recorded with status "kept"
|
|
57
|
+
* and reason "limit", marked used, never sent unless you call releaseKept().
|
|
58
|
+
* Kept payments don't count toward `perDay`. Needs a store with
|
|
59
|
+
* `senderLimit` (memoryStore and firestoreStore have it).
|
|
60
|
+
*/
|
|
61
|
+
perSenderPerDay?: number;
|
|
50
62
|
/**
|
|
51
63
|
* Also look at the wallet's recent receives, not only its waiting payments.
|
|
52
64
|
* Turn on if something else receives into this wallet (a payout run does).
|
|
@@ -100,6 +112,8 @@ export async function findLateReturns(o: LateReturnOptions): Promise<number> {
|
|
|
100
112
|
const d = defaults(o);
|
|
101
113
|
const never = new Set([o.signer.address, ...(o.never ?? [])].map(nanoPrefix));
|
|
102
114
|
const day = { key: new Date(d.now).toISOString().slice(0, 10), max: d.perDay };
|
|
115
|
+
const perSender = o.perSenderPerDay;
|
|
116
|
+
if (perSender !== undefined && !o.store.senderLimit) throw new Error("perSenderPerDay needs a store with senderLimit (memoryStore and firestoreStore have it)");
|
|
103
117
|
let made = 0;
|
|
104
118
|
for (const [hash, amount] of await candidates(o, d.minRaw)) {
|
|
105
119
|
if (made >= d.perRun) break;
|
|
@@ -112,13 +126,32 @@ export async function findLateReturns(o: LateReturnOptions): Promise<number> {
|
|
|
112
126
|
const seenAt = Number(info.local_timestamp ?? 0) * 1000;
|
|
113
127
|
if (!seenAt || d.now - seenAt < d.minAgeSec * 1000) continue;
|
|
114
128
|
if (await o.isClaimable({ hash, amount, from, seenAt })) continue;
|
|
115
|
-
const res = await o.store.record({ hash, amount, to: from, by: "auto" }, day);
|
|
129
|
+
const res = await o.store.record({ hash, amount, to: from, by: "auto" }, day, perSender === undefined ? undefined : { key: senderKey(day.key, from), max: perSender });
|
|
116
130
|
if (res === "limit") break;
|
|
117
131
|
if (res === "created") made++;
|
|
118
132
|
}
|
|
119
133
|
return made;
|
|
120
134
|
}
|
|
121
135
|
|
|
136
|
+
/** A sender's counter for a day: the day and a hash of the address (the address itself is not stored in it). */
|
|
137
|
+
export const senderKey = (day: string, address: string) => `${day}-${createHash("sha256").update(nanoPrefix(address)).digest("hex").slice(0, 32)}`;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Releases a kept payment: it goes back to its sender with the next run
|
|
141
|
+
* (exactly once, like any return). The sender is read again from your node
|
|
142
|
+
* (block_info), so this works even if your site replaced the stored address.
|
|
143
|
+
* `by` records who released it. A payment that isn't kept is left alone.
|
|
144
|
+
*/
|
|
145
|
+
export async function releaseKept(o: Pick<LateReturnOptions, "rpc" | "store">, hash: string, by: string): Promise<"released" | "not-kept" | "missing"> {
|
|
146
|
+
if (!o.store.release) throw new Error("this store can't release kept payments (it has no release())");
|
|
147
|
+
const H = hash.toUpperCase();
|
|
148
|
+
const r = await o.store.get(H);
|
|
149
|
+
if (!r) return "missing";
|
|
150
|
+
if (r.status !== "kept") return "not-kept";
|
|
151
|
+
const info = await o.rpc<BlockInfo>({ action: "block_info", hash: H, json_block: "true" });
|
|
152
|
+
return o.store.release(H, nanoPrefix(info.block_account), by);
|
|
153
|
+
}
|
|
154
|
+
|
|
122
155
|
/** Sends the pending returns back, each exactly once. Returns how many it sent. Needs the lock. */
|
|
123
156
|
export async function settleReturns(o: LateReturnOptions, lock: ReturnType<ReturnStore["lock"]>): Promise<number> {
|
|
124
157
|
const d = defaults(o);
|
package/src/memoryStore.ts
CHANGED
|
@@ -9,12 +9,16 @@ import { LockLost, type Lock, type RecordResult, type ReturnRecord, type ReturnS
|
|
|
9
9
|
export function memoryStore(used: Set<string> = new Set()) {
|
|
10
10
|
const returns = new Map<string, ReturnRecord>();
|
|
11
11
|
const days = new Map<string, number>();
|
|
12
|
+
/** Returns per sender per day (key: senderKey()). */
|
|
13
|
+
const senders = new Map<string, number>();
|
|
12
14
|
let held: { owner: string; until: number } | null = null;
|
|
13
15
|
|
|
14
|
-
const store: ReturnStore & { returns: Map<string, ReturnRecord>; used: Set<string>; days: Map<string, number>; expireLock: () => void } = {
|
|
16
|
+
const store: ReturnStore & { returns: Map<string, ReturnRecord>; used: Set<string>; days: Map<string, number>; senders: Map<string, number>; expireLock: () => void } = {
|
|
15
17
|
returns,
|
|
16
18
|
used,
|
|
17
19
|
days,
|
|
20
|
+
senders,
|
|
21
|
+
senderLimit: true,
|
|
18
22
|
/** Tests only: makes the current lock expire now. */
|
|
19
23
|
expireLock: () => {
|
|
20
24
|
if (held) held.until = 0;
|
|
@@ -22,17 +26,31 @@ export function memoryStore(used: Set<string> = new Set()) {
|
|
|
22
26
|
async isUsed(hash) {
|
|
23
27
|
return used.has(hash.toUpperCase());
|
|
24
28
|
},
|
|
25
|
-
async record(r, day): Promise<RecordResult> {
|
|
29
|
+
async record(r, day, sender): Promise<RecordResult> {
|
|
26
30
|
const hash = r.hash.toUpperCase();
|
|
27
31
|
if (returns.has(hash)) return "exists";
|
|
28
32
|
if (used.has(hash)) return "used";
|
|
33
|
+
const s = sender ? (senders.get(sender.key) ?? 0) : 0;
|
|
34
|
+
if (sender && s >= sender.max) {
|
|
35
|
+
used.add(hash);
|
|
36
|
+
returns.set(hash, { ...r, hash, status: "kept", reason: "limit", day: day.key, createdAt: Date.now() });
|
|
37
|
+
return "kept";
|
|
38
|
+
}
|
|
29
39
|
const n = days.get(day.key) ?? 0;
|
|
30
40
|
if (n >= day.max) return "limit";
|
|
31
41
|
used.add(hash);
|
|
32
42
|
returns.set(hash, { ...r, hash, status: "pending", createdAt: Date.now() });
|
|
33
43
|
days.set(day.key, n + 1);
|
|
44
|
+
if (sender) senders.set(sender.key, s + 1);
|
|
34
45
|
return "created";
|
|
35
46
|
},
|
|
47
|
+
async release(hash, to, by) {
|
|
48
|
+
const r = returns.get(hash.toUpperCase());
|
|
49
|
+
if (!r) return "missing";
|
|
50
|
+
if (r.status !== "kept") return "not-kept";
|
|
51
|
+
Object.assign(r, { status: "pending", to, releasedBy: by, releasedAt: Date.now() });
|
|
52
|
+
return "released";
|
|
53
|
+
},
|
|
36
54
|
async get(hash) {
|
|
37
55
|
const r = returns.get(hash.toUpperCase());
|
|
38
56
|
return r ? structuredClone(r) : null;
|
package/src/types.ts
CHANGED
|
@@ -37,10 +37,17 @@ export type ReturnRecord = {
|
|
|
37
37
|
amount: string;
|
|
38
38
|
/** Where it goes back to: the payment's sender. */
|
|
39
39
|
to: string;
|
|
40
|
-
|
|
40
|
+
/** "kept": over the sender's daily limit (perSenderPerDay); it waits, unsent, until releaseKept(). */
|
|
41
|
+
status: "pending" | "returned" | "kept";
|
|
41
42
|
/** "auto" for the scan, or who asked (an admin). */
|
|
42
43
|
by: string;
|
|
43
44
|
createdAt: number;
|
|
45
|
+
/** Why a payment was kept ("limit"), and the UTC day it counted on. */
|
|
46
|
+
reason?: string | null;
|
|
47
|
+
day?: string | null;
|
|
48
|
+
/** Who released a kept payment, and when. */
|
|
49
|
+
releasedBy?: string | null;
|
|
50
|
+
releasedAt?: number | null;
|
|
44
51
|
/** The receive of this payment, once the wallet has it ("earlier" when an earlier run received it). */
|
|
45
52
|
recv?: string | null;
|
|
46
53
|
send?: SendIntent | null;
|
|
@@ -49,7 +56,10 @@ export type ReturnRecord = {
|
|
|
49
56
|
tries?: number;
|
|
50
57
|
};
|
|
51
58
|
|
|
52
|
-
export type RecordResult = "created" | "exists" | "used" | "limit";
|
|
59
|
+
export type RecordResult = "created" | "exists" | "used" | "limit" | "kept";
|
|
60
|
+
|
|
61
|
+
/** A sender's daily limit for record(): `key` names the sender and the day (it holds no address), `max` the returns it may have. */
|
|
62
|
+
export type SenderLimit = { key: string; max: number };
|
|
53
63
|
|
|
54
64
|
/** The wallet's lock: one run at a time. Every money write proves it still holds the lock. */
|
|
55
65
|
export interface Lock {
|
|
@@ -71,8 +81,21 @@ export interface ReturnStore {
|
|
|
71
81
|
* In one atomic step: unless a return exists for the hash, the payment is
|
|
72
82
|
* used, or today's count reached `day.max`, create the pending return, mark
|
|
73
83
|
* the payment used (so no checkout can claim it later) and count it.
|
|
84
|
+
*
|
|
85
|
+
* With `sender` (only when the store has `senderLimit`): a payment whose
|
|
86
|
+
* sender already has `sender.max` returns today is recorded as "kept"
|
|
87
|
+
* instead (marked used, not sent, not counted in `day`), and the result is
|
|
88
|
+
* "kept". Otherwise the sender's count goes up with the day's.
|
|
89
|
+
*/
|
|
90
|
+
record(r: { hash: string; amount: string; to: string; by: string }, day: { key: string; max: number }, sender?: SenderLimit): Promise<RecordResult>;
|
|
91
|
+
/** True when record() takes a SenderLimit, and release() exists (perSenderPerDay needs both). */
|
|
92
|
+
senderLimit?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* A kept payment goes back after all: in one atomic step, a "kept" record
|
|
95
|
+
* becomes "pending" (sent by the next run), going to `to`. Anything else is
|
|
96
|
+
* left alone.
|
|
74
97
|
*/
|
|
75
|
-
|
|
98
|
+
release?(hash: string, to: string, by: string): Promise<"released" | "not-kept" | "missing">;
|
|
76
99
|
get(hash: string): Promise<ReturnRecord | null>;
|
|
77
100
|
pending(limit: number): Promise<ReturnRecord[]>;
|
|
78
101
|
/** A lock for one run, held for `ttlMs` unless renewed. */
|