@haven_ai/sdk 0.1.2 → 0.1.4
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 +154 -13
- package/dist/index.cjs +1111 -92
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +405 -8
- package/dist/index.d.ts +405 -8
- package/dist/index.js +1100 -93
- package/dist/index.js.map +1 -1
- package/examples/mcp-x402-sse.ts +149 -0
- package/examples/x402_openapi_python.py +114 -0
- package/package.json +4 -3
|
@@ -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(`Waiting for user approval in Haven. Resume state saved to ${resumeFile}.`)
|
|
134
|
+
console.error(`After approval, 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,114 @@
|
|
|
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 default: $HAVEN_API_URL/demo/x402/data
|
|
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.get("HAVEN_X402_URL", f"{API}/demo/x402/data")
|
|
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
|
+
if authorization.get("status") == "pending_approval":
|
|
95
|
+
print(json.dumps({
|
|
96
|
+
"payment_id": authorization["payment_id"],
|
|
97
|
+
"next_action": authorization.get("next_action"),
|
|
98
|
+
"message": authorization.get("message"),
|
|
99
|
+
}, indent=2))
|
|
100
|
+
sys.exit("Payment is waiting for approval in Haven. Re-run after approval and use /payments/{id}/resume_state.")
|
|
101
|
+
|
|
102
|
+
sign_hash = authorization["sign_data"]["hash"]
|
|
103
|
+
signature = sign_raw_hash(sign_hash, DELEGATE_KEY)
|
|
104
|
+
|
|
105
|
+
result = post_haven(f"/payments/{authorization['payment_id']}/sign", {"signature": signature})
|
|
106
|
+
tx_hash = result["tx_hash"]
|
|
107
|
+
|
|
108
|
+
paid = requests.get(PAID_URL, headers={"PAYMENT-SIGNATURE": b64_json({"txHash": tx_hash})}, timeout=30)
|
|
109
|
+
print(json.dumps({
|
|
110
|
+
"status": paid.status_code,
|
|
111
|
+
"payment_id": authorization["payment_id"],
|
|
112
|
+
"tx_hash": tx_hash,
|
|
113
|
+
"response": paid.json(),
|
|
114
|
+
}, indent=2))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haven_ai/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "TypeScript SDK for Haven — agent wallet infrastructure",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"dist",
|
|
23
|
-
"README.md"
|
|
23
|
+
"README.md",
|
|
24
|
+
"examples"
|
|
24
25
|
],
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build": "tsup",
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
"license": "MIT",
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"ethers": "^6.13.0",
|
|
44
|
-
"viem": "^2.
|
|
45
|
+
"viem": "^2.51.0",
|
|
45
46
|
"x402": "^1.2.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|