@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 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.
@@ -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
@@ -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
- async record(r, day) {
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);
@@ -8,5 +8,6 @@ export declare function memoryStore(used?: Set<string>): ReturnStore & {
8
8
  returns: Map<string, ReturnRecord>;
9
9
  used: Set<string>;
10
10
  days: Map<string, number>;
11
+ senders: Map<string, number>;
11
12
  expireLock: () => void;
12
13
  };
@@ -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
- status: "pending" | "returned";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/late-returns",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Send Nano (XNO) payments that nothing claimed back to their sender, exactly once.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
- async record(r, day): Promise<RecordResult> {
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);
@@ -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
- status: "pending" | "returned";
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
- record(r: { hash: string; amount: string; to: string; by: string }, day: { key: string; max: number }): Promise<RecordResult>;
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. */