@agentis-hq/cli 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/dist/index.js +1422 -0
- package/package.json +40 -0
- package/templates/facilitator/.env.example.tpl +20 -0
- package/templates/facilitator/README.md.tpl +46 -0
- package/templates/facilitator/kora/kora.toml.tpl +103 -0
- package/templates/facilitator/kora/signers.toml +8 -0
- package/templates/facilitator/package.json.tpl +25 -0
- package/templates/facilitator/src/facilitator.ts +163 -0
- package/templates/facilitator/src/heartbeat.ts +36 -0
- package/templates/facilitator/src/ledger.ts +141 -0
- package/templates/facilitator/tsconfig.json +11 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentis-hq/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"agentis": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"description": "Agentis CLI",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"templates",
|
|
15
|
+
"package.json"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "bun run src/index.ts",
|
|
19
|
+
"build": "tsup",
|
|
20
|
+
"check": "bun --check src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@agentis-hq/core": "^0.1.0",
|
|
24
|
+
"@agentis-hq/sdk": "^0.1.0",
|
|
25
|
+
"@napi-rs/keyring": "^1.2.0",
|
|
26
|
+
"@noble/ciphers": "^2.2.0",
|
|
27
|
+
"@noble/curves": "^2.2.0",
|
|
28
|
+
"@noble/hashes": "^2.2.0",
|
|
29
|
+
"@scure/bip32": "^2.2.0",
|
|
30
|
+
"@scure/bip39": "^2.2.0",
|
|
31
|
+
"@solana-program/system": "^0.12.0",
|
|
32
|
+
"@solana/kit": "^6.8.0",
|
|
33
|
+
"uuid": "^14.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/bun": "latest",
|
|
37
|
+
"tsup": "^8.0.0",
|
|
38
|
+
"typescript": "^5.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
AGENTIS_API_URL={{AGENTIS_API_URL}}
|
|
2
|
+
AGENTIS_FACILITATOR_ID={{FACILITATOR_ID}}
|
|
3
|
+
AGENTIS_HEARTBEAT_SECRET={{HEARTBEAT_SECRET}}
|
|
4
|
+
AGENTIS_PUBLIC_URL=
|
|
5
|
+
|
|
6
|
+
NETWORK={{NETWORK}}
|
|
7
|
+
ACCEPTED_MINT={{ACCEPTED_MINT}}
|
|
8
|
+
FACILITATOR_FEE_BPS={{FEE_BPS}}
|
|
9
|
+
FACILITATOR_PORT=3000
|
|
10
|
+
DATABASE_PATH=data/facilitator.db
|
|
11
|
+
|
|
12
|
+
KORA_RPC_URL=http://localhost:8080/
|
|
13
|
+
KORA_API_KEY={{KORA_API_KEY}}
|
|
14
|
+
KORA_PORT=8080
|
|
15
|
+
SOLANA_RPC_URL=https://api.devnet.solana.com
|
|
16
|
+
KORA_PRIVATE_KEY=
|
|
17
|
+
|
|
18
|
+
ADMIN_TOKEN=change-me
|
|
19
|
+
DEFAULT_SELLER_PAY_TO=
|
|
20
|
+
DEFAULT_SELLER_BALANCE_USD=0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# {{NAME}}
|
|
2
|
+
|
|
3
|
+
Kora-backed x402 facilitator scaffolded by Agentis.
|
|
4
|
+
|
|
5
|
+
## What This Does
|
|
6
|
+
|
|
7
|
+
- Exposes x402 `/verify`, `/settle`, and `/supported`.
|
|
8
|
+
- Uses Kora to sponsor/sign/submit Solana transactions.
|
|
9
|
+
- Keeps a local SQLite seller ledger.
|
|
10
|
+
- Charges facilitator fees from prepaid seller balances.
|
|
11
|
+
- Sends heartbeat metrics to Agentis for operator tracking.
|
|
12
|
+
|
|
13
|
+
## Run
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun install
|
|
17
|
+
cp .env.example .env
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Fill `KORA_PRIVATE_KEY`, then fund that signer with devnet SOL. Start Kora and the facilitator in separate terminals:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bun run kora
|
|
24
|
+
bun run dev
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Seller Ledger
|
|
28
|
+
|
|
29
|
+
The seller should advertise a gross x402 price that already accounts for facilitator fees. This facilitator settles the gross payment to the seller, then deducts its configured fee from the seller's prepaid local ledger.
|
|
30
|
+
|
|
31
|
+
Add or top up a seller:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
curl -X POST http://localhost:3000/admin/sellers \
|
|
35
|
+
-H "content-type: application/json" \
|
|
36
|
+
-H "x-admin-token: $ADMIN_TOKEN" \
|
|
37
|
+
-d '{"payTo":"SELLER_WALLET","topUpUsd":10}'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Publish
|
|
41
|
+
|
|
42
|
+
After deploying publicly:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
agentis facilitator publish {{FACILITATOR_ID}} --url https://your-facilitator.example --listed
|
|
46
|
+
```
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
[kora]
|
|
2
|
+
rate_limit = 100
|
|
3
|
+
|
|
4
|
+
[kora.auth]
|
|
5
|
+
api_key = "{{KORA_API_KEY}}"
|
|
6
|
+
|
|
7
|
+
[kora.cache]
|
|
8
|
+
enabled = false
|
|
9
|
+
default_ttl = 300
|
|
10
|
+
account_ttl = 60
|
|
11
|
+
|
|
12
|
+
[kora.enabled_methods]
|
|
13
|
+
liveness = true
|
|
14
|
+
estimate_transaction_fee = false
|
|
15
|
+
get_supported_tokens = true
|
|
16
|
+
sign_transaction = true
|
|
17
|
+
sign_and_send_transaction = true
|
|
18
|
+
transfer_transaction = false
|
|
19
|
+
get_blockhash = true
|
|
20
|
+
get_config = true
|
|
21
|
+
get_payer_signer = true
|
|
22
|
+
get_version = true
|
|
23
|
+
|
|
24
|
+
[validation]
|
|
25
|
+
max_allowed_lamports = 1000000
|
|
26
|
+
max_signatures = 10
|
|
27
|
+
price_source = "Mock"
|
|
28
|
+
allow_durable_transactions = false
|
|
29
|
+
allowed_programs = [
|
|
30
|
+
"11111111111111111111111111111111",
|
|
31
|
+
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
32
|
+
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
33
|
+
"ComputeBudget111111111111111111111111111111",
|
|
34
|
+
"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"
|
|
35
|
+
]
|
|
36
|
+
allowed_tokens = [
|
|
37
|
+
"{{ACCEPTED_MINT}}"
|
|
38
|
+
]
|
|
39
|
+
allowed_spl_paid_tokens = [
|
|
40
|
+
"{{ACCEPTED_MINT}}"
|
|
41
|
+
]
|
|
42
|
+
disallowed_accounts = []
|
|
43
|
+
|
|
44
|
+
[validation.fee_payer_policy]
|
|
45
|
+
|
|
46
|
+
[validation.fee_payer_policy.system]
|
|
47
|
+
allow_transfer = false
|
|
48
|
+
allow_assign = false
|
|
49
|
+
allow_create_account = false
|
|
50
|
+
allow_allocate = false
|
|
51
|
+
|
|
52
|
+
[validation.fee_payer_policy.system.nonce]
|
|
53
|
+
allow_initialize = false
|
|
54
|
+
allow_advance = false
|
|
55
|
+
allow_authorize = false
|
|
56
|
+
allow_withdraw = false
|
|
57
|
+
|
|
58
|
+
[validation.fee_payer_policy.spl_token]
|
|
59
|
+
allow_transfer = false
|
|
60
|
+
allow_burn = false
|
|
61
|
+
allow_close_account = false
|
|
62
|
+
allow_approve = false
|
|
63
|
+
allow_revoke = false
|
|
64
|
+
allow_set_authority = false
|
|
65
|
+
allow_mint_to = false
|
|
66
|
+
allow_initialize_mint = false
|
|
67
|
+
allow_initialize_account = false
|
|
68
|
+
allow_initialize_multisig = false
|
|
69
|
+
allow_freeze_account = false
|
|
70
|
+
allow_thaw_account = false
|
|
71
|
+
|
|
72
|
+
[validation.fee_payer_policy.token_2022]
|
|
73
|
+
allow_transfer = false
|
|
74
|
+
allow_burn = false
|
|
75
|
+
allow_close_account = false
|
|
76
|
+
allow_approve = false
|
|
77
|
+
allow_revoke = false
|
|
78
|
+
allow_set_authority = false
|
|
79
|
+
allow_mint_to = false
|
|
80
|
+
allow_initialize_mint = false
|
|
81
|
+
allow_initialize_account = false
|
|
82
|
+
allow_initialize_multisig = false
|
|
83
|
+
allow_freeze_account = false
|
|
84
|
+
allow_thaw_account = false
|
|
85
|
+
|
|
86
|
+
[validation.fee_payer_policy.alt]
|
|
87
|
+
allow_create = false
|
|
88
|
+
allow_extend = false
|
|
89
|
+
allow_freeze = false
|
|
90
|
+
allow_deactivate = false
|
|
91
|
+
allow_close = false
|
|
92
|
+
|
|
93
|
+
[validation.price]
|
|
94
|
+
type = "free"
|
|
95
|
+
|
|
96
|
+
[kora.usage_limit]
|
|
97
|
+
enabled = false
|
|
98
|
+
cache_url = "redis://localhost:6379"
|
|
99
|
+
max_transactions = 1000
|
|
100
|
+
fallback_if_unavailable = false
|
|
101
|
+
|
|
102
|
+
[kora.bundle]
|
|
103
|
+
enabled = false
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx src/facilitator.ts",
|
|
8
|
+
"start": "tsx src/facilitator.ts",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"kora": "RPC_URL=${SOLANA_RPC_URL:-https://api.devnet.solana.com} kora --config kora/kora.toml rpc start --signers-config kora/signers.toml --port ${KORA_PORT:-8080}"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@solana/kora": "^0.2.1",
|
|
14
|
+
"@x402/core": "^2.10.0",
|
|
15
|
+
"@x402/svm": "^2.10.0",
|
|
16
|
+
"dotenv": "^17.2.3",
|
|
17
|
+
"express": "^5.2.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/express": "^5.0.0",
|
|
21
|
+
"@types/node": "^24.5.2",
|
|
22
|
+
"tsx": "^4.21.0",
|
|
23
|
+
"typescript": "^5.9.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { config } from 'dotenv'
|
|
2
|
+
import express from 'express'
|
|
3
|
+
import { KoraClient } from '@solana/kora'
|
|
4
|
+
import { SOLANA_DEVNET_CAIP2 } from '@x402/svm'
|
|
5
|
+
import {
|
|
6
|
+
getMetrics,
|
|
7
|
+
listSellers,
|
|
8
|
+
recordSettlement,
|
|
9
|
+
requireSellerCanPayFee,
|
|
10
|
+
upsertSeller,
|
|
11
|
+
microsToDollars,
|
|
12
|
+
} from './ledger'
|
|
13
|
+
import { startHeartbeat } from './heartbeat'
|
|
14
|
+
|
|
15
|
+
config()
|
|
16
|
+
|
|
17
|
+
const app = express()
|
|
18
|
+
app.use(express.json({ limit: '2mb' }))
|
|
19
|
+
|
|
20
|
+
const PORT = Number(process.env.FACILITATOR_PORT ?? 3000)
|
|
21
|
+
const NETWORK = process.env.NETWORK ?? SOLANA_DEVNET_CAIP2
|
|
22
|
+
const KORA_RPC_URL = process.env.KORA_RPC_URL ?? 'http://localhost:8080/'
|
|
23
|
+
const KORA_API_KEY = process.env.KORA_API_KEY ?? ''
|
|
24
|
+
const DEFAULT_FEE_BPS = Number(process.env.FACILITATOR_FEE_BPS ?? 500)
|
|
25
|
+
|
|
26
|
+
function kora() {
|
|
27
|
+
return new KoraClient({ rpcUrl: KORA_RPC_URL, apiKey: KORA_API_KEY })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function paymentAmountMicros(paymentRequirements: any): number {
|
|
31
|
+
const raw = paymentRequirements?.maxAmountRequired ?? paymentRequirements?.amount
|
|
32
|
+
const amount = typeof raw === 'bigint' ? Number(raw) : Number(String(raw ?? '0'))
|
|
33
|
+
if (!Number.isFinite(amount) || amount <= 0) throw new Error('Invalid payment amount')
|
|
34
|
+
return amount
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function paymentPayTo(paymentRequirements: any): string {
|
|
38
|
+
const payTo = String(paymentRequirements?.payTo ?? '')
|
|
39
|
+
if (!payTo) throw new Error('Missing payment recipient')
|
|
40
|
+
return payTo
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function paymentTransaction(paymentPayload: any): string {
|
|
44
|
+
const transaction = paymentPayload?.payload?.transaction
|
|
45
|
+
if (!transaction || typeof transaction !== 'string') throw new Error('Missing Solana transaction in payment payload')
|
|
46
|
+
return transaction
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertNetwork(paymentRequirements: any) {
|
|
50
|
+
const network = String(paymentRequirements?.network ?? '')
|
|
51
|
+
if (!network.startsWith('solana:')) throw new Error('Invalid network')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function requireAdmin(req: express.Request, res: express.Response): boolean {
|
|
55
|
+
const token = process.env.ADMIN_TOKEN
|
|
56
|
+
if (!token || token === 'change-me') {
|
|
57
|
+
res.status(500).json({ error: 'ADMIN_TOKEN must be set before using admin endpoints' })
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
if (req.header('x-admin-token') !== token) {
|
|
61
|
+
res.status(401).json({ error: 'Unauthorized' })
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const defaultSeller = process.env.DEFAULT_SELLER_PAY_TO
|
|
68
|
+
if (defaultSeller) {
|
|
69
|
+
upsertSeller({
|
|
70
|
+
payTo: defaultSeller,
|
|
71
|
+
label: 'default',
|
|
72
|
+
balanceUsd: Number(process.env.DEFAULT_SELLER_BALANCE_USD ?? 0),
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
app.get('/health', (_req, res) => {
|
|
77
|
+
res.json({ ok: true, network: NETWORK, feeBps: DEFAULT_FEE_BPS, ...getMetrics() })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
app.get('/supported', async (_req, res) => {
|
|
81
|
+
try {
|
|
82
|
+
const { signer_address } = await kora().getPayerSigner()
|
|
83
|
+
res.json({
|
|
84
|
+
kinds: [{
|
|
85
|
+
x402Version: 2,
|
|
86
|
+
scheme: 'exact',
|
|
87
|
+
network: NETWORK,
|
|
88
|
+
extra: { feePayer: signer_address },
|
|
89
|
+
}],
|
|
90
|
+
})
|
|
91
|
+
} catch (err) {
|
|
92
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
app.post('/verify', async (req, res) => {
|
|
97
|
+
try {
|
|
98
|
+
const { paymentPayload, paymentRequirements } = req.body
|
|
99
|
+
assertNetwork(paymentRequirements)
|
|
100
|
+
const payTo = paymentPayTo(paymentRequirements)
|
|
101
|
+
const amountMicros = paymentAmountMicros(paymentRequirements)
|
|
102
|
+
requireSellerCanPayFee(payTo, amountMicros, DEFAULT_FEE_BPS)
|
|
103
|
+
await kora().signTransaction({ transaction: paymentTransaction(paymentPayload) })
|
|
104
|
+
res.json({ isValid: true })
|
|
105
|
+
} catch (err) {
|
|
106
|
+
res.status(400).json({
|
|
107
|
+
isValid: false,
|
|
108
|
+
invalidReason: err instanceof Error ? err.message : 'Kora validation failed',
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
app.post('/settle', async (req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const { paymentPayload, paymentRequirements } = req.body
|
|
116
|
+
assertNetwork(paymentRequirements)
|
|
117
|
+
const payTo = paymentPayTo(paymentRequirements)
|
|
118
|
+
const amountMicros = paymentAmountMicros(paymentRequirements)
|
|
119
|
+
const { feeMicros } = requireSellerCanPayFee(payTo, amountMicros, DEFAULT_FEE_BPS)
|
|
120
|
+
const { signature } = await kora().signAndSendTransaction({
|
|
121
|
+
transaction: paymentTransaction(paymentPayload),
|
|
122
|
+
})
|
|
123
|
+
recordSettlement({ payTo, signature, amountMicros, feeMicros })
|
|
124
|
+
res.json({ transaction: signature, success: true, network: NETWORK })
|
|
125
|
+
} catch (err) {
|
|
126
|
+
res.status(400).json({
|
|
127
|
+
transaction: '',
|
|
128
|
+
success: false,
|
|
129
|
+
network: NETWORK,
|
|
130
|
+
errorReason: err instanceof Error ? err.message : 'Kora settlement failed',
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
app.get('/admin/sellers', (req, res) => {
|
|
136
|
+
if (!requireAdmin(req, res)) return
|
|
137
|
+
res.json(listSellers().map((seller: any) => ({
|
|
138
|
+
...seller,
|
|
139
|
+
balanceUsd: microsToDollars(seller.balanceMicros),
|
|
140
|
+
})))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
app.post('/admin/sellers', (req, res) => {
|
|
144
|
+
if (!requireAdmin(req, res)) return
|
|
145
|
+
const payTo = String(req.body?.payTo ?? '')
|
|
146
|
+
if (!payTo) return res.status(400).json({ error: 'payTo is required' })
|
|
147
|
+
const seller = upsertSeller({
|
|
148
|
+
payTo,
|
|
149
|
+
label: req.body?.label ? String(req.body.label) : undefined,
|
|
150
|
+
topUpUsd: req.body?.topUpUsd === undefined ? undefined : Number(req.body.topUpUsd),
|
|
151
|
+
balanceUsd: req.body?.balanceUsd === undefined ? undefined : Number(req.body.balanceUsd),
|
|
152
|
+
feeBps: req.body?.feeBps === undefined ? undefined : Number(req.body.feeBps),
|
|
153
|
+
active: req.body?.active === undefined ? undefined : Boolean(req.body.active),
|
|
154
|
+
})
|
|
155
|
+
res.json({ ...seller, balanceUsd: microsToDollars(seller.balanceMicros) })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
startHeartbeat()
|
|
159
|
+
|
|
160
|
+
app.listen(PORT, () => {
|
|
161
|
+
console.log(`Agentis facilitator listening at http://localhost:${PORT}`)
|
|
162
|
+
console.log(`Kora RPC: ${KORA_RPC_URL}`)
|
|
163
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getMetrics } from './ledger'
|
|
2
|
+
|
|
3
|
+
export function startHeartbeat() {
|
|
4
|
+
const apiUrl = process.env.AGENTIS_API_URL
|
|
5
|
+
const id = process.env.AGENTIS_FACILITATOR_ID
|
|
6
|
+
const secret = process.env.AGENTIS_HEARTBEAT_SECRET
|
|
7
|
+
if (!apiUrl || !id || !secret) {
|
|
8
|
+
console.warn('[heartbeat] disabled: missing Agentis heartbeat env')
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const send = async () => {
|
|
13
|
+
try {
|
|
14
|
+
const metrics = getMetrics()
|
|
15
|
+
await fetch(`${apiUrl}/facilitators/${id}/heartbeat`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: {
|
|
18
|
+
'content-type': 'application/json',
|
|
19
|
+
'x-agentis-heartbeat-secret': secret,
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
publicUrl: process.env.AGENTIS_PUBLIC_URL || null,
|
|
23
|
+
version: 'agentis-facilitator/0.1.0',
|
|
24
|
+
supported: ['x402', process.env.NETWORK ?? 'solana-devnet'],
|
|
25
|
+
feeBps: Number(process.env.FACILITATOR_FEE_BPS ?? 500),
|
|
26
|
+
...metrics,
|
|
27
|
+
}),
|
|
28
|
+
})
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.warn('[heartbeat] failed', err instanceof Error ? err.message : err)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
void send()
|
|
35
|
+
setInterval(send, 30_000).unref()
|
|
36
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
import { mkdirSync } from 'fs'
|
|
3
|
+
import { dirname } from 'path'
|
|
4
|
+
|
|
5
|
+
export type Seller = {
|
|
6
|
+
payTo: string
|
|
7
|
+
label: string | null
|
|
8
|
+
balanceMicros: number
|
|
9
|
+
feeBps: number | null
|
|
10
|
+
active: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const dbPath = process.env.DATABASE_PATH ?? 'data/facilitator.db'
|
|
14
|
+
mkdirSync(dirname(dbPath), { recursive: true })
|
|
15
|
+
|
|
16
|
+
const db = new DatabaseSync(dbPath)
|
|
17
|
+
db.exec(`
|
|
18
|
+
PRAGMA journal_mode = WAL;
|
|
19
|
+
CREATE TABLE IF NOT EXISTS sellers (
|
|
20
|
+
pay_to TEXT PRIMARY KEY,
|
|
21
|
+
label TEXT,
|
|
22
|
+
balance_micros INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
fee_bps INTEGER,
|
|
24
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
25
|
+
created_at TEXT NOT NULL,
|
|
26
|
+
updated_at TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS settlements (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
pay_to TEXT NOT NULL,
|
|
31
|
+
transaction_signature TEXT NOT NULL,
|
|
32
|
+
amount_micros INTEGER NOT NULL,
|
|
33
|
+
fee_micros INTEGER NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL
|
|
35
|
+
);
|
|
36
|
+
`)
|
|
37
|
+
|
|
38
|
+
export function dollarsToMicros(value: number): number {
|
|
39
|
+
return Math.round(value * 1_000_000)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function microsToDollars(value: number): number {
|
|
43
|
+
return value / 1_000_000
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getSeller(payTo: string): Seller | null {
|
|
47
|
+
const row = db.prepare(`
|
|
48
|
+
SELECT pay_to as payTo, label, balance_micros as balanceMicros, fee_bps as feeBps, active
|
|
49
|
+
FROM sellers
|
|
50
|
+
WHERE pay_to = ?
|
|
51
|
+
`).get(payTo) as Seller | null
|
|
52
|
+
return row
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function upsertSeller(input: { payTo: string; label?: string | null; topUpUsd?: number; balanceUsd?: number; feeBps?: number | null; active?: boolean }): Seller {
|
|
56
|
+
const current = getSeller(input.payTo)
|
|
57
|
+
const now = new Date().toISOString()
|
|
58
|
+
const balanceMicros = input.balanceUsd === undefined
|
|
59
|
+
? (current?.balanceMicros ?? 0) + dollarsToMicros(input.topUpUsd ?? 0)
|
|
60
|
+
: dollarsToMicros(input.balanceUsd)
|
|
61
|
+
|
|
62
|
+
if (current) {
|
|
63
|
+
db.prepare(`
|
|
64
|
+
UPDATE sellers
|
|
65
|
+
SET label = ?, balance_micros = ?, fee_bps = ?, active = ?, updated_at = ?
|
|
66
|
+
WHERE pay_to = ?
|
|
67
|
+
`).run(
|
|
68
|
+
input.label ?? current.label,
|
|
69
|
+
balanceMicros,
|
|
70
|
+
input.feeBps ?? current.feeBps,
|
|
71
|
+
input.active === undefined ? current.active : Number(input.active),
|
|
72
|
+
now,
|
|
73
|
+
input.payTo,
|
|
74
|
+
)
|
|
75
|
+
} else {
|
|
76
|
+
db.prepare(`
|
|
77
|
+
INSERT INTO sellers (pay_to, label, balance_micros, fee_bps, active, created_at, updated_at)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
79
|
+
`).run(
|
|
80
|
+
input.payTo,
|
|
81
|
+
input.label ?? null,
|
|
82
|
+
balanceMicros,
|
|
83
|
+
input.feeBps ?? null,
|
|
84
|
+
input.active === undefined ? 1 : Number(input.active),
|
|
85
|
+
now,
|
|
86
|
+
now,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return getSeller(input.payTo)!
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function requireSellerCanPayFee(payTo: string, amountMicros: number, defaultFeeBps: number): { seller: Seller; feeMicros: number } {
|
|
94
|
+
const seller = getSeller(payTo)
|
|
95
|
+
if (!seller || !seller.active) throw new Error('Seller is not enabled on this facilitator')
|
|
96
|
+
const feeBps = seller.feeBps ?? defaultFeeBps
|
|
97
|
+
const feeMicros = Math.ceil(amountMicros * feeBps / 10_000)
|
|
98
|
+
if (seller.balanceMicros < feeMicros) throw new Error('Seller prepaid facilitator balance is too low')
|
|
99
|
+
return { seller, feeMicros }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function recordSettlement(input: { payTo: string; signature: string; amountMicros: number; feeMicros: number }) {
|
|
103
|
+
const now = new Date().toISOString()
|
|
104
|
+
db.exec('BEGIN IMMEDIATE')
|
|
105
|
+
try {
|
|
106
|
+
db.prepare(`
|
|
107
|
+
UPDATE sellers
|
|
108
|
+
SET balance_micros = balance_micros - ?, updated_at = ?
|
|
109
|
+
WHERE pay_to = ?
|
|
110
|
+
`).run(input.feeMicros, now, input.payTo)
|
|
111
|
+
db.prepare(`
|
|
112
|
+
INSERT INTO settlements (id, pay_to, transaction_signature, amount_micros, fee_micros, created_at)
|
|
113
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
114
|
+
`).run(crypto.randomUUID(), input.payTo, input.signature, input.amountMicros, input.feeMicros, now)
|
|
115
|
+
db.exec('COMMIT')
|
|
116
|
+
} catch (err) {
|
|
117
|
+
db.exec('ROLLBACK')
|
|
118
|
+
throw err
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function listSellers() {
|
|
123
|
+
return db.prepare(`
|
|
124
|
+
SELECT pay_to as payTo, label, balance_micros as balanceMicros, fee_bps as feeBps, active
|
|
125
|
+
FROM sellers
|
|
126
|
+
ORDER BY updated_at DESC
|
|
127
|
+
`).all()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getMetrics() {
|
|
131
|
+
const settled = db.prepare(`
|
|
132
|
+
SELECT COUNT(*) as count, COALESCE(SUM(amount_micros), 0) as volumeMicros
|
|
133
|
+
FROM settlements
|
|
134
|
+
`).get() as { count: number; volumeMicros: number }
|
|
135
|
+
const sellers = db.prepare(`SELECT COUNT(*) as count FROM sellers WHERE active = 1`).get() as { count: number }
|
|
136
|
+
return {
|
|
137
|
+
settledCount: settled.count,
|
|
138
|
+
settledVolumeUsd: microsToDollars(settled.volumeMicros),
|
|
139
|
+
sellerCount: sellers.count,
|
|
140
|
+
}
|
|
141
|
+
}
|