@warda_protocol/vendor 0.1.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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +36 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +64 -0
- package/dist/node.js.map +1 -0
- package/dist/priced.d.ts +46 -0
- package/dist/priced.d.ts.map +1 -0
- package/dist/priced.js +78 -0
- package/dist/priced.js.map +1 -0
- package/dist/quote.d.ts +27 -0
- package/dist/quote.d.ts.map +1 -0
- package/dist/quote.js +56 -0
- package/dist/quote.js.map +1 -0
- package/dist/settle.d.ts +66 -0
- package/dist/settle.d.ts.map +1 -0
- package/dist/settle.js +155 -0
- package/dist/settle.js.map +1 -0
- package/dist/spent.d.ts +54 -0
- package/dist/spent.d.ts.map +1 -0
- package/dist/spent.js +59 -0
- package/dist/spent.js.map +1 -0
- package/dist/verify.d.ts +53 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +31 -0
- package/dist/verify.js.map +1 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artautas Jasinskas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @warda_protocol/vendor
|
|
2
|
+
|
|
3
|
+
Sell an API for Kaspa, over HTTP 402.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { priced } from "@warda_protocol/vendor";
|
|
7
|
+
|
|
8
|
+
export default priced(
|
|
9
|
+
{
|
|
10
|
+
payTo: process.env.PAY_TO!, // the address that must be paid
|
|
11
|
+
sompi: 4_000_000n, // 0.04 KAS, exactly
|
|
12
|
+
network: "mainnet",
|
|
13
|
+
secret: process.env.QUOTE_SECRET!,
|
|
14
|
+
},
|
|
15
|
+
async () => ({ answer: 42 }),
|
|
16
|
+
);
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
No payment header, and it quotes a price. With one, it looks in the UTXO set for
|
|
20
|
+
a coin **at your address, from the transaction the buyer named, for exactly the
|
|
21
|
+
amount you quoted** — and only then calls your function.
|
|
22
|
+
|
|
23
|
+
It never reads the payment header as truth. The header is written by the party
|
|
24
|
+
who benefits from lying, and a fabricated transaction id is as easy to write as
|
|
25
|
+
a real one.
|
|
26
|
+
|
|
27
|
+
## It does not know what a covenant is
|
|
28
|
+
|
|
29
|
+
A [Warda](https://wardaprotocol.com) grant is one kind of buyer. An ordinary
|
|
30
|
+
wallet is another. This cannot tell them apart and does not try: whether a
|
|
31
|
+
payment was *allowed* is a question answered on the buyer's side before the
|
|
32
|
+
transaction existed. A seller only needs to know whether it arrived.
|
|
33
|
+
|
|
34
|
+
## Replays are your decision, and you have to make it
|
|
35
|
+
|
|
36
|
+
A coin at your address stays there. So the same proof verifies every time, and
|
|
37
|
+
a buyer who paid once can call forever. There is no stateless fix — spending
|
|
38
|
+
the coin onward costs a transaction and a fee; binding payment to the request
|
|
39
|
+
body needs both ends to implement it. What a seller needs is a set of
|
|
40
|
+
transaction ids already served:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
priced({ ..., spent: { has: (id) => redis.sismember("paid", id),
|
|
44
|
+
add: (id) => redis.sadd("paid", id) } }, deliver)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The default, `inMemorySpent()`, holds for one process and says so on startup.
|
|
48
|
+
Under any serverless host that is most requests. `replayAllowed()` exists so
|
|
49
|
+
that choosing to allow them is visible in your source rather than looking like
|
|
50
|
+
an oversight.
|
|
51
|
+
|
|
52
|
+
## The status codes are the API
|
|
53
|
+
|
|
54
|
+
A buyer holding a broadcast payment reads them to decide whether to wait, stop,
|
|
55
|
+
or re-present the same proof. That difference is one payment or two.
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `402` no header | here is the price |
|
|
60
|
+
| `402` + `retry` | **re-present the same proof.** Not yet visible on chain |
|
|
61
|
+
| `400` | the proof is wrong, or the amount is. Paying again will not help |
|
|
62
|
+
| `409` | this payment already bought one thing |
|
|
63
|
+
| `503` | the seller cannot see the chain. Same proof again, do not re-pay |
|
|
64
|
+
| `502` | paid, and the goods failed. The txid is in the body |
|
|
65
|
+
| `200` | served, with a receipt naming what was checked and which node said so |
|
|
66
|
+
|
|
67
|
+
## Which node answered
|
|
68
|
+
|
|
69
|
+
Your security here is "the money is visibly in the UTXO set", which makes the
|
|
70
|
+
node answering that part of your security. `rpc` is tried first, always. A
|
|
71
|
+
`resolver` is consulted only if that fails, and when it does the response says
|
|
72
|
+
so in `readFrom` — a node you do not control is then telling you that you were
|
|
73
|
+
paid. That risk is yours, not the buyer's, which is why the fallback exists at
|
|
74
|
+
all and why it is reported rather than hidden.
|
|
75
|
+
|
|
76
|
+
MIT.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sell an API for Kaspa, over HTTP 402.
|
|
3
|
+
*
|
|
4
|
+
* The seller's half of what @warda_protocol/x402 buys. It quotes a price, and
|
|
5
|
+
* then — before serving anything — looks in the UTXO set for a coin at its own
|
|
6
|
+
* address, from the transaction the buyer named, for exactly the amount
|
|
7
|
+
* quoted. It never trusts the payment header, because the header is written by
|
|
8
|
+
* the party who benefits from lying.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here knows what a covenant is. A Warda grant is one kind of buyer;
|
|
11
|
+
* an ordinary wallet is another; this cannot tell them apart and does not try.
|
|
12
|
+
* Whether a payment was ALLOWED is a question the buyer's side answered before
|
|
13
|
+
* the transaction existed. All a seller needs to know is whether it arrived.
|
|
14
|
+
*/
|
|
15
|
+
export { priced, pricedFetch, type PricedOptions, type Deliver } from "./priced.ts";
|
|
16
|
+
export { settle, type SaleTerms, type SettleInput, type SettleResult } from "./settle.ts";
|
|
17
|
+
export { checkPayment, type PaymentClaim, type PaymentCheck } from "./verify.ts";
|
|
18
|
+
export { issueQuote, checkQuote, type QuoteTerms, type QuoteOptions } from "./quote.ts";
|
|
19
|
+
export { inMemorySpent, replayAllowed, type SpentStore } from "./spent.ts";
|
|
20
|
+
export { openNode, type NodeSource, type OpenedNode } from "./node.ts";
|
|
21
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,KAAK,OAAO,EAAE,MAAM,aAAa,CAAC;AACpF,OAAO,EAAE,MAAM,EAAE,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAC1F,OAAO,EAAE,YAAY,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AACxF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAC3E,OAAO,EAAE,QAAQ,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sell an API for Kaspa, over HTTP 402.
|
|
3
|
+
*
|
|
4
|
+
* The seller's half of what @warda_protocol/x402 buys. It quotes a price, and
|
|
5
|
+
* then — before serving anything — looks in the UTXO set for a coin at its own
|
|
6
|
+
* address, from the transaction the buyer named, for exactly the amount
|
|
7
|
+
* quoted. It never trusts the payment header, because the header is written by
|
|
8
|
+
* the party who benefits from lying.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here knows what a covenant is. A Warda grant is one kind of buyer;
|
|
11
|
+
* an ordinary wallet is another; this cannot tell them apart and does not try.
|
|
12
|
+
* Whether a payment was ALLOWED is a question the buyer's side answered before
|
|
13
|
+
* the transaction existed. All a seller needs to know is whether it arrived.
|
|
14
|
+
*/
|
|
15
|
+
export { priced, pricedFetch } from "./priced.js";
|
|
16
|
+
export { settle } from "./settle.js";
|
|
17
|
+
export { checkPayment } from "./verify.js";
|
|
18
|
+
export { issueQuote, checkQuote } from "./quote.js";
|
|
19
|
+
export { inMemorySpent, replayAllowed } from "./spent.js";
|
|
20
|
+
export { openNode } from "./node.js";
|
|
21
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,MAAM,EAAE,WAAW,EAAoC,MAAM,aAAa,CAAC;AACpF,OAAO,EAAE,MAAM,EAAuD,MAAM,aAAa,CAAC;AAC1F,OAAO,EAAE,YAAY,EAAwC,MAAM,aAAa,CAAC;AACjF,OAAO,EAAE,UAAU,EAAE,UAAU,EAAsC,MAAM,YAAY,CAAC;AACxF,OAAO,EAAE,aAAa,EAAE,aAAa,EAAmB,MAAM,YAAY,CAAC;AAC3E,OAAO,EAAE,QAAQ,EAAoC,MAAM,WAAW,CAAC"}
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting a node to ask, and saying which one answered.
|
|
3
|
+
*
|
|
4
|
+
* A seller's entire security here is "the money is visibly in the UTXO set",
|
|
5
|
+
* which makes the node answering that question part of the security. Reading
|
|
6
|
+
* your own is the strong version. Everything else is a trade, and the trade is
|
|
7
|
+
* worth naming rather than hiding: a dishonest node could report a payment
|
|
8
|
+
* that does not exist and you would hand over the goods. That risk is the
|
|
9
|
+
* SELLER's — a buyer loses nothing by it — which is why a fallback is offered
|
|
10
|
+
* at all, and why what it cost you is reported in the response instead of
|
|
11
|
+
* being left for someone to discover.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the fallback exists
|
|
14
|
+
*
|
|
15
|
+
* The vendor this was extracted from pointed WARDA_RPC_JSON at a Cloudflare
|
|
16
|
+
* quick tunnel, which is handed a new random hostname every time it restarts.
|
|
17
|
+
* It restarted. Every paid request after that failed, including one already
|
|
18
|
+
* paid for, and nothing said so. An endpoint that only works while one machine
|
|
19
|
+
* is up is not published.
|
|
20
|
+
*/
|
|
21
|
+
import { NodeClient } from "@warda_protocol/kaspa";
|
|
22
|
+
export interface NodeSource {
|
|
23
|
+
/** Your node's JSON wRPC url. Tried first, always. */
|
|
24
|
+
rpc?: string;
|
|
25
|
+
/** A Kaspa Resolver, used only if `rpc` is absent or unreachable. */
|
|
26
|
+
resolver?: string;
|
|
27
|
+
/** Which chain, as kaspad names it: `mainnet`, `testnet-10`. */
|
|
28
|
+
network: string;
|
|
29
|
+
}
|
|
30
|
+
export interface OpenedNode {
|
|
31
|
+
client: NodeClient;
|
|
32
|
+
/** Sentence for the receipt: which node's word this is. */
|
|
33
|
+
readFrom: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function openNode(source: NodeSource): Promise<OpenedNode>;
|
|
36
|
+
//# sourceMappingURL=node.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,UAAU,EAA6B,MAAM,uBAAuB,CAAC;AAE9E,MAAM,WAAW,UAAU;IACzB,sDAAsD;IACtD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,UAAU,CAAC;IACnB,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CA8CtE"}
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting a node to ask, and saying which one answered.
|
|
3
|
+
*
|
|
4
|
+
* A seller's entire security here is "the money is visibly in the UTXO set",
|
|
5
|
+
* which makes the node answering that question part of the security. Reading
|
|
6
|
+
* your own is the strong version. Everything else is a trade, and the trade is
|
|
7
|
+
* worth naming rather than hiding: a dishonest node could report a payment
|
|
8
|
+
* that does not exist and you would hand over the goods. That risk is the
|
|
9
|
+
* SELLER's — a buyer loses nothing by it — which is why a fallback is offered
|
|
10
|
+
* at all, and why what it cost you is reported in the response instead of
|
|
11
|
+
* being left for someone to discover.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the fallback exists
|
|
14
|
+
*
|
|
15
|
+
* The vendor this was extracted from pointed WARDA_RPC_JSON at a Cloudflare
|
|
16
|
+
* quick tunnel, which is handed a new random hostname every time it restarts.
|
|
17
|
+
* It restarted. Every paid request after that failed, including one already
|
|
18
|
+
* paid for, and nothing said so. An endpoint that only works while one machine
|
|
19
|
+
* is up is not published.
|
|
20
|
+
*/
|
|
21
|
+
import { NodeClient, resolveNode, resolverFrom } from "@warda_protocol/kaspa";
|
|
22
|
+
export async function openNode(source) {
|
|
23
|
+
let firstFailure = null;
|
|
24
|
+
if (source.rpc) {
|
|
25
|
+
try {
|
|
26
|
+
return {
|
|
27
|
+
client: await NodeClient.connect({ url: source.rpc }),
|
|
28
|
+
readFrom: "this vendor's own node",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
firstFailure = e.message;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const resolver = source.resolver ?? resolverFrom({});
|
|
36
|
+
if (resolver) {
|
|
37
|
+
/**
|
|
38
|
+
* `resolveNode` then `open`, not `open` alone.
|
|
39
|
+
*
|
|
40
|
+
* `NodeClient.open` consults a resolver only when NO node is named, and it
|
|
41
|
+
* counts WARDA_RPC_JSON as naming one — correctly, for its own purposes.
|
|
42
|
+
* At this point that variable holds the url that just failed, so calling
|
|
43
|
+
* `open()` here re-dials the dead host and throws the same error twice.
|
|
44
|
+
* The fallback existed, was deployed, and did nothing. Resolving first and
|
|
45
|
+
* passing the url explicitly is what actually gets past a
|
|
46
|
+
* configured-but-unreachable node.
|
|
47
|
+
*/
|
|
48
|
+
const found = await resolveNode({ resolver, networkId: source.network });
|
|
49
|
+
const { client, health } = await NodeClient.open({
|
|
50
|
+
url: found.url,
|
|
51
|
+
networkId: source.network,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
client,
|
|
55
|
+
readFrom: `a public node found by a resolver (kaspad ${health.serverVersion}), because this ` +
|
|
56
|
+
`vendor's own node could not be reached. A node this vendor does not control is ` +
|
|
57
|
+
`answering whether you paid it.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
throw new Error(firstFailure
|
|
61
|
+
? `${firstFailure}\n\nNo resolver was configured, so there was nothing to fall back to.`
|
|
62
|
+
: "no rpc url and no resolver: this vendor cannot read the chain");
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=node.js.map
|
package/dist/node.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.js","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAiB9E,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,MAAkB;IAC/C,IAAI,YAAY,GAAkB,IAAI,CAAC;IAEvC,IAAI,MAAM,CAAC,GAAG,EAAE,CAAC;QACf,IAAI,CAAC;YACH,OAAO;gBACL,MAAM,EAAE,MAAM,UAAU,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC;gBACrD,QAAQ,EAAE,wBAAwB;aACnC,CAAC;QACJ,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,YAAY,GAAI,CAAW,CAAC,OAAO,CAAC;QACtC,CAAC;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,YAAY,CAAC,EAAE,CAAC,CAAC;IACrD,IAAI,QAAQ,EAAE,CAAC;QACb;;;;;;;;;;WAUG;QACH,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;QACzE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YAC/C,GAAG,EAAE,KAAK,CAAC,GAAG;YACd,SAAS,EAAE,MAAM,CAAC,OAAO;SAC1B,CAAC,CAAC;QACH,OAAO;YACL,MAAM;YACN,QAAQ,EACN,6CAA6C,MAAM,CAAC,aAAa,kBAAkB;gBACnF,iFAAiF;gBACjF,gCAAgC;SACnC,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,KAAK,CACb,YAAY;QACV,CAAC,CAAC,GAAG,YAAY,uEAAuE;QACxF,CAAC,CAAC,+DAA+D,CACpE,CAAC;AACJ,CAAC"}
|
package/dist/priced.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ten-line version.
|
|
3
|
+
*
|
|
4
|
+
* `settle` is the whole protocol and takes eight things. Most sellers have one
|
|
5
|
+
* endpoint, one price and one address, and should not have to assemble a state
|
|
6
|
+
* machine to charge for it — so this wraps it into the two shapes people
|
|
7
|
+
* actually deploy: a node:http handler and a Fetch handler.
|
|
8
|
+
*
|
|
9
|
+
* export default priced(
|
|
10
|
+
* { payTo: process.env.PAY_TO!, sompi: 4_000_000n, network: "mainnet",
|
|
11
|
+
* secret: process.env.QUOTE_SECRET! },
|
|
12
|
+
* async () => ({ answer: 42 }),
|
|
13
|
+
* );
|
|
14
|
+
*
|
|
15
|
+
* Everything the wrapper decides for you is a default you can override, except
|
|
16
|
+
* one: it will not start without a quote secret, and it will not invent one.
|
|
17
|
+
* A generated-per-process secret makes every quote unverifiable by the next
|
|
18
|
+
* process, which on a serverless host is most of them — the failure would be
|
|
19
|
+
* intermittent, would look like a buyer problem, and would land after the money
|
|
20
|
+
* was spent.
|
|
21
|
+
*/
|
|
22
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
23
|
+
import { type SpentStore } from "./spent.ts";
|
|
24
|
+
import { type SaleTerms } from "./settle.ts";
|
|
25
|
+
export interface PricedOptions extends Omit<SaleTerms, "resource"> {
|
|
26
|
+
/** Signs quotes. Required, and must be the same across every instance. */
|
|
27
|
+
secret: string;
|
|
28
|
+
/** What is being sold. Defaults to the request path. */
|
|
29
|
+
resource?: string;
|
|
30
|
+
/** How long a quote is good for. Default two minutes. */
|
|
31
|
+
ttlMs?: number;
|
|
32
|
+
/** Your node's JSON wRPC url. Falls back to WARDA_RPC_JSON. */
|
|
33
|
+
rpc?: string;
|
|
34
|
+
/** A Kaspa Resolver, used only when the node above is unreachable. */
|
|
35
|
+
resolver?: string;
|
|
36
|
+
/** Replay protection. Defaults to per-process memory, which warns. */
|
|
37
|
+
spent?: SpentStore;
|
|
38
|
+
}
|
|
39
|
+
export type Deliver = (context: {
|
|
40
|
+
txid: string;
|
|
41
|
+
}) => unknown | Promise<unknown>;
|
|
42
|
+
/** For `node:http`, and for hosts that hand you (req, res) — Vercel included. */
|
|
43
|
+
export declare function priced(options: PricedOptions, deliver: Deliver): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
44
|
+
/** For Fetch-shaped hosts: Workers, Deno, Bun, and Next's route handlers. */
|
|
45
|
+
export declare function pricedFetch(options: PricedOptions, deliver: Deliver): (request: Request) => Promise<Response>;
|
|
46
|
+
//# sourceMappingURL=priced.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"priced.d.ts","sourceRoot":"","sources":["../src/priced.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAC5D,OAAO,EAAU,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAErD,MAAM,WAAW,aAAc,SAAQ,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC;IAChE,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAchF,iFAAiF;AACjF,wBAAgB,MAAM,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,IAG/B,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAAC,IAAI,CAAC,CAwBxF;AAED,6EAA6E;AAC7E,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,IAGpC,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAuBnE"}
|
package/dist/priced.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { openNode } from "./node.js";
|
|
2
|
+
import { inMemorySpent } from "./spent.js";
|
|
3
|
+
import { settle } from "./settle.js";
|
|
4
|
+
function prepare(options) {
|
|
5
|
+
if (!options.secret) {
|
|
6
|
+
throw new Error("@warda_protocol/vendor: `secret` is required and cannot be generated for you. It signs " +
|
|
7
|
+
"quotes, so every instance must use the SAME value — a per-process secret makes each " +
|
|
8
|
+
"quote unverifiable by the next process, which under a serverless host is most of them.");
|
|
9
|
+
}
|
|
10
|
+
const spent = options.spent ?? inMemorySpent();
|
|
11
|
+
return { spent };
|
|
12
|
+
}
|
|
13
|
+
/** For `node:http`, and for hosts that hand you (req, res) — Vercel included. */
|
|
14
|
+
export function priced(options, deliver) {
|
|
15
|
+
const { spent } = prepare(options);
|
|
16
|
+
return async function handler(req, res) {
|
|
17
|
+
const resource = options.resource ?? (req.url ?? "/").split("?")[0];
|
|
18
|
+
const header = req.headers["x-payment"];
|
|
19
|
+
const result = await settle({
|
|
20
|
+
terms: { ...options, resource },
|
|
21
|
+
paymentHeader: header === undefined ? null : String(header),
|
|
22
|
+
quote: { secret: options.secret, ttlMs: options.ttlMs },
|
|
23
|
+
spent,
|
|
24
|
+
openNode: () => openNode({
|
|
25
|
+
rpc: options.rpc ?? process.env.WARDA_RPC_JSON,
|
|
26
|
+
resolver: options.resolver,
|
|
27
|
+
network: options.network,
|
|
28
|
+
}),
|
|
29
|
+
deliver: () => deliver({ txid: decodeTxid(header) }),
|
|
30
|
+
});
|
|
31
|
+
res.writeHead(result.status, {
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
"access-control-allow-origin": "*",
|
|
34
|
+
});
|
|
35
|
+
res.end(JSON.stringify(result.body, null, 2));
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** For Fetch-shaped hosts: Workers, Deno, Bun, and Next's route handlers. */
|
|
39
|
+
export function pricedFetch(options, deliver) {
|
|
40
|
+
const { spent } = prepare(options);
|
|
41
|
+
return async function handler(request) {
|
|
42
|
+
const header = request.headers.get("x-payment");
|
|
43
|
+
const resource = options.resource ?? new URL(request.url).pathname;
|
|
44
|
+
const result = await settle({
|
|
45
|
+
terms: { ...options, resource },
|
|
46
|
+
paymentHeader: header,
|
|
47
|
+
quote: { secret: options.secret, ttlMs: options.ttlMs },
|
|
48
|
+
spent,
|
|
49
|
+
openNode: () => openNode({
|
|
50
|
+
rpc: options.rpc ?? process.env.WARDA_RPC_JSON,
|
|
51
|
+
resolver: options.resolver,
|
|
52
|
+
network: options.network,
|
|
53
|
+
}),
|
|
54
|
+
deliver: () => deliver({ txid: decodeTxid(header) }),
|
|
55
|
+
});
|
|
56
|
+
return new Response(JSON.stringify(result.body, null, 2), {
|
|
57
|
+
status: result.status,
|
|
58
|
+
headers: { "content-type": "application/json", "access-control-allow-origin": "*" },
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/* The txid is handed to `deliver` so a seller can file its own receipt against
|
|
63
|
+
the payment. Decoding it a second time here rather than threading it out of
|
|
64
|
+
settle keeps that function's return shape about the RESPONSE; by the time
|
|
65
|
+
deliver runs the header has already been parsed and verified, so this cannot
|
|
66
|
+
fail in a way that matters. */
|
|
67
|
+
function decodeTxid(header) {
|
|
68
|
+
if (!header)
|
|
69
|
+
return "";
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(Buffer.from(String(header), "base64").toString("utf8"));
|
|
72
|
+
return typeof parsed?.txid === "string" ? parsed.txid : "";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=priced.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"priced.js","sourceRoot":"","sources":["../src/priced.ts"],"names":[],"mappings":"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"}
|
package/dist/quote.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** What a quote commits to. Change any of it and the signature stops matching. */
|
|
2
|
+
export interface QuoteTerms {
|
|
3
|
+
/** The resource being sold — usually the request path. */
|
|
4
|
+
resource: string;
|
|
5
|
+
/** The exact price. `exact` means exact: over- and under-payment both fail. */
|
|
6
|
+
sompi: bigint;
|
|
7
|
+
/** Unix ms after which this quote is refused. */
|
|
8
|
+
expiresAt: number;
|
|
9
|
+
}
|
|
10
|
+
export interface QuoteOptions {
|
|
11
|
+
/** The signing secret. Anyone holding it can mint quotes in your name. */
|
|
12
|
+
secret: string;
|
|
13
|
+
/** How long a quote stays good. Default two minutes. */
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
}
|
|
16
|
+
/** Mint a nonce for these terms: `<expiry>.<mac>`. */
|
|
17
|
+
export declare function issueQuote(terms: QuoteTerms, options: QuoteOptions): string;
|
|
18
|
+
/**
|
|
19
|
+
* Check a nonce against the terms it claims to be for.
|
|
20
|
+
*
|
|
21
|
+
* Returns a reason, or null when it is good. A reason rather than a boolean
|
|
22
|
+
* because the three ways this fails are three different conversations with the
|
|
23
|
+
* buyer: a malformed nonce is a client bug, an expired one means ask again,
|
|
24
|
+
* and a mismatch means the quote came from somewhere else.
|
|
25
|
+
*/
|
|
26
|
+
export declare function checkQuote(nonce: string, terms: Omit<QuoteTerms, "expiresAt">, options: QuoteOptions, now?: number): string | null;
|
|
27
|
+
//# sourceMappingURL=quote.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quote.d.ts","sourceRoot":"","sources":["../src/quote.ts"],"names":[],"mappings":"AAwBA,kFAAkF;AAClF,MAAM,WAAW,UAAU;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAQD,sDAAsD;AACtD,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY,GAAG,MAAM,CAE3E;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,WAAW,CAAC,EACpC,OAAO,EAAE,YAAY,EACrB,GAAG,GAAE,MAAmB,GACvB,MAAM,GAAG,IAAI,CAaf"}
|
package/dist/quote.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A quote you can verify without having remembered issuing it.
|
|
3
|
+
*
|
|
4
|
+
* The obvious implementation keeps the nonce it just handed out in a variable
|
|
5
|
+
* and compares on the way back. That works for one caller on localhost and
|
|
6
|
+
* fails for everything else, in a way worth spelling out because it is the
|
|
7
|
+
* failure this file exists to prevent: two agents overlapping means the second
|
|
8
|
+
* quote overwrites the first, and the first agent's perfectly good payment is
|
|
9
|
+
* then rejected for a nonce mismatch it did nothing to cause — AFTER it has
|
|
10
|
+
* spent the money. On a serverless host it is worse still, because two
|
|
11
|
+
* requests need not share a process at all, so the variable is empty for
|
|
12
|
+
* almost everyone.
|
|
13
|
+
*
|
|
14
|
+
* An HMAC over the fields being quoted, plus an expiry, needs no memory: the
|
|
15
|
+
* server can tell its own quote from a made-up one by recomputing it. That is
|
|
16
|
+
* what lets this run as a lambda, and it is why the nonce carries its own
|
|
17
|
+
* expiry in the clear — there is nowhere else to keep it.
|
|
18
|
+
*
|
|
19
|
+
* What this does NOT do is stop a quote being used twice. See `spent.ts`: a
|
|
20
|
+
* signature proves the vendor issued the price, not that the buyer has only
|
|
21
|
+
* arrived once.
|
|
22
|
+
*/
|
|
23
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
24
|
+
const mac = (secret, t) => createHmac("sha256", secret)
|
|
25
|
+
.update(`${t.resource}:${t.sompi}:${t.expiresAt}`)
|
|
26
|
+
.digest("hex")
|
|
27
|
+
.slice(0, 32);
|
|
28
|
+
/** Mint a nonce for these terms: `<expiry>.<mac>`. */
|
|
29
|
+
export function issueQuote(terms, options) {
|
|
30
|
+
return `${terms.expiresAt}.${mac(options.secret, terms)}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Check a nonce against the terms it claims to be for.
|
|
34
|
+
*
|
|
35
|
+
* Returns a reason, or null when it is good. A reason rather than a boolean
|
|
36
|
+
* because the three ways this fails are three different conversations with the
|
|
37
|
+
* buyer: a malformed nonce is a client bug, an expired one means ask again,
|
|
38
|
+
* and a mismatch means the quote came from somewhere else.
|
|
39
|
+
*/
|
|
40
|
+
export function checkQuote(nonce, terms, options, now = Date.now()) {
|
|
41
|
+
const [expiryText, presented] = String(nonce).split(".");
|
|
42
|
+
const expiresAt = Number(expiryText);
|
|
43
|
+
if (!expiryText || !presented || !Number.isFinite(expiresAt))
|
|
44
|
+
return "malformed quote";
|
|
45
|
+
if (now > expiresAt)
|
|
46
|
+
return "the quote has expired; ask again";
|
|
47
|
+
const expected = mac(options.secret, { ...terms, expiresAt });
|
|
48
|
+
const a = Buffer.from(presented);
|
|
49
|
+
const b = Buffer.from(expected);
|
|
50
|
+
/* Length first: timingSafeEqual throws on a mismatch rather than returning
|
|
51
|
+
false, and a thrown comparison is a 500 where a 400 was meant. */
|
|
52
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
53
|
+
return "this quote was not issued here";
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=quote.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quote.js","sourceRoot":"","sources":["../src/quote.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAmB1D,MAAM,GAAG,GAAG,CAAC,MAAc,EAAE,CAAa,EAAU,EAAE,CACpD,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC;KACzB,MAAM,CAAC,GAAG,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,SAAS,EAAE,CAAC;KACjD,MAAM,CAAC,KAAK,CAAC;KACb,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAElB,sDAAsD;AACtD,MAAM,UAAU,UAAU,CAAC,KAAiB,EAAE,OAAqB;IACjE,OAAO,GAAG,KAAK,CAAC,SAAS,IAAI,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC;AAC5D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CACxB,KAAa,EACb,KAAoC,EACpC,OAAqB,EACrB,MAAc,IAAI,CAAC,GAAG,EAAE;IAExB,MAAM,CAAC,UAAU,EAAE,SAAS,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACzD,MAAM,SAAS,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IACrC,IAAI,CAAC,UAAU,IAAI,CAAC,SAAS,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,iBAAiB,CAAC;IACvF,IAAI,GAAG,GAAG,SAAS;QAAE,OAAO,kCAAkC,CAAC;IAE/D,MAAM,QAAQ,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,GAAG,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;IAC9D,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChC;wEACoE;IACpE,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC;QAAE,OAAO,gCAAgC,CAAC;IAC7F,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/dist/settle.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole 402 exchange, with no HTTP in it.
|
|
3
|
+
*
|
|
4
|
+
* Takes what the request carried and returns what to answer. No req, no res,
|
|
5
|
+
* no framework — because the same seven branches are correct on node:http, on
|
|
6
|
+
* a Fetch handler, in a Worker and in a test, and the one place this logic
|
|
7
|
+
* gets copied is the place the copies drift.
|
|
8
|
+
*
|
|
9
|
+
* ## The branches, and why each answers as it does
|
|
10
|
+
*
|
|
11
|
+
* The status codes are not decoration. A buyer that has already broadcast a
|
|
12
|
+
* payment reads them to decide whether to wait, give up, or re-present the
|
|
13
|
+
* same proof — and the difference between those is the difference between one
|
|
14
|
+
* payment and two.
|
|
15
|
+
*
|
|
16
|
+
* 402 no header here is the price. Nothing has happened yet.
|
|
17
|
+
* 400 bad proof the buyer's client is wrong. Paying again will not help.
|
|
18
|
+
* 402 not visible RETRY, with the SAME proof. This is the important one.
|
|
19
|
+
* 409 already served this payment bought one thing and has had it.
|
|
20
|
+
* 503 no node the seller is blind, not the buyer wrong. Same proof again.
|
|
21
|
+
* 502 delivery failed paid, and we could not produce it. Says so, with the txid.
|
|
22
|
+
* 200 served with a receipt naming what was checked and by whom.
|
|
23
|
+
*
|
|
24
|
+
* The 402-with-retry is what stops double payment, so it is deliberately the
|
|
25
|
+
* same status as the initial quote: a client that treats any 402 as "pay" would
|
|
26
|
+
* pay twice, and the adapter on the other side of this exists to get that
|
|
27
|
+
* right. See @warda_protocol/x402.
|
|
28
|
+
*/
|
|
29
|
+
import { type QuoteOptions } from "./quote.ts";
|
|
30
|
+
import type { SpentStore } from "./spent.ts";
|
|
31
|
+
import type { NodeClient } from "@warda_protocol/kaspa";
|
|
32
|
+
export interface SaleTerms {
|
|
33
|
+
/** What is being sold; goes in the quote's signature. Usually the path. */
|
|
34
|
+
resource: string;
|
|
35
|
+
/** The address that must be paid. */
|
|
36
|
+
payTo: string;
|
|
37
|
+
/** The exact price in sompi. */
|
|
38
|
+
sompi: bigint;
|
|
39
|
+
/** As the buyer's client will read it: `mainnet`, `testnet-10`. */
|
|
40
|
+
network: string;
|
|
41
|
+
/** Optional, for the 402 body. */
|
|
42
|
+
description?: string;
|
|
43
|
+
/** Who is selling — echoed in the receipt so a buyer knows who it paid. */
|
|
44
|
+
seller?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface SettleInput {
|
|
47
|
+
terms: SaleTerms;
|
|
48
|
+
/** The raw `X-PAYMENT` header, or null when there was none. */
|
|
49
|
+
paymentHeader: string | null;
|
|
50
|
+
quote: QuoteOptions;
|
|
51
|
+
spent: SpentStore;
|
|
52
|
+
/** Opens a node and says which one. Called only once a proof is presented. */
|
|
53
|
+
openNode: () => Promise<{
|
|
54
|
+
client: Pick<NodeClient, "getUtxosByAddresses" | "close">;
|
|
55
|
+
readFrom: string;
|
|
56
|
+
}>;
|
|
57
|
+
/** Produce the goods. Called ONLY after the money is confirmed on chain. */
|
|
58
|
+
deliver: () => unknown | Promise<unknown>;
|
|
59
|
+
now?: () => number;
|
|
60
|
+
}
|
|
61
|
+
export interface SettleResult {
|
|
62
|
+
status: number;
|
|
63
|
+
body: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
export declare function settle(input: SettleInput): Promise<SettleResult>;
|
|
66
|
+
//# sourceMappingURL=settle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settle.d.ts","sourceRoot":"","sources":["../src/settle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAA0B,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEvE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAExD,MAAM,WAAW,SAAS;IACxB,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAC;IACjB,qCAAqC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,SAAS,CAAC;IACjB,+DAA+D;IAC/D,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,EAAE,YAAY,CAAC;IACpB,KAAK,EAAE,UAAU,CAAC;IAClB,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,OAAO,CAAC;QAAE,MAAM,EAAE,IAAI,CAAC,UAAU,EAAE,qBAAqB,GAAG,OAAO,CAAC,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzG,4EAA4E;IAC5E,OAAO,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAID,wBAAsB,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,CAsItE"}
|
package/dist/settle.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole 402 exchange, with no HTTP in it.
|
|
3
|
+
*
|
|
4
|
+
* Takes what the request carried and returns what to answer. No req, no res,
|
|
5
|
+
* no framework — because the same seven branches are correct on node:http, on
|
|
6
|
+
* a Fetch handler, in a Worker and in a test, and the one place this logic
|
|
7
|
+
* gets copied is the place the copies drift.
|
|
8
|
+
*
|
|
9
|
+
* ## The branches, and why each answers as it does
|
|
10
|
+
*
|
|
11
|
+
* The status codes are not decoration. A buyer that has already broadcast a
|
|
12
|
+
* payment reads them to decide whether to wait, give up, or re-present the
|
|
13
|
+
* same proof — and the difference between those is the difference between one
|
|
14
|
+
* payment and two.
|
|
15
|
+
*
|
|
16
|
+
* 402 no header here is the price. Nothing has happened yet.
|
|
17
|
+
* 400 bad proof the buyer's client is wrong. Paying again will not help.
|
|
18
|
+
* 402 not visible RETRY, with the SAME proof. This is the important one.
|
|
19
|
+
* 409 already served this payment bought one thing and has had it.
|
|
20
|
+
* 503 no node the seller is blind, not the buyer wrong. Same proof again.
|
|
21
|
+
* 502 delivery failed paid, and we could not produce it. Says so, with the txid.
|
|
22
|
+
* 200 served with a receipt naming what was checked and by whom.
|
|
23
|
+
*
|
|
24
|
+
* The 402-with-retry is what stops double payment, so it is deliberately the
|
|
25
|
+
* same status as the initial quote: a client that treats any 402 as "pay" would
|
|
26
|
+
* pay twice, and the adapter on the other side of this exists to get that
|
|
27
|
+
* right. See @warda_protocol/x402.
|
|
28
|
+
*/
|
|
29
|
+
import { checkQuote, issueQuote } from "./quote.js";
|
|
30
|
+
import { checkPayment } from "./verify.js";
|
|
31
|
+
const MAX_TIMEOUT_SECONDS = 60;
|
|
32
|
+
export async function settle(input) {
|
|
33
|
+
const { terms, quote: quoteOptions, spent } = input;
|
|
34
|
+
const now = input.now ?? Date.now;
|
|
35
|
+
if (!input.paymentHeader) {
|
|
36
|
+
const expiresAt = now() + (quoteOptions.ttlMs ?? 120_000);
|
|
37
|
+
return {
|
|
38
|
+
status: 402,
|
|
39
|
+
body: {
|
|
40
|
+
x402Version: 1,
|
|
41
|
+
error: "payment required",
|
|
42
|
+
accepts: [
|
|
43
|
+
{
|
|
44
|
+
scheme: "exact",
|
|
45
|
+
network: terms.network,
|
|
46
|
+
asset: "KAS",
|
|
47
|
+
payTo: terms.payTo,
|
|
48
|
+
amountSompi: terms.sompi.toString(),
|
|
49
|
+
nonce: issueQuote({ resource: terms.resource, sompi: terms.sompi, expiresAt }, quoteOptions),
|
|
50
|
+
maxTimeoutSeconds: MAX_TIMEOUT_SECONDS,
|
|
51
|
+
...(terms.description ? { description: terms.description } : {}),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
let proof;
|
|
58
|
+
try {
|
|
59
|
+
proof = JSON.parse(Buffer.from(input.paymentHeader, "base64").toString("utf8"));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { status: 400, body: { error: "X-PAYMENT is not base64 JSON" } };
|
|
63
|
+
}
|
|
64
|
+
const badQuote = checkQuote(String(proof.nonce ?? ""), { resource: terms.resource, sompi: terms.sompi }, quoteOptions, now());
|
|
65
|
+
if (badQuote)
|
|
66
|
+
return { status: 400, body: { error: badQuote } };
|
|
67
|
+
const txid = typeof proof.txid === "string" ? proof.txid : "";
|
|
68
|
+
if (!txid)
|
|
69
|
+
return { status: 400, body: { error: "no txid in the payment proof" } };
|
|
70
|
+
/* Before the node, not after: a replay is answerable without asking anyone,
|
|
71
|
+
and asking the chain about a payment already served wastes a round trip to
|
|
72
|
+
arrive at the same refusal. */
|
|
73
|
+
if (await spent.has(txid)) {
|
|
74
|
+
return {
|
|
75
|
+
status: 409,
|
|
76
|
+
body: {
|
|
77
|
+
error: "this payment has already been served",
|
|
78
|
+
settledBy: txid,
|
|
79
|
+
detail: "One payment buys one delivery. The coin is still in the UTXO set and always will " +
|
|
80
|
+
"be, so presenting it again proves nothing new. Buy again to get another.",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
let opened;
|
|
85
|
+
try {
|
|
86
|
+
opened = await input.openNode();
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
return {
|
|
90
|
+
status: 503,
|
|
91
|
+
body: {
|
|
92
|
+
error: `could not reach a node: ${e.message}`,
|
|
93
|
+
detail: "the payment may well be on chain; this vendor cannot see it. Re-present the same " +
|
|
94
|
+
"X-PAYMENT header rather than paying again — this vendor has NOT been paid twice " +
|
|
95
|
+
"and a second payment would not help.",
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const check = await checkPayment(opened.client, {
|
|
101
|
+
txid,
|
|
102
|
+
payTo: terms.payTo,
|
|
103
|
+
sompi: terms.sompi,
|
|
104
|
+
});
|
|
105
|
+
if (!check.paid) {
|
|
106
|
+
return {
|
|
107
|
+
status: check.retry ? 402 : 400,
|
|
108
|
+
body: {
|
|
109
|
+
error: check.reason,
|
|
110
|
+
...(check.retry ? { retry: true } : {}),
|
|
111
|
+
readFrom: opened.readFrom,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/* Recorded BEFORE delivery. If the seller crashes between the two, a buyer
|
|
116
|
+
loses a purchase it paid for — bad, and recoverable by a human. Recorded
|
|
117
|
+
after, a crash leaves a payment that can be replayed forever, which is
|
|
118
|
+
not recoverable by anyone. Of the two ways to be wrong, this is the one
|
|
119
|
+
that fails towards the seller noticing. */
|
|
120
|
+
await spent.add(txid);
|
|
121
|
+
let payload;
|
|
122
|
+
try {
|
|
123
|
+
payload = await input.deliver();
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
return {
|
|
127
|
+
status: 502,
|
|
128
|
+
body: {
|
|
129
|
+
error: `payment settled but the goods could not be produced: ${e.message}`,
|
|
130
|
+
settledBy: txid,
|
|
131
|
+
paidTo: terms.payTo,
|
|
132
|
+
...(terms.seller ? { seller: terms.seller } : {}),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
status: 200,
|
|
138
|
+
body: {
|
|
139
|
+
...payload,
|
|
140
|
+
...(terms.seller ? { seller: terms.seller } : {}),
|
|
141
|
+
paidTo: terms.payTo,
|
|
142
|
+
settledBy: txid,
|
|
143
|
+
verified: "a UTXO at this endpoint's payee address, from that transaction, for exactly the quoted amount",
|
|
144
|
+
readFrom: opened.readFrom,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
return { status: 503, body: { error: `could not reach a node: ${e.message}` } };
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
opened.client.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=settle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settle.js","sourceRoot":"","sources":["../src/settle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,UAAU,EAAE,UAAU,EAAqB,MAAM,YAAY,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAqC3C,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAE/B,MAAM,CAAC,KAAK,UAAU,MAAM,CAAC,KAAkB;IAC7C,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,GAAG,KAAK,CAAC;IACpD,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IAElC,IAAI,CAAC,KAAK,CAAC,aAAa,EAAE,CAAC;QACzB,MAAM,SAAS,GAAG,GAAG,EAAE,GAAG,CAAC,YAAY,CAAC,KAAK,IAAI,OAAO,CAAC,CAAC;QAC1D,OAAO;YACL,MAAM,EAAE,GAAG;YACX,IAAI,EAAE;gBACJ,WAAW,EAAE,CAAC;gBACd,KAAK,EAAE,kBAAkB;gBACzB,OAAO,EAAE;oBACP;wBACE,MAAM,EAAE,OAAO;wBACf,OAAO,EAAE,KAAK,CAAC,OAAO;wBACtB,KAAK,EAAE,KAAK;wBACZ,KAAK,EAAE,KAAK,CAAC,KAAK;wBAClB,WAAW,EAAE,KAAK,CAAC,KAAK,CAAC,QAAQ,EAAE;wBACnC,KAAK,EAAE,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,YAAY,CAAC;wBAC5F,iBAAiB,EAAE,mBAAmB;wBACtC,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;qBACjE;iBACF;aACF;SACF,CAAC;IACJ,CAAC;IAED,IAAI,KAA0C,CAAC;IAC/C,IAAI,CAAC;QACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;IAClF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,8BAA8B,EAAE,EAAE,CAAC;IAC1E,CAAC;IAED,MAAM,QAAQ,GAAG,UAAU,CACzB,MAAM,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,EACzB,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,EAChD,YAAY,EACZ,GAAG,EAAE,CACN,CAAC;IACF,IAAI,QAAQ;QAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,CAAC;IAEhE,MAAM,IAAI,GAAG,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9D,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,8BAA8B,EAAE,EAAE,CAAC;IAEnF;;qCAEiC;IACjC,IAAI,MAAM,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1B,OAAO;YACL,MAAM,EAAE,GAAG;YACX,IAAI,EAAE;gBACJ,KAAK,EAAE,sCAAsC;gBAC7C,SAAS,EAAE,IAAI;gBACf,MAAM,EACJ,mFAAmF;oBACnF,0EAA0E;aAC7E;SACF,CAAC;IACJ,CAAC;IAED,IAAI,MAAoD,CAAC;IACzD,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,KAAK,CAAC,QAAQ,EAAE,CAAC;IAClC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO;YACL,MAAM,EAAE,GAAG;YACX,IAAI,EAAE;gBACJ,KAAK,EAAE,2BAA4B,CAAW,CAAC,OAAO,EAAE;gBACxD,MAAM,EACJ,mFAAmF;oBACnF,kFAAkF;oBAClF,sCAAsC;aACzC;SACF,CAAC;IACJ,CAAC;IAED,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE;YAC9C,IAAI;YACJ,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,KAAK,EAAE,KAAK,CAAC,KAAK;SACnB,CAAC,CAAC;QAEH,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;YAChB,OAAO;gBACL,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG;gBAC/B,IAAI,EAAE;oBACJ,KAAK,EAAE,KAAK,CAAC,MAAM;oBACnB,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;oBACvC,QAAQ,EAAE,MAAM,CAAC,QAAQ;iBAC1B;aACF,CAAC;QACJ,CAAC;QAED;;;;qDAI6C;QAC7C,MAAM,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAEtB,IAAI,OAAgB,CAAC;QACrB,IAAI,CAAC;YACH,OAAO,GAAG,MAAM,KAAK,CAAC,OAAO,EAAE,CAAC;QAClC,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO;gBACL,MAAM,EAAE,GAAG;gBACX,IAAI,EAAE;oBACJ,KAAK,EAAE,wDAAyD,CAAW,CAAC,OAAO,EAAE;oBACrF,SAAS,EAAE,IAAI;oBACf,MAAM,EAAE,KAAK,CAAC,KAAK;oBACnB,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBAClD;aACF,CAAC;QACJ,CAAC;QAED,OAAO;YACL,MAAM,EAAE,GAAG;YACX,IAAI,EAAE;gBACJ,GAAI,OAAkB;gBACtB,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACjD,MAAM,EAAE,KAAK,CAAC,KAAK;gBACnB,SAAS,EAAE,IAAI;gBACf,QAAQ,EACN,+FAA+F;gBACjG,QAAQ,EAAE,MAAM,CAAC,QAAQ;aAC1B;SACF,CAAC;IACJ,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,2BAA4B,CAAW,CAAC,OAAO,EAAE,EAAE,EAAE,CAAC;IAC7F,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;AACH,CAAC"}
|
package/dist/spent.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One payment, one delivery.
|
|
3
|
+
*
|
|
4
|
+
* Everything else here can be done with no memory at all: the quote carries
|
|
5
|
+
* its own signature, and the chain remembers the payment. This cannot. A coin
|
|
6
|
+
* sitting at your address is a fact that stays true, so a buyer who paid once
|
|
7
|
+
* can present the same `X-PAYMENT` header a hundred times and every check in
|
|
8
|
+
* `verify.ts` passes every time. The demo vendor this package was extracted
|
|
9
|
+
* from has exactly that hole, and on a testnet demo it costs nothing; on a
|
|
10
|
+
* priced API it is the whole business model.
|
|
11
|
+
*
|
|
12
|
+
* There is no clever stateless fix. The alternatives are worse or harder:
|
|
13
|
+
* spending the coin onward makes delivery cost a transaction and a fee;
|
|
14
|
+
* binding the payment to the request body is what kaspa-x402 v2 does and needs
|
|
15
|
+
* both ends to implement it. What a seller actually needs is a set of
|
|
16
|
+
* transaction ids it has already served, which is a database row.
|
|
17
|
+
*
|
|
18
|
+
* So this package asks for one rather than pretending it does not need it. The
|
|
19
|
+
* interface is two methods so that a Redis SET, a unique index, a KV namespace
|
|
20
|
+
* or a Map all satisfy it without an adapter.
|
|
21
|
+
*
|
|
22
|
+
* ## The default is memory, and it is not a store
|
|
23
|
+
*
|
|
24
|
+
* `inMemorySpent()` exists so the first five minutes work. It is per-process,
|
|
25
|
+
* so under any host that runs more than one — every serverless platform, and
|
|
26
|
+
* every deployment with two instances — a replayed payment lands on a process
|
|
27
|
+
* that has not seen it and is served again. It says so in its own name and it
|
|
28
|
+
* says so on startup. It is a placeholder for a decision, not a decision.
|
|
29
|
+
*/
|
|
30
|
+
export interface SpentStore {
|
|
31
|
+
/** Has this transaction already been served? */
|
|
32
|
+
has(txid: string): boolean | Promise<boolean>;
|
|
33
|
+
/** Record that it has. Called only after the payment verified. */
|
|
34
|
+
add(txid: string): void | Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A per-process set. Correct on one long-lived instance, wrong everywhere else.
|
|
38
|
+
*
|
|
39
|
+
* The warning goes to stderr once, at construction, rather than on every
|
|
40
|
+
* request: a line per paid call is a line nobody reads, and the decision this
|
|
41
|
+
* is standing in for is made at deploy time.
|
|
42
|
+
*/
|
|
43
|
+
export declare function inMemorySpent(options?: {
|
|
44
|
+
quiet?: boolean;
|
|
45
|
+
}): SpentStore;
|
|
46
|
+
/**
|
|
47
|
+
* A store that permits replays, for a seller who has decided that is fine.
|
|
48
|
+
*
|
|
49
|
+
* Named for what it does rather than offered as `spent: undefined`, because
|
|
50
|
+
* the difference between "I chose to allow replays" and "I did not think about
|
|
51
|
+
* replays" should be visible in the source of whatever is deployed.
|
|
52
|
+
*/
|
|
53
|
+
export declare function replayAllowed(): SpentStore;
|
|
54
|
+
//# sourceMappingURL=spent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spent.d.ts","sourceRoot":"","sources":["../src/spent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,WAAW,UAAU;IACzB,gDAAgD;IAChD,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9C,kEAAkE;IAClE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,UAAU,CAa3E;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,IAAI,UAAU,CAE1C"}
|
package/dist/spent.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One payment, one delivery.
|
|
3
|
+
*
|
|
4
|
+
* Everything else here can be done with no memory at all: the quote carries
|
|
5
|
+
* its own signature, and the chain remembers the payment. This cannot. A coin
|
|
6
|
+
* sitting at your address is a fact that stays true, so a buyer who paid once
|
|
7
|
+
* can present the same `X-PAYMENT` header a hundred times and every check in
|
|
8
|
+
* `verify.ts` passes every time. The demo vendor this package was extracted
|
|
9
|
+
* from has exactly that hole, and on a testnet demo it costs nothing; on a
|
|
10
|
+
* priced API it is the whole business model.
|
|
11
|
+
*
|
|
12
|
+
* There is no clever stateless fix. The alternatives are worse or harder:
|
|
13
|
+
* spending the coin onward makes delivery cost a transaction and a fee;
|
|
14
|
+
* binding the payment to the request body is what kaspa-x402 v2 does and needs
|
|
15
|
+
* both ends to implement it. What a seller actually needs is a set of
|
|
16
|
+
* transaction ids it has already served, which is a database row.
|
|
17
|
+
*
|
|
18
|
+
* So this package asks for one rather than pretending it does not need it. The
|
|
19
|
+
* interface is two methods so that a Redis SET, a unique index, a KV namespace
|
|
20
|
+
* or a Map all satisfy it without an adapter.
|
|
21
|
+
*
|
|
22
|
+
* ## The default is memory, and it is not a store
|
|
23
|
+
*
|
|
24
|
+
* `inMemorySpent()` exists so the first five minutes work. It is per-process,
|
|
25
|
+
* so under any host that runs more than one — every serverless platform, and
|
|
26
|
+
* every deployment with two instances — a replayed payment lands on a process
|
|
27
|
+
* that has not seen it and is served again. It says so in its own name and it
|
|
28
|
+
* says so on startup. It is a placeholder for a decision, not a decision.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* A per-process set. Correct on one long-lived instance, wrong everywhere else.
|
|
32
|
+
*
|
|
33
|
+
* The warning goes to stderr once, at construction, rather than on every
|
|
34
|
+
* request: a line per paid call is a line nobody reads, and the decision this
|
|
35
|
+
* is standing in for is made at deploy time.
|
|
36
|
+
*/
|
|
37
|
+
export function inMemorySpent(options = {}) {
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
if (!options.quiet) {
|
|
40
|
+
console.error("[warda/vendor] replay protection is IN MEMORY. It holds for one process only, so a " +
|
|
41
|
+
"second instance — or the next cold start — will serve a replayed payment again. " +
|
|
42
|
+
"Pass a shared `spent` store before charging anyone real money.");
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
has: (txid) => seen.has(txid),
|
|
46
|
+
add: (txid) => void seen.add(txid),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A store that permits replays, for a seller who has decided that is fine.
|
|
51
|
+
*
|
|
52
|
+
* Named for what it does rather than offered as `spent: undefined`, because
|
|
53
|
+
* the difference between "I chose to allow replays" and "I did not think about
|
|
54
|
+
* replays" should be visible in the source of whatever is deployed.
|
|
55
|
+
*/
|
|
56
|
+
export function replayAllowed() {
|
|
57
|
+
return { has: () => false, add: () => { } };
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=spent.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spent.js","sourceRoot":"","sources":["../src/spent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AASH;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,UAA+B,EAAE;IAC7D,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QACnB,OAAO,CAAC,KAAK,CACX,qFAAqF;YACnF,kFAAkF;YAClF,gEAAgE,CACnE,CAAC;IACJ,CAAC;IACD,OAAO;QACL,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;QAC7B,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;KACnC,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa;IAC3B,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,GAAE,CAAC,EAAE,CAAC;AAC7C,CAAC"}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did the money arrive?
|
|
3
|
+
*
|
|
4
|
+
* This is the only question a seller actually needs answered, and the only
|
|
5
|
+
* honest way to answer it is to look at the UTXO set. A vendor that reads the
|
|
6
|
+
* buyer's `X-PAYMENT` header and believes it has verified nothing: the header
|
|
7
|
+
* is written by the party who benefits from lying, and a fabricated
|
|
8
|
+
* transaction id is as easy to write as a real one.
|
|
9
|
+
*
|
|
10
|
+
* So: ask a node what is sitting at YOUR address, and look for a coin from the
|
|
11
|
+
* transaction the buyer named, worth exactly what you quoted.
|
|
12
|
+
*
|
|
13
|
+
* ## Three things this deliberately does not accept
|
|
14
|
+
*
|
|
15
|
+
* A coin at the right address from a DIFFERENT transaction. Somebody else's
|
|
16
|
+
* payment, or an earlier one of this buyer's, is not this purchase.
|
|
17
|
+
*
|
|
18
|
+
* A coin from the right transaction for a different amount. `exact` is the
|
|
19
|
+
* scheme's name and its meaning; underpayment is not a discount and
|
|
20
|
+
* overpayment is not a tip, because a vendor that rounds is a vendor whose
|
|
21
|
+
* price is a suggestion.
|
|
22
|
+
*
|
|
23
|
+
* A transaction that exists in a mempool somewhere. Only a UTXO counts. A
|
|
24
|
+
* payment in flight is indistinguishable, from here, from one that will never
|
|
25
|
+
* confirm — and the caller is told to come back rather than refused, because
|
|
26
|
+
* it has already spent the money.
|
|
27
|
+
*/
|
|
28
|
+
import type { NodeClient } from "@warda_protocol/kaspa";
|
|
29
|
+
export interface PaymentClaim {
|
|
30
|
+
/** The transaction the buyer says paid you. */
|
|
31
|
+
txid: string;
|
|
32
|
+
/** The address you quoted. */
|
|
33
|
+
payTo: string;
|
|
34
|
+
/** The exact amount you quoted, in sompi. */
|
|
35
|
+
sompi: bigint;
|
|
36
|
+
}
|
|
37
|
+
export type PaymentCheck = {
|
|
38
|
+
paid: true;
|
|
39
|
+
txid: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Not visible. `retry` is the whole reason this is one shape rather than a
|
|
43
|
+
* boolean: a buyer whose payment has not landed yet must be told to
|
|
44
|
+
* re-present the SAME proof, and a buyer whose payment is wrong must not be.
|
|
45
|
+
* Collapsing those two into "no" is how a client ends up paying twice.
|
|
46
|
+
*/
|
|
47
|
+
| {
|
|
48
|
+
paid: false;
|
|
49
|
+
retry: boolean;
|
|
50
|
+
reason: string;
|
|
51
|
+
};
|
|
52
|
+
export declare function checkPayment(client: Pick<NodeClient, "getUtxosByAddresses">, claim: PaymentClaim): Promise<PaymentCheck>;
|
|
53
|
+
//# sourceMappingURL=verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAGxD,MAAM,WAAW,YAAY;IAC3B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,6CAA6C;IAC7C,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAC9B;;;;;GAKG;GACD;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpD,wBAAsB,YAAY,CAChC,MAAM,EAAE,IAAI,CAAC,UAAU,EAAE,qBAAqB,CAAC,EAC/C,KAAK,EAAE,YAAY,GAClB,OAAO,CAAC,YAAY,CAAC,CAiCvB"}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { toHex } from "@warda_protocol/kaspa";
|
|
2
|
+
export async function checkPayment(client, claim) {
|
|
3
|
+
const utxos = await client.getUtxosByAddresses([claim.payTo]);
|
|
4
|
+
const wanted = claim.txid.toLowerCase();
|
|
5
|
+
const fromThatTx = utxos.filter((u) => toHex(u.outpoint.transactionId).toLowerCase() === wanted);
|
|
6
|
+
if (fromThatTx.length === 0) {
|
|
7
|
+
return {
|
|
8
|
+
paid: false,
|
|
9
|
+
retry: true,
|
|
10
|
+
reason: "payment not yet visible on chain",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const exact = fromThatTx.find((u) => u.entry.value === claim.sompi);
|
|
14
|
+
if (!exact) {
|
|
15
|
+
/* The transaction IS there and pays this address the wrong amount. Not a
|
|
16
|
+
retry: waiting will not change what it paid, and telling a buyer to wait
|
|
17
|
+
for a payment that can never be accepted is how money gets spent twice
|
|
18
|
+
chasing a purchase that was refused the first time. Name both figures —
|
|
19
|
+
the buyer cannot see its own transaction from inside this refusal. */
|
|
20
|
+
const paid = fromThatTx.map((u) => u.entry.value.toString()).join(", ");
|
|
21
|
+
return {
|
|
22
|
+
paid: false,
|
|
23
|
+
retry: false,
|
|
24
|
+
reason: `transaction ${claim.txid} pays this address ${paid} sompi, and the quote was ` +
|
|
25
|
+
`${claim.sompi}. The scheme is "exact": this is not underpayment or overpayment, ` +
|
|
26
|
+
`it is a different purchase.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { paid: true, txid: claim.txid };
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=verify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AA4BA,OAAO,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AAqB9C,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,MAA+C,EAC/C,KAAmB;IAEnB,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;IAE9D,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;IACxC,MAAM,UAAU,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,WAAW,EAAE,KAAK,MAAM,CAAC,CAAC;IAEjG,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO;YACL,IAAI,EAAE,KAAK;YACX,KAAK,EAAE,IAAI;YACX,MAAM,EAAE,kCAAkC;SAC3C,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,KAAK,KAAK,CAAC,KAAK,CAAC,CAAC;IACpE,IAAI,CAAC,KAAK,EAAE,CAAC;QACX;;;;gFAIwE;QACxE,MAAM,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACxE,OAAO;YACL,IAAI,EAAE,KAAK;YACX,KAAK,EAAE,KAAK;YACZ,MAAM,EACJ,eAAe,KAAK,CAAC,IAAI,sBAAsB,IAAI,4BAA4B;gBAC/E,GAAG,KAAK,CAAC,KAAK,oEAAoE;gBAClF,6BAA6B;SAChC,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;AAC1C,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@warda_protocol/vendor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sell an API for Kaspa over HTTP 402. Quotes a price, then verifies the payment is in the UTXO set before serving \u2014 it never trusts the payment header.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
28
|
+
"build": "tsc -p tsconfig.build.json",
|
|
29
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@warda_protocol/kaspa": "^0.5.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^26.4.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"kaspa",
|
|
39
|
+
"x402",
|
|
40
|
+
"http-402",
|
|
41
|
+
"agent-payments",
|
|
42
|
+
"paid-api",
|
|
43
|
+
"vendor"
|
|
44
|
+
],
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/ArtyKOMarkets/warda.git",
|
|
48
|
+
"directory": "vendor"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://wardaprotocol.com",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/ArtyKOMarkets/warda/issues"
|
|
53
|
+
},
|
|
54
|
+
"sideEffects": false
|
|
55
|
+
}
|