@paydlux/agent 0.0.0-stage → 0.1.1
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 +154 -2
- package/dist/agent.d.ts +49 -0
- package/dist/agent.js +113 -0
- package/dist/desk.d.ts +43 -0
- package/dist/desk.js +107 -0
- package/dist/gateway.d.ts +82 -0
- package/dist/gateway.js +252 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +9 -0
- package/dist/policy.d.ts +52 -0
- package/dist/policy.js +58 -0
- package/dist/portal.d.ts +4 -0
- package/dist/portal.js +62 -0
- package/dist/vault.d.ts +135 -0
- package/dist/vault.js +296 -0
- package/package.json +64 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ziibo
|
|
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
CHANGED
|
@@ -1,3 +1,155 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Paydlux — spend-limited payments for AI agents
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AI agents are starting to buy things (APIs, data, compute) over HTTP with
|
|
4
|
+
stablecoin payments ([x402](https://x402.org)). Today an agent gets a raw
|
|
5
|
+
crypto wallet: **no spending cap, no human approval, no refund path** — and
|
|
6
|
+
real money has already been drained this way.
|
|
7
|
+
|
|
8
|
+
Paydlux puts the guardrails **on the ledger** instead of in your code.
|
|
9
|
+
|
|
10
|
+
## The Stellar path (primary — no smart contract, no faucet, no gas fight)
|
|
11
|
+
|
|
12
|
+
There is no contract to audit. The whole mechanism is three native Stellar
|
|
13
|
+
primitives:
|
|
14
|
+
|
|
15
|
+
| primitive | what it gives us |
|
|
16
|
+
|---|---|
|
|
17
|
+
| issued asset / Circle USDC | the payment token |
|
|
18
|
+
| multisig account | the vault — releasing funds needs 2 of the signers |
|
|
19
|
+
| pre-authorised transaction hash | the spending mandate, as a *signer* |
|
|
20
|
+
|
|
21
|
+
The mandate is the trick. We pre-build a transaction whose operations are
|
|
22
|
+
exactly "pay N tokens to this payee, inside this time window, once", take its
|
|
23
|
+
hash, and register that hash as a weight-1 signer on the vault. The agent key
|
|
24
|
+
is also weight-1 and the threshold is 2. So the agent can reach threshold only
|
|
25
|
+
by presenting **that exact transaction**. Change the amount, the payee, the
|
|
26
|
+
token, the window, or try to run it twice, and the ledger refuses. A prompt
|
|
27
|
+
injection cannot talk its way past a signature check.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
stellar/vault.ts create a 2-of-2 vault, register mandates, settle, revoke
|
|
31
|
+
stellar/gateway.ts HTTP 402 resource server; serves only after the ledger agrees
|
|
32
|
+
stellar/agent.ts the buyer side — one class, one method call
|
|
33
|
+
stellar/desk.ts approval queue: a human decides, in the clear
|
|
34
|
+
stellar/portal.ts the page that human looks at
|
|
35
|
+
stellar/policy.ts daily budget / seller allowlist (the part a ledger can't count)
|
|
36
|
+
stellar/demo.ts 12 checks against the live testnet, one command
|
|
37
|
+
stellar/probe-*.ts the experiments that settled how the ledger behaves
|
|
38
|
+
stellar/onboard.ts keys → funded vault → printed agent key
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Try it
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm i
|
|
45
|
+
npx tsx stellar/demo.ts # 12 checks, live testnet
|
|
46
|
+
npx tsx stellar/story.ts --public # one unguessable link; approve from your phone
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No wallet, no faucet, no config. The SDK also builds as an npm package
|
|
50
|
+
(`npm run build` → `dist/`, published as `@paydlux/agent`):
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { StellarVault, Gateway, PaydluxAgent } from "@paydlux/agent";
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`story.ts --public` matters for demos: the portal has no login, so the printed
|
|
57
|
+
link carries a 22-character key and every request without it gets a 403.
|
|
58
|
+
|
|
59
|
+
It prints what it proves:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
— 4. human pre-authorises a mandate; agent executes it
|
|
63
|
+
✅ content served, tx cabeb25ec92c2391…
|
|
64
|
+
✅ seller 2.0000000, vault 48.0000000
|
|
65
|
+
— 5. tamper with the envelope — the ledger refuses
|
|
66
|
+
✅ refused: price mismatch: want 2 got 9.0000000
|
|
67
|
+
— 6. replay the same mandate — money moves once
|
|
68
|
+
✅ refused: mandate is not registered on the vault (expired or revoked)
|
|
69
|
+
— 7. over-budget request is declined before anything is built
|
|
70
|
+
✅ declined [max_per_payment]: 40 exceeds the per-payment ceiling of 10
|
|
71
|
+
— 8. approval mode — a human decides
|
|
72
|
+
✅ human approved → settled on-chain, tx bf4ce06c742f18f2…
|
|
73
|
+
— 9. deny path — nothing moves
|
|
74
|
+
✅ denied, balance unchanged at 42.0000000
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Plug your own agent in:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { PaydluxAgent } from "./stellar/agent.ts";
|
|
81
|
+
|
|
82
|
+
const agent = new PaydluxAgent({ agentKey: process.env.AGENT_KEY! });
|
|
83
|
+
const res = await agent.get("https://seller.example.com/api/data", { mandate });
|
|
84
|
+
if (res.ok) console.log(res.data, "paid in tx", res.tx);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`npx tsx stellar/onboard.ts` creates the vault and prints that key. The key is
|
|
88
|
+
**one of two signatures** — it cannot spend alone.
|
|
89
|
+
|
|
90
|
+
### What is enforced where (said honestly)
|
|
91
|
+
|
|
92
|
+
| enforced by Stellar | enforced by `policy.ts` |
|
|
93
|
+
|---|---|
|
|
94
|
+
| max per payment, payee, token, time window, one-shot execution | payments per day, running daily total, seller allow/deny list |
|
|
95
|
+
|
|
96
|
+
The vault is one payment at a time; counting payments is our job. A rule break
|
|
97
|
+
means we never build the transaction, so nothing reaches the ledger.
|
|
98
|
+
|
|
99
|
+
Two ledger behaviours we verified rather than assumed, and designed around:
|
|
100
|
+
|
|
101
|
+
- **A mandate is consumed on use.** After it runs, Stellar removes the pre-auth
|
|
102
|
+
signer. One mandate, one payment — no counter needed.
|
|
103
|
+
- **A pre-auth transaction is bound to a sequence number**, so only the newest
|
|
104
|
+
mandate can ever run; an older one is in the past and can never fire.
|
|
105
|
+
`createMandate` therefore revokes previous mandates in the same transaction.
|
|
106
|
+
The product is deliberately one open order at a time.
|
|
107
|
+
- **Resubmitting an identical envelope does not error.** Horizon returns the
|
|
108
|
+
*cached* result. The money cannot move twice, but a naive gateway would serve
|
|
109
|
+
the content twice — so `gateway.ts` checks the ledger for an already-applied
|
|
110
|
+
transaction before shipping anything.
|
|
111
|
+
|
|
112
|
+
### Real money
|
|
113
|
+
|
|
114
|
+
Circle issues USDC natively on Stellar — not wrapped, not bridged. `vault.ts`
|
|
115
|
+
pins the issuer (`GA5ZSEJY…KZVN` on public, `GBBD47IF…FLA5` on testnet) and
|
|
116
|
+
rejects any other token that calls itself USDC. The vault's USDC trustline is
|
|
117
|
+
verified on testnet. Settlement fee is 0.00001 XLM.
|
|
118
|
+
|
|
119
|
+
## The EVM path (research, kept for the deeper feature set)
|
|
120
|
+
|
|
121
|
+
`contracts/` + `sdk/` are the earlier Base Sepolia work: a Solidity vault with
|
|
122
|
+
escrow, proof-of-delivery receipts, session keys and Merkle-batched settlement
|
|
123
|
+
(~$0.00005/payment). 61 contract tests pass, including 4×512-run fuzz suites
|
|
124
|
+
over the core invariants.
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
contracts/src/AgentVault.sol vault, mandates, settle, escrow, sessions, batches
|
|
128
|
+
contracts/src/AgentCreditToken.sol USDC-backed credit token (deferred settlement)
|
|
129
|
+
sdk/core.ts EIP-712 typed data (mirrors the contract exactly)
|
|
130
|
+
sdk/gateway.ts x402-style HTTP 402 resource server
|
|
131
|
+
sdk/approval.ts human-in-the-loop Approval Desk
|
|
132
|
+
sdk/economics.ts unit economics from measured gas + live prices
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
anvil --chain-id 31337 &
|
|
137
|
+
cd contracts && forge build && forge test
|
|
138
|
+
cd ../sdk && node demo.ts
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Status
|
|
142
|
+
|
|
143
|
+
Working prototype on **Stellar testnet**, end to end, verified by `stellar/demo.ts`.
|
|
144
|
+
The EVM contracts are tested but **not audited**. Nothing here has run with
|
|
145
|
+
real funds. Do not use it with real funds.
|
|
146
|
+
|
|
147
|
+
## Economics
|
|
148
|
+
|
|
149
|
+
Stellar settles for ~0.00001 XLM, so per-payment cost is not the constraint —
|
|
150
|
+
trust is. Pricing is 0.5¢ per settlement capped at 1%, plus SaaS for mandate
|
|
151
|
+
management, dashboards and compliance evidence.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
package/dist/agent.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Horizon } from "stellar-sdk";
|
|
2
|
+
import { type Mandate } from "./vault.ts";
|
|
3
|
+
export type AgentConfig = {
|
|
4
|
+
/** the agent's own key. One of two signers on the vault. */
|
|
5
|
+
agentKey: string;
|
|
6
|
+
/** where to send the payment proof once a gateway asks for one */
|
|
7
|
+
network?: "testnet" | "public";
|
|
8
|
+
/** max attempts before giving up */
|
|
9
|
+
maxRetries?: number;
|
|
10
|
+
/** approval mode: how long to wait for a human, and how often to poll */
|
|
11
|
+
approvalTimeoutMs?: number;
|
|
12
|
+
pollMs?: number;
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
};
|
|
15
|
+
export type PayResponse<T = string> = {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
status: number;
|
|
18
|
+
data?: T;
|
|
19
|
+
tx?: string;
|
|
20
|
+
/** set when a human has to approve */
|
|
21
|
+
handle?: string;
|
|
22
|
+
desk?: string;
|
|
23
|
+
reason?: string;
|
|
24
|
+
requirements?: any;
|
|
25
|
+
};
|
|
26
|
+
export declare class PaydluxAgent {
|
|
27
|
+
private key;
|
|
28
|
+
private cfg;
|
|
29
|
+
constructor(cfg: AgentConfig);
|
|
30
|
+
get publicKey(): string;
|
|
31
|
+
/**
|
|
32
|
+
* GET a paid resource. Handles the 402 handshake: read what the seller
|
|
33
|
+
* wants, satisfy it, retry once with the proof attached.
|
|
34
|
+
*/
|
|
35
|
+
get<T = string>(url: string, opts?: {
|
|
36
|
+
mandate?: Mandate;
|
|
37
|
+
body?: string;
|
|
38
|
+
}): Promise<PayResponse<T>>;
|
|
39
|
+
/**
|
|
40
|
+
* Sign and submit a mandate envelope directly to Horizon. Used when the
|
|
41
|
+
* gateway wants the payment settled before it serves anything.
|
|
42
|
+
*/
|
|
43
|
+
submitEnvelope(mandate: Mandate): Promise<Awaited<ReturnType<Horizon.Server["submitTransaction"]>>>;
|
|
44
|
+
/**
|
|
45
|
+
* Wait for a human to decide on an approval-mode payment.
|
|
46
|
+
* Returns the settled record, or null on deny/timeout.
|
|
47
|
+
*/
|
|
48
|
+
waitForApproval(desk: string, handle: string): Promise<any>;
|
|
49
|
+
}
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The agent side. This is all a buyer has to write.
|
|
2
|
+
//
|
|
3
|
+
// const agent = new PaydluxAgent({ gateway: "http://localhost:4021", agentKey });
|
|
4
|
+
// const res = await agent.get("https://api.example.com/data");
|
|
5
|
+
//
|
|
6
|
+
// The agent holds one key of two. It can ask, and it can execute a mandate a
|
|
7
|
+
// human already signed. It cannot move money on its own — the ledger refuses.
|
|
8
|
+
import { Horizon, Keypair, Transaction } from "stellar-sdk";
|
|
9
|
+
import { passphrase } from "./vault.js";
|
|
10
|
+
const PAY_HEADER = "x-payment";
|
|
11
|
+
export class PaydluxAgent {
|
|
12
|
+
key;
|
|
13
|
+
cfg;
|
|
14
|
+
constructor(cfg) {
|
|
15
|
+
this.key = Keypair.fromSecret(cfg.agentKey);
|
|
16
|
+
this.cfg = {
|
|
17
|
+
network: "testnet", maxRetries: 3, approvalTimeoutMs: 120_000, pollMs: 2_000, ...cfg,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
get publicKey() { return this.key.publicKey(); }
|
|
21
|
+
/**
|
|
22
|
+
* GET a paid resource. Handles the 402 handshake: read what the seller
|
|
23
|
+
* wants, satisfy it, retry once with the proof attached.
|
|
24
|
+
*/
|
|
25
|
+
async get(url, opts = {}) {
|
|
26
|
+
const f = this.cfg.fetch ?? fetch;
|
|
27
|
+
const first = await f(url, { method: opts.body ? "POST" : "GET", body: opts.body });
|
|
28
|
+
if (first.status !== 402) {
|
|
29
|
+
return { ok: first.ok, status: first.status, data: (await first.json().catch(() => first.text())) };
|
|
30
|
+
}
|
|
31
|
+
const reqs = parseRequirements(first.headers.get(PAY_HEADER));
|
|
32
|
+
if (!reqs)
|
|
33
|
+
return { ok: false, status: 402, reason: "seller sent 402 without payment terms", requirements: null };
|
|
34
|
+
if (reqs.mode === "approval") {
|
|
35
|
+
// Tell the seller we want it. The seller parks the request at the
|
|
36
|
+
// approval desk and a person decides. We do not get to decide.
|
|
37
|
+
const intent = signIntent(url, reqs, this.key);
|
|
38
|
+
const parked = await f(url, {
|
|
39
|
+
method: opts.body ? "POST" : "GET", body: opts.body,
|
|
40
|
+
headers: { [PAY_HEADER]: JSON.stringify({ intent }) },
|
|
41
|
+
});
|
|
42
|
+
const j = await parked.json().catch(() => ({}));
|
|
43
|
+
return { ok: false, status: parked.status, handle: j.handle, desk: j.desk, requirements: reqs,
|
|
44
|
+
reason: "a human has to approve this payment" };
|
|
45
|
+
}
|
|
46
|
+
if (!opts.mandate) {
|
|
47
|
+
return { ok: false, status: 402, requirements: reqs,
|
|
48
|
+
reason: "seller wants a pre-authorised mandate and none was supplied" };
|
|
49
|
+
}
|
|
50
|
+
// Sign locally, then send. Signing does not change the hash (the hash is
|
|
51
|
+
// over the unsigned body), so the vault still recognises the mandate.
|
|
52
|
+
const signed = new Transaction(opts.mandate.envelope, passphrase(this.cfg.network));
|
|
53
|
+
signed.sign(this.key);
|
|
54
|
+
const proof = { envelope: signed.toXDR(), mandateHash: opts.mandate.hash };
|
|
55
|
+
const retry = await f(url, {
|
|
56
|
+
method: opts.body ? "POST" : "GET",
|
|
57
|
+
body: opts.body,
|
|
58
|
+
headers: { [PAY_HEADER]: JSON.stringify(proof) },
|
|
59
|
+
});
|
|
60
|
+
const j = await retry.json().catch(() => ({}));
|
|
61
|
+
if (retry.status === 200) {
|
|
62
|
+
return { ok: true, status: 200, data: (j.data ?? j), tx: j.tx, requirements: reqs };
|
|
63
|
+
}
|
|
64
|
+
return { ok: false, status: retry.status, reason: j.reason ?? "payment rejected", requirements: reqs };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Sign and submit a mandate envelope directly to Horizon. Used when the
|
|
68
|
+
* gateway wants the payment settled before it serves anything.
|
|
69
|
+
*/
|
|
70
|
+
async submitEnvelope(mandate) {
|
|
71
|
+
const tx = new Transaction(mandate.envelope, passphrase(this.cfg.network));
|
|
72
|
+
tx.sign(this.key);
|
|
73
|
+
const h = new Horizon.Server(this.cfg.network === "public"
|
|
74
|
+
? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org");
|
|
75
|
+
return h.submitTransaction(tx);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Wait for a human to decide on an approval-mode payment.
|
|
79
|
+
* Returns the settled record, or null on deny/timeout.
|
|
80
|
+
*/
|
|
81
|
+
async waitForApproval(desk, handle) {
|
|
82
|
+
const f = this.cfg.fetch ?? fetch;
|
|
83
|
+
const deadline = Date.now() + this.cfg.approvalTimeoutMs;
|
|
84
|
+
while (Date.now() < deadline) {
|
|
85
|
+
const r = await f(`${desk}/list`).then((x) => x.json()).catch(() => []);
|
|
86
|
+
const rec = r.find((x) => x.id === handle);
|
|
87
|
+
if (rec && rec.status === "settled")
|
|
88
|
+
return rec;
|
|
89
|
+
if (rec && (rec.status === "denied" || rec.status === "failed"))
|
|
90
|
+
return rec;
|
|
91
|
+
await new Promise((res) => setTimeout(res, this.cfg.pollMs));
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A signed intent: proof the request came from the real agent key, so a random
|
|
98
|
+
* visitor cannot spam the approval desk with pending payments.
|
|
99
|
+
*/
|
|
100
|
+
function signIntent(url, reqs, key) {
|
|
101
|
+
const payload = `${url}|${reqs.maxAmountRequired}|${reqs.vault}|${Date.now()}`;
|
|
102
|
+
return { payload, signature: Buffer.from(key.sign(Buffer.from(payload))).toString("base64") };
|
|
103
|
+
}
|
|
104
|
+
function parseRequirements(header) {
|
|
105
|
+
if (!header)
|
|
106
|
+
return null;
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(header);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/desk.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import type { StellarVault } from "./vault.ts";
|
|
3
|
+
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
4
|
+
* random, and compared without leaking timing. */
|
|
5
|
+
export declare function newPortalKey(): string;
|
|
6
|
+
export type DeskRecord = {
|
|
7
|
+
id: string;
|
|
8
|
+
at: number;
|
|
9
|
+
uri: string;
|
|
10
|
+
price: string;
|
|
11
|
+
body: string;
|
|
12
|
+
status: "pending" | "approved" | "denied" | "settled" | "failed";
|
|
13
|
+
content?: string;
|
|
14
|
+
tx?: string;
|
|
15
|
+
reason?: string;
|
|
16
|
+
};
|
|
17
|
+
export type DeskStore = DeskRecord;
|
|
18
|
+
export type DeskConfig = {
|
|
19
|
+
vault: StellarVault;
|
|
20
|
+
payee: string;
|
|
21
|
+
/** seeds of the two human keys. DEV ONLY — in production these live on the phone. */
|
|
22
|
+
principal?: string;
|
|
23
|
+
approver?: string;
|
|
24
|
+
port: number;
|
|
25
|
+
/** If set, the portal and its actions require this key in the URL.
|
|
26
|
+
* Required before putting the desk on any public URL. */
|
|
27
|
+
portalKey?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare class ApprovalDesk {
|
|
30
|
+
private records;
|
|
31
|
+
private seq;
|
|
32
|
+
cfg: DeskConfig;
|
|
33
|
+
constructor(cfg: DeskConfig);
|
|
34
|
+
enqueue(uri: string, price: string, body: string, content: string): string;
|
|
35
|
+
get(id: string): DeskRecord | null;
|
|
36
|
+
list(): DeskRecord[];
|
|
37
|
+
/** The human clicked Approve: sign and submit the release. */
|
|
38
|
+
approve(id: string): Promise<DeskRecord>;
|
|
39
|
+
deny(id: string, reason?: string): DeskRecord;
|
|
40
|
+
/** Serve the pending list as JSON (the portal polls this). */
|
|
41
|
+
api(handler: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
|
|
42
|
+
listen(port?: number): Promise<void>;
|
|
43
|
+
}
|
package/dist/desk.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Paydlux approval desk on Stellar — the human-in-the-loop layer.
|
|
2
|
+
//
|
|
3
|
+
// A route in "approval" mode never settles on the agent's say-so. The gateway
|
|
4
|
+
// parks the request here; a person opens the portal, reads what the agent
|
|
5
|
+
// wanted to buy and for how much, and clicks Approve or Deny.
|
|
6
|
+
//
|
|
7
|
+
// Approving means signing a Stellar transaction with the human's key. The
|
|
8
|
+
// vault needs principal + approver (threshold 2), so:
|
|
9
|
+
// - a stolen agent key cannot approve
|
|
10
|
+
// - a stolen phone cannot approve alone
|
|
11
|
+
// - the approval is a real ledger signature, not a database flag
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { Keypair } from "stellar-sdk";
|
|
15
|
+
import { portalPage } from "./portal.js";
|
|
16
|
+
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
17
|
+
* random, and compared without leaking timing. */
|
|
18
|
+
export function newPortalKey() { return randomBytes(16).toString("base64url"); }
|
|
19
|
+
function same(a, b) {
|
|
20
|
+
const x = Buffer.from(a), y = Buffer.from(b);
|
|
21
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
22
|
+
}
|
|
23
|
+
export class ApprovalDesk {
|
|
24
|
+
records = new Map();
|
|
25
|
+
seq = 0;
|
|
26
|
+
cfg;
|
|
27
|
+
constructor(cfg) { this.cfg = cfg; }
|
|
28
|
+
enqueue(uri, price, body, content) {
|
|
29
|
+
const id = `pay_${(++this.seq).toString().padStart(4, "0")}`;
|
|
30
|
+
this.records.set(id, { id, at: Date.now(), uri, price, body, status: "pending", content });
|
|
31
|
+
console.log(`[desk] ${id} awaiting a human: ${price} for ${uri}`);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
get(id) { return this.records.get(id) ?? null; }
|
|
35
|
+
list() { return [...this.records.values()]; }
|
|
36
|
+
/** The human clicked Approve: sign and submit the release. */
|
|
37
|
+
async approve(id) {
|
|
38
|
+
const rec = this.records.get(id);
|
|
39
|
+
if (!rec)
|
|
40
|
+
throw new Error("unknown handle " + id);
|
|
41
|
+
if (rec.status !== "pending")
|
|
42
|
+
throw new Error(`already ${rec.status}`);
|
|
43
|
+
if (!this.cfg.principal || !this.cfg.approver) {
|
|
44
|
+
rec.status = "failed";
|
|
45
|
+
rec.reason = "no human keys configured (portal signing not wired yet)";
|
|
46
|
+
return rec;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const res = await this.cfg.vault.release(this.cfg.payee, rec.price, Keypair.fromSecret(this.cfg.principal), Keypair.fromSecret(this.cfg.approver), id);
|
|
50
|
+
rec.status = "settled";
|
|
51
|
+
rec.tx = String(res?.hash ?? "");
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
rec.status = "failed";
|
|
55
|
+
rec.reason = e.message;
|
|
56
|
+
}
|
|
57
|
+
return rec;
|
|
58
|
+
}
|
|
59
|
+
deny(id, reason = "denied by the approver") {
|
|
60
|
+
const rec = this.records.get(id);
|
|
61
|
+
if (!rec)
|
|
62
|
+
throw new Error("unknown handle " + id);
|
|
63
|
+
if (rec.status !== "pending")
|
|
64
|
+
throw new Error(`already ${rec.status}`);
|
|
65
|
+
rec.status = "denied";
|
|
66
|
+
rec.reason = reason;
|
|
67
|
+
return rec;
|
|
68
|
+
}
|
|
69
|
+
/** Serve the pending list as JSON (the portal polls this). */
|
|
70
|
+
api(handler) { return handler; }
|
|
71
|
+
listen(port = this.cfg.port) {
|
|
72
|
+
const srv = createServer(async (req, res) => {
|
|
73
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
74
|
+
// A public demo link must not be an open approval button. If a portalKey
|
|
75
|
+
// is configured, every route needs it, or the desk answers 403.
|
|
76
|
+
if (this.cfg.portalKey && !same(url.searchParams.get("k") ?? "", this.cfg.portalKey)) {
|
|
77
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
78
|
+
res.end("403 — this approval link was never issued or has been rotated.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const qs = url.search;
|
|
82
|
+
const send = (code, obj) => {
|
|
83
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
84
|
+
res.end(JSON.stringify(obj));
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
if (url.pathname === "/") {
|
|
88
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
89
|
+
res.end(portalPage(this.list(), this.cfg.vault.address, this.cfg.vault.network, qs));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (url.pathname === "/list")
|
|
93
|
+
return send(200, this.list());
|
|
94
|
+
const m = url.pathname.match(/^\/(approve|deny)\/([\w-]+)$/);
|
|
95
|
+
if (m && req.method === "POST") {
|
|
96
|
+
const rec = m[1] === "approve" ? await this.approve(m[2]) : this.deny(m[2]);
|
|
97
|
+
return send(200, rec);
|
|
98
|
+
}
|
|
99
|
+
return send(404, { error: "not found" });
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
return send(400, { error: e.message });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return new Promise((r) => srv.listen(port, () => r()));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { Transaction } from "stellar-sdk";
|
|
3
|
+
import { StellarVault, StellarTxError, type Amount, type Mandate, type Network } from "./vault.ts";
|
|
4
|
+
import { ApprovalDesk, type DeskStore } from "./desk.ts";
|
|
5
|
+
export declare const PAY_HEADER = "x-payment";
|
|
6
|
+
export type Route = {
|
|
7
|
+
uri: string;
|
|
8
|
+
price: Amount;
|
|
9
|
+
mode: "preauth" | "approval";
|
|
10
|
+
/** seconds the seller's content stays reserved while a human decides */
|
|
11
|
+
holdSeconds?: number;
|
|
12
|
+
};
|
|
13
|
+
export type GatewayConfig = {
|
|
14
|
+
vault: StellarVault;
|
|
15
|
+
network: Network;
|
|
16
|
+
/** the seller receiving payment (usually the vault owner's merchant account) */
|
|
17
|
+
payee: string;
|
|
18
|
+
routes: Route[];
|
|
19
|
+
/** human keys. In production these live on the phone, never here. */
|
|
20
|
+
principal?: string;
|
|
21
|
+
approver?: string;
|
|
22
|
+
deskPort?: number;
|
|
23
|
+
/** gate the approval portal behind a single unguessable link */
|
|
24
|
+
portalKey?: string;
|
|
25
|
+
};
|
|
26
|
+
export type PaymentProof = {
|
|
27
|
+
/** pre-authorised envelope, base64 XDR, as issued by the vault */
|
|
28
|
+
envelope?: string;
|
|
29
|
+
/** hex hash of the envelope, also the vault's preauth signer */
|
|
30
|
+
mandateHash?: string;
|
|
31
|
+
/** approval mode: a signed statement of what the agent wants to buy */
|
|
32
|
+
intent?: {
|
|
33
|
+
payload: string;
|
|
34
|
+
signature: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export declare class Gateway {
|
|
38
|
+
private routes;
|
|
39
|
+
private mandates;
|
|
40
|
+
/** envelope hashes already redeemed, so one mandate = one delivery */
|
|
41
|
+
private settled;
|
|
42
|
+
desk: ApprovalDesk;
|
|
43
|
+
cfg: GatewayConfig;
|
|
44
|
+
constructor(cfg: GatewayConfig);
|
|
45
|
+
/** Mandates the vault currently holds, so the gateway can hand them out. */
|
|
46
|
+
syncMandates(): Promise<any>;
|
|
47
|
+
/** Register a pre-authorised mandate for a route (needs both human keys). */
|
|
48
|
+
authorise(uri: string, spec: Parameters<StellarVault["createMandate"]>[0], principal: Parameters<StellarVault["createMandate"]>[1], approver: Parameters<StellarVault["createMandate"]>[2]): Promise<Mandate>;
|
|
49
|
+
/** The intent must be signed by a real agent key on the vault. */
|
|
50
|
+
private verifyIntent;
|
|
51
|
+
/** Keys allowed to place approval requests. */
|
|
52
|
+
agentKeys: string[];
|
|
53
|
+
requirements(uri: string): {
|
|
54
|
+
scheme: string;
|
|
55
|
+
network: string;
|
|
56
|
+
maxAmountRequired: string;
|
|
57
|
+
resource: string;
|
|
58
|
+
payTo: string;
|
|
59
|
+
token: {
|
|
60
|
+
code: string;
|
|
61
|
+
issuer: string;
|
|
62
|
+
};
|
|
63
|
+
vault: string;
|
|
64
|
+
mode: "approval" | "preauth";
|
|
65
|
+
maxTimeoutSeconds: number;
|
|
66
|
+
version: string;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Check a proof against the live request before spending anything.
|
|
70
|
+
* The envelope must name this gateway's payee and this exact price, and it
|
|
71
|
+
* must be a hash the vault actually recognises.
|
|
72
|
+
*/
|
|
73
|
+
verify(uri: string, proof: PaymentProof): Promise<{
|
|
74
|
+
route: Route;
|
|
75
|
+
tx: Transaction<import("stellar-sdk").Memo<import("stellar-sdk").MemoType>, import("stellar-sdk").Operation[]>;
|
|
76
|
+
hash: string;
|
|
77
|
+
}>;
|
|
78
|
+
handler(getContent: (uri: string) => string): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
79
|
+
listen(port: number, getContent?: (uri: string) => string): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
export { StellarTxError };
|
|
82
|
+
export type { DeskStore };
|