@haven_ai/sdk 0.0.0-dev.202609031523.fd49e1a
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/README.md +581 -0
- package/dist/index.cjs +4726 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2808 -0
- package/dist/index.d.ts +2808 -0
- package/dist/index.js +4628 -0
- package/dist/index.js.map +1 -0
- package/examples/mcp-x402-sse.ts +149 -0
- package/examples/x402_openapi_python.py +119 -0
- package/package.json +67 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import {
|
|
3
|
+
HavenClient,
|
|
4
|
+
HavenPaymentStateError,
|
|
5
|
+
type X402ResumeState,
|
|
6
|
+
} from '@haven_ai/sdk'
|
|
7
|
+
|
|
8
|
+
const mcpUrl = process.env.MCP_URL
|
|
9
|
+
const apiKey = process.env.HAVEN_API_KEY
|
|
10
|
+
const delegateKey = process.env.HAVEN_DELEGATE_KEY
|
|
11
|
+
const baseUrl = process.env.HAVEN_API_URL
|
|
12
|
+
const maxUsd = Number(process.env.MAX_X402_USD ?? '0.05')
|
|
13
|
+
const resumeFile = process.env.HAVEN_X402_RESUME_FILE ?? '.haven-x402-resume.json'
|
|
14
|
+
const resumePaymentId = process.env.HAVEN_RESUME_PAYMENT_ID
|
|
15
|
+
|
|
16
|
+
if (!mcpUrl) throw new Error('MCP_URL is required')
|
|
17
|
+
if (!apiKey) throw new Error('HAVEN_API_KEY is required')
|
|
18
|
+
if (!delegateKey) throw new Error('HAVEN_DELEGATE_KEY is required')
|
|
19
|
+
|
|
20
|
+
const haven = new HavenClient({ apiKey, delegateKey, baseUrl })
|
|
21
|
+
|
|
22
|
+
async function initializeMcpSession(): Promise<string> {
|
|
23
|
+
const response = await fetch(mcpUrl!, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
Accept: 'application/json, text/event-stream',
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
jsonrpc: '2.0',
|
|
31
|
+
id: 'init-1',
|
|
32
|
+
method: 'initialize',
|
|
33
|
+
params: {
|
|
34
|
+
protocolVersion: '2025-03-26',
|
|
35
|
+
capabilities: {},
|
|
36
|
+
clientInfo: {
|
|
37
|
+
name: 'haven-x402-mcp-example',
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const sessionId = response.headers.get('mcp-session-id')
|
|
45
|
+
if (!sessionId) {
|
|
46
|
+
throw new Error('MCP initialize response did not include mcp-session-id')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return sessionId
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function paidToolCallInit(sessionId: string): RequestInit {
|
|
53
|
+
return {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
Accept: 'application/json, text/event-stream',
|
|
58
|
+
'mcp-session-id': sessionId,
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
jsonrpc: '2.0',
|
|
62
|
+
id: 'paid-call-1',
|
|
63
|
+
method: 'tools/call',
|
|
64
|
+
params: {
|
|
65
|
+
name: process.env.MCP_TOOL ?? 'paid_tool',
|
|
66
|
+
arguments: process.env.MCP_TOOL_ARGS
|
|
67
|
+
? JSON.parse(process.env.MCP_TOOL_ARGS)
|
|
68
|
+
: {},
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function capturedRequest(url: string, init: RequestInit): X402ResumeState['request'] {
|
|
75
|
+
return {
|
|
76
|
+
url,
|
|
77
|
+
method: init.method ?? 'GET',
|
|
78
|
+
headers: Array.from(new Headers(init.headers).entries()),
|
|
79
|
+
body: typeof init.body === 'string' ? init.body : undefined,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function printResponse(response: Response): Promise<void> {
|
|
84
|
+
console.log('HTTP', response.status, response.statusText)
|
|
85
|
+
console.log(await response.text())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function resumeFromSavedState(): Promise<void> {
|
|
89
|
+
const state = JSON.parse(await readFile(resumeFile, 'utf8')) as X402ResumeState
|
|
90
|
+
const response = await haven.resumeX402Payment(state)
|
|
91
|
+
await printResponse(response)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resumeFromPaymentId(paymentId: string): Promise<void> {
|
|
95
|
+
const state = await haven.getResumeState(paymentId)
|
|
96
|
+
if (state.rail !== 'x402') {
|
|
97
|
+
throw new Error(`Payment ${paymentId} is ${state.rail}; this example resumes x402 payments only.`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sessionId = await initializeMcpSession()
|
|
101
|
+
const request = paidToolCallInit(sessionId)
|
|
102
|
+
state.request = capturedRequest(mcpUrl!, request)
|
|
103
|
+
state.url = state.request.url
|
|
104
|
+
|
|
105
|
+
const response = await haven.resumeX402Payment(state)
|
|
106
|
+
await printResponse(response)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function quoteAndPay(): Promise<void> {
|
|
110
|
+
const sessionId = await initializeMcpSession()
|
|
111
|
+
const request = paidToolCallInit(sessionId)
|
|
112
|
+
const idempotencyKey = process.env.HAVEN_X402_IDEMPOTENCY_KEY ?? `mcp:${sessionId}:paid-call-1`
|
|
113
|
+
|
|
114
|
+
const quote = await haven.quoteX402(mcpUrl!, request, { idempotencyKey })
|
|
115
|
+
console.log('Quote', {
|
|
116
|
+
amount: quote.amount,
|
|
117
|
+
token: quote.token,
|
|
118
|
+
network: quote.network,
|
|
119
|
+
merchantAddress: quote.merchantAddress,
|
|
120
|
+
resourceUrl: quote.resourceUrl,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
if (Number(quote.amount) > maxUsd) {
|
|
124
|
+
throw new Error(`Quote ${quote.amount} ${quote.token} is above cap ${maxUsd}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const response = await haven.payX402Quote(quote)
|
|
129
|
+
await printResponse(response)
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof HavenPaymentStateError && err.resumeState) {
|
|
132
|
+
await writeFile(resumeFile, JSON.stringify(err.resumeState, null, 2))
|
|
133
|
+
console.error(`Payment did not complete in one pass. Resume state saved to ${resumeFile}.`)
|
|
134
|
+
console.error(`Once the funding leg confirms, rerun with HAVEN_RESUME_PAYMENT_ID=${err.resumeState.paymentId}.`)
|
|
135
|
+
console.error(`The local file fallback is still available with HAVEN_X402_RESUME=1.`)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw err
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (resumePaymentId) {
|
|
144
|
+
await resumeFromPaymentId(resumePaymentId)
|
|
145
|
+
} else if (process.env.HAVEN_X402_RESUME === '1') {
|
|
146
|
+
await resumeFromSavedState()
|
|
147
|
+
} else {
|
|
148
|
+
await quoteAndPay()
|
|
149
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Minimal non-TypeScript x402 proof of concept for the Haven OpenAPI surface.
|
|
4
|
+
|
|
5
|
+
Requires:
|
|
6
|
+
pip install requests eth-keys
|
|
7
|
+
|
|
8
|
+
Environment:
|
|
9
|
+
HAVEN_API_KEY sk_agent_* from Haven
|
|
10
|
+
HAVEN_DELEGATE_KEY local agent delegate private key; never sent to Haven
|
|
11
|
+
HAVEN_API_URL default: https://havenbackend-production-8a00.up.railway.app
|
|
12
|
+
HAVEN_X402_URL required: URL of any x402-gated (HTTP 402) resource to pay for
|
|
13
|
+
|
|
14
|
+
This intentionally uses only documented HTTP endpoints:
|
|
15
|
+
GET /openapi.json
|
|
16
|
+
POST /x402/authorize
|
|
17
|
+
POST /payments/{id}/sign
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
from eth_keys import keys
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
API = os.environ.get("HAVEN_API_URL", "https://havenbackend-production-8a00.up.railway.app").rstrip("/")
|
|
30
|
+
PAID_URL = os.environ["HAVEN_X402_URL"] # any x402-gated resource to pay for
|
|
31
|
+
API_KEY = os.environ["HAVEN_API_KEY"]
|
|
32
|
+
DELEGATE_KEY = os.environ["HAVEN_DELEGATE_KEY"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def b64_json(value):
|
|
36
|
+
return base64.b64encode(json.dumps(value).encode()).decode()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_payment_required(response):
|
|
40
|
+
header = response.headers.get("PAYMENT-REQUIRED")
|
|
41
|
+
if not header:
|
|
42
|
+
raise RuntimeError(f"Expected PAYMENT-REQUIRED header, got HTTP {response.status_code}: {response.text}")
|
|
43
|
+
return json.loads(base64.b64decode(header).decode())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def post_haven(path, payload):
|
|
47
|
+
response = requests.post(
|
|
48
|
+
f"{API}{path}",
|
|
49
|
+
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
|
|
50
|
+
json=payload,
|
|
51
|
+
timeout=60,
|
|
52
|
+
)
|
|
53
|
+
body = response.json()
|
|
54
|
+
if response.status_code >= 400:
|
|
55
|
+
raise RuntimeError(f"{path} failed with HTTP {response.status_code}: {body}")
|
|
56
|
+
return body
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sign_raw_hash(sign_hash, private_key):
|
|
60
|
+
"""Sign the raw Safe hash exactly as /payments/{id}/sign expects."""
|
|
61
|
+
key_hex = private_key[2:] if private_key.startswith("0x") else private_key
|
|
62
|
+
hash_hex = sign_hash[2:] if sign_hash.startswith("0x") else sign_hash
|
|
63
|
+
signature = keys.PrivateKey(bytes.fromhex(key_hex)).sign_msg_hash(bytes.fromhex(hash_hex))
|
|
64
|
+
v = signature.v + 27 if signature.v in (0, 1) else signature.v
|
|
65
|
+
return (
|
|
66
|
+
"0x"
|
|
67
|
+
+ signature.r.to_bytes(32, "big").hex()
|
|
68
|
+
+ signature.s.to_bytes(32, "big").hex()
|
|
69
|
+
+ bytes([v]).hex()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
spec = requests.get(f"{API}/openapi.json", timeout=30).json()
|
|
74
|
+
authorize_path = "/x402/authorize" if "/x402/authorize" in spec["paths"] else "/x402"
|
|
75
|
+
|
|
76
|
+
initial = requests.get(PAID_URL, timeout=30)
|
|
77
|
+
if initial.status_code != 402:
|
|
78
|
+
raise RuntimeError(f"Expected paid resource to return HTTP 402, got {initial.status_code}")
|
|
79
|
+
|
|
80
|
+
payment_required = decode_payment_required(initial)
|
|
81
|
+
accepted = payment_required["accepts"][0]
|
|
82
|
+
|
|
83
|
+
authorization = post_haven(authorize_path, {
|
|
84
|
+
"url": payment_required["resource"]["url"],
|
|
85
|
+
"payTo": accepted["payTo"],
|
|
86
|
+
"merchantPayTo": accepted["payTo"],
|
|
87
|
+
"amount": accepted.get("amount") or accepted["maxAmountRequired"],
|
|
88
|
+
"asset": accepted["asset"],
|
|
89
|
+
"network": accepted["network"],
|
|
90
|
+
"description": payment_required["resource"].get("description"),
|
|
91
|
+
"idempotencyKey": f"python-openapi:{payment_required['resource']['url']}",
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
# A payment outside the agent's on-chain budget is DECLINED before any money
|
|
95
|
+
# moves — /x402/authorize answers 403 delegation_budget_exceeded and no intent
|
|
96
|
+
# row is created. There is no approval queue to wait on, so there is nothing to
|
|
97
|
+
# poll: raise the budget in Haven and run this again.
|
|
98
|
+
if not authorization.get("sign_data"):
|
|
99
|
+
print(json.dumps({
|
|
100
|
+
"status": authorization.get("status"),
|
|
101
|
+
"payment_id": authorization.get("payment_id"),
|
|
102
|
+
"next_action": authorization.get("next_action"),
|
|
103
|
+
"message": authorization.get("message"),
|
|
104
|
+
}, indent=2))
|
|
105
|
+
sys.exit("Haven returned no signing payload for this payment — nothing to sign and nothing queued.")
|
|
106
|
+
|
|
107
|
+
sign_hash = authorization["sign_data"]["hash"]
|
|
108
|
+
signature = sign_raw_hash(sign_hash, DELEGATE_KEY)
|
|
109
|
+
|
|
110
|
+
result = post_haven(f"/payments/{authorization['payment_id']}/sign", {"signature": signature})
|
|
111
|
+
tx_hash = result["tx_hash"]
|
|
112
|
+
|
|
113
|
+
paid = requests.get(PAID_URL, headers={"PAYMENT-SIGNATURE": b64_json({"txHash": tx_hash})}, timeout=30)
|
|
114
|
+
print(json.dumps({
|
|
115
|
+
"status": paid.status_code,
|
|
116
|
+
"payment_id": authorization["payment_id"],
|
|
117
|
+
"tx_hash": tx_hash,
|
|
118
|
+
"response": paid.json(),
|
|
119
|
+
}, indent=2))
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@haven_ai/sdk",
|
|
3
|
+
"version": "0.0.0-dev.202609031523.fd49e1a",
|
|
4
|
+
"description": "TypeScript SDK for Haven — give AI agents budgeted, non-custodial payment ability. Pay x402 (HTTP 402) APIs in USDC within on-chain-enforced spending limits; the agent never holds funds or keys.",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.cjs",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"require": {
|
|
19
|
+
"types": "./dist/index.d.cts",
|
|
20
|
+
"default": "./dist/index.cjs"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md",
|
|
27
|
+
"examples"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"dev": "tsup --watch",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"typecheck": "tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"haven",
|
|
37
|
+
"ai-agent",
|
|
38
|
+
"agent-payments",
|
|
39
|
+
"x402",
|
|
40
|
+
"402",
|
|
41
|
+
"payments",
|
|
42
|
+
"spending-limit",
|
|
43
|
+
"budget",
|
|
44
|
+
"usdc",
|
|
45
|
+
"non-custodial",
|
|
46
|
+
"mcp",
|
|
47
|
+
"agentic-commerce",
|
|
48
|
+
"web3"
|
|
49
|
+
],
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/d-hinders/Haven-AI.git",
|
|
54
|
+
"directory": "packages/sdk"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"ethers": "^6.13.0",
|
|
58
|
+
"viem": "^2.51.0",
|
|
59
|
+
"x402": "^1.2.0"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"tsup": "^8.0.0",
|
|
63
|
+
"typescript": "^5.7.0",
|
|
64
|
+
"vitest": "^3.2.4"
|
|
65
|
+
},
|
|
66
|
+
"homepage": "https://haven.xyz"
|
|
67
|
+
}
|