@stableyard/mppx-stableyard 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/src/client.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { Credential, Method } from 'mppx'
2
+ import { charge } from './method.js'
3
+ import { StableyardAPI, type StableyardConfig, type Session } from './stableyard-api.js'
4
+
5
+ /** Deposit info returned by Stableyard for the agent to pay */
6
+ export type DepositInfo = {
7
+ /** Deposit type: "direct_transfer" (same-chain) or "gasyard" (cross-chain via gateway) */
8
+ type: string
9
+ /** Address to send funds to (for direct transfer) or gateway address */
10
+ address?: string
11
+ gatewayAddress?: string
12
+ /** Chain ID */
13
+ chainId: number
14
+ /** Token details */
15
+ token: { address: string; symbol: string; decimals: number }
16
+ /** Amount to send */
17
+ amount: { raw: string; formatted: string }
18
+ /** Payment methods — includes gateway calldata for cross-chain */
19
+ methods: Array<{
20
+ type: string
21
+ address?: string
22
+ gatewayAddress?: string
23
+ transactions?: Array<{ type: string; to: string; data: string; value: string; chainId: number }>
24
+ gaslessSupported?: boolean
25
+ gaslessEndpoint?: string
26
+ instructions?: string[]
27
+ }>
28
+ }
29
+
30
+ /**
31
+ * Function that executes the on-chain payment.
32
+ * Receives the full deposit info so the agent can choose the best method
33
+ * (direct transfer, gateway, or gasless).
34
+ */
35
+ export type SendPaymentFn = (deposit: DepositInfo, session: Session) => Promise<string | null>
36
+
37
+ export type ClientConfig = StableyardConfig & {
38
+ /** Preferred chain for payment (e.g., "base", "polygon", "arbitrum") */
39
+ chain?: string
40
+ /** Maximum time to wait for settlement in ms (defaults to 60000) */
41
+ settlementTimeoutMs?: number
42
+ /**
43
+ * Function to execute the on-chain payment.
44
+ * Receives deposit info with address/calldata.
45
+ * Returns tx hash, or null if payment was handled externally (e.g., gasless).
46
+ *
47
+ * If not provided, the client will create the session and wait
48
+ * for external payment (e.g., via checkoutUrl).
49
+ */
50
+ sendPayment?: SendPaymentFn
51
+ }
52
+
53
+ /**
54
+ * Creates a Stableyard charge method for the client side.
55
+ *
56
+ * When the client encounters a 402 with method="stableyard", it automatically:
57
+ * 1. Creates a Stableyard session with sourceChain → gets deposit address inline
58
+ * 2. Calls sendPayment to execute the transfer (if provided)
59
+ * 3. Submits tx hash for faster detection
60
+ * 4. Polls until settled
61
+ * 5. Returns credential with sessionId as proof
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * import { Mppx } from 'mppx/client'
66
+ * import { stableyard } from 'mppx-stableyard/client'
67
+ *
68
+ * Mppx.create({
69
+ * methods: [
70
+ * stableyard({
71
+ * apiKey: 'sy_secret_...',
72
+ * chain: 'base',
73
+ * sendPayment: async (deposit) => {
74
+ * // Direct transfer: send USDC to deposit address
75
+ * if (deposit.type === 'direct_transfer') {
76
+ * return await sendERC20(deposit.address, deposit.amount.raw)
77
+ * }
78
+ * // Cross-chain: execute gateway transactions
79
+ * for (const tx of deposit.methods[0].transactions) {
80
+ * await walletClient.sendTransaction(tx)
81
+ * }
82
+ * return txHash
83
+ * },
84
+ * }),
85
+ * ],
86
+ * })
87
+ * ```
88
+ */
89
+ export function stableyard(config: ClientConfig) {
90
+ const api = new StableyardAPI({
91
+ apiKey: config.apiKey,
92
+ baseUrl: config.baseUrl,
93
+ })
94
+
95
+ const chain = config.chain ?? 'base'
96
+ const settlementTimeoutMs = config.settlementTimeoutMs ?? 60_000
97
+
98
+ return Method.toClient(charge, {
99
+ async createCredential({ challenge }) {
100
+ const { amount, currency, destination } = challenge.request
101
+
102
+ // 1. Create session with sourceChain → deposit address inline
103
+ const session = await api.createSession({
104
+ amount: Number(amount),
105
+ destination,
106
+ currency: (currency === 'USDC' || currency === 'USDT') ? currency : 'USDC',
107
+ sourceChain: chain,
108
+ resource: `${challenge.realm}/${challenge.intent}`,
109
+ metadata: {
110
+ mpp_challenge_id: challenge.id,
111
+ mpp_method: 'stableyard',
112
+ },
113
+ })
114
+
115
+ // 2. Execute payment if sendPayment provided and deposit info available
116
+ if (config.sendPayment && session.deposit) {
117
+ const txHash = await config.sendPayment(session.deposit as unknown as DepositInfo, session)
118
+
119
+ // 3. Submit tx hash for faster detection
120
+ if (txHash) {
121
+ await api.submitTx(session.id, txHash, chain).catch(() => {
122
+ // Non-fatal — Stableyard may detect independently
123
+ })
124
+ }
125
+ }
126
+
127
+ // 4. Wait for settlement
128
+ await api.waitForSettlement(session.id, settlementTimeoutMs)
129
+
130
+ // 5. Return credential with session ID as proof
131
+ return Credential.serialize(
132
+ Credential.from({
133
+ challenge,
134
+ payload: {
135
+ sessionId: session.id,
136
+ },
137
+ }),
138
+ )
139
+ },
140
+ })
141
+ }
142
+
143
+ export { charge } from './method.js'
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { charge } from './method.js'
2
+ export { StableyardAPI } from './stableyard-api.js'
3
+ export type { StableyardConfig, CreateSessionParams, Session, QuoteResponse, VerifyResponse } from './stableyard-api.js'
package/src/method.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { Method, z } from 'mppx'
2
+
3
+ /**
4
+ * Stableyard charge method definition for MPP.
5
+ *
6
+ * Enables cross-chain payments via Stableyard's settlement network.
7
+ * Agent pays from any supported chain → Stableyard routes → merchant
8
+ * receives on their preferred chain/token or fiat to bank.
9
+ */
10
+ export const charge = Method.from({
11
+ name: 'stableyard',
12
+ intent: 'charge',
13
+ schema: {
14
+ credential: {
15
+ payload: z.object({
16
+ /** Stableyard session ID proving payment */
17
+ sessionId: z.string(),
18
+ /** Transaction hash (optional, for faster detection) */
19
+ txHash: z.optional(z.string()),
20
+ }),
21
+ },
22
+ request: z.object({
23
+ /** Amount in smallest unit (e.g., "1000000" for $1 USDC with 6 decimals) */
24
+ amount: z.string(),
25
+ /** Currency identifier (e.g., "USDC", "USDT") */
26
+ currency: z.string(),
27
+ /** Number of decimal places for the currency */
28
+ decimals: z.number(),
29
+ /** Stableyard destination — username (merchant@stableyard) or wallet address */
30
+ destination: z.string(),
31
+ /** Human-readable description */
32
+ description: z.optional(z.string()),
33
+ /** External reference ID for merchant reconciliation */
34
+ externalId: z.optional(z.string()),
35
+ }),
36
+ },
37
+ })
package/src/server.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { Method } from 'mppx'
2
+ import { charge } from './method.js'
3
+ import { StableyardAPI, type StableyardConfig } from './stableyard-api.js'
4
+
5
+ export type ServerConfig = StableyardConfig & {
6
+ /** Stableyard destination for receiving payments (e.g., "merchant@stableyard" or wallet address) */
7
+ destination: string
8
+ /** Default currency (defaults to "USDC") */
9
+ currency?: string
10
+ /** Number of decimal places (defaults to 6 for USDC) */
11
+ decimals?: number
12
+ /** Maximum time to wait for settlement verification in ms (defaults to 30000) */
13
+ verifyTimeoutMs?: number
14
+ }
15
+
16
+ /**
17
+ * Creates a Stableyard charge method for the server side.
18
+ *
19
+ * Accepts payments from any chain supported by Stableyard (Base, Arbitrum,
20
+ * Polygon, BNB, Ethereum, Solana, Movement) and settles to the merchant's
21
+ * preferred chain/token/fiat.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { Mppx } from 'mppx/server'
26
+ * import { stableyard } from 'mppx-stableyard/server'
27
+ *
28
+ * const mppx = Mppx.create({
29
+ * methods: [
30
+ * stableyard({
31
+ * apiKey: 'sy_secret_...',
32
+ * destination: 'merchant@stableyard',
33
+ * }),
34
+ * ],
35
+ * })
36
+ * ```
37
+ */
38
+ export function stableyard(config: ServerConfig) {
39
+ const api = new StableyardAPI({
40
+ apiKey: config.apiKey,
41
+ baseUrl: config.baseUrl,
42
+ })
43
+
44
+ const currency = config.currency ?? 'USDC'
45
+ const decimals = config.decimals ?? 6
46
+ const verifyTimeoutMs = config.verifyTimeoutMs ?? 30_000
47
+
48
+ return Method.toServer(charge, {
49
+ defaults: {
50
+ currency,
51
+ decimals,
52
+ destination: config.destination,
53
+ },
54
+
55
+ async verify({ credential, request }) {
56
+ const { sessionId, txHash } = credential.payload
57
+
58
+ // Submit tx hash for faster detection if provided
59
+ if (txHash) {
60
+ await api.submitTx(sessionId, txHash, 'auto').catch(() => {
61
+ // Non-fatal — Stableyard may detect it independently
62
+ })
63
+ }
64
+
65
+ // Verify the session payment
66
+ const verification = await api.verifySession(sessionId)
67
+
68
+ if (!verification.verified) {
69
+ // If not yet verified, poll for settlement
70
+ try {
71
+ await api.waitForSettlement(sessionId, verifyTimeoutMs)
72
+ } catch {
73
+ throw new Error(`Payment verification failed for session ${sessionId}`)
74
+ }
75
+ }
76
+
77
+ return {
78
+ method: 'stableyard' as const,
79
+ status: 'success' as const,
80
+ reference: sessionId,
81
+ timestamp: new Date().toISOString(),
82
+ }
83
+ },
84
+ })
85
+ }
86
+
87
+ export { charge } from './method.js'
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Stableyard API client — thin wrapper around api.stableyard.fi/v2
3
+ *
4
+ * Handles session creation, verification, payment, and status polling.
5
+ * Based on OpenAPI spec at https://api.stableyard.fi/sdk.json
6
+ */
7
+
8
+ const PRODUCTION_URL = 'https://api.stableyard.fi/v2'
9
+ const SANDBOX_URL = 'https://api-staging.stableyard.fi/v2'
10
+
11
+ export type StableyardConfig = {
12
+ /** Stableyard API key (sy_secret_* for server, sy_pub_* for client) */
13
+ apiKey: string
14
+ /** Base URL override. Auto-detected from key prefix (sy_test_* → sandbox) */
15
+ baseUrl?: string
16
+ }
17
+
18
+ export type CreateSessionParams = {
19
+ /** Amount in smallest unit (6 decimals). 10000000 = $10.00 USDC */
20
+ amount: number
21
+ /** Destination — username@stableyard or wallet address */
22
+ destination: string
23
+ /** Currency: "USDC" or "USDT". Defaults to "USDC" */
24
+ currency?: 'USDC' | 'USDT'
25
+ /** Human-readable description (max 500 chars) */
26
+ description?: string
27
+ /** Metadata (max 20 keys, 500 chars each) */
28
+ metadata?: Record<string, string>
29
+ /** Redirect URL on success */
30
+ successUrl?: string
31
+ /** API path for x402/paywall use case */
32
+ resource?: string
33
+ /** Restrict payer chains. Default: all supported */
34
+ acceptedChains?: string[]
35
+ /** Generate QR codes for POS */
36
+ qrFormats?: ('universal' | 'solana' | 'evm')[]
37
+ /** Revenue splits (basis points, must total 10000) */
38
+ splits?: Array<{ destination: string; percentage: number }>
39
+ /** TTL in seconds. Default: 900 */
40
+ expiresIn?: number
41
+ /** Source chain — if provided, calls Intent Engine inline and returns deposit address */
42
+ sourceChain?: string
43
+ /** Source token — defaults to session currency */
44
+ sourceToken?: string
45
+ }
46
+
47
+ export type Session = {
48
+ object: 'session'
49
+ id: string
50
+ type: 'checkout' | 'deposit' | 'transfer'
51
+ status: 'open' | 'pending' | 'routing' | 'settled' | 'failed' | 'expired' | 'refunded'
52
+ livemode: boolean
53
+ amount: number
54
+ currency: 'USDC' | 'USDT'
55
+ amountFormatted: string
56
+ destination: {
57
+ accountId: string
58
+ paymentAddress: string | null
59
+ wallet: string
60
+ chain: string
61
+ token: string
62
+ }
63
+ source: {
64
+ wallet: string
65
+ chain: string
66
+ token: string
67
+ } | null
68
+ fees: {
69
+ total: number
70
+ totalFormatted: string
71
+ stableyard: number
72
+ partner: number
73
+ }
74
+ checkoutUrl: string | null
75
+ clientSecret: string | null
76
+ paymentMethods: Array<{
77
+ type: 'wallet_direct' | 'vault_balance'
78
+ depositAddress: string | null
79
+ chain?: string
80
+ }> | null
81
+ /** Deposit info — populated when sourceChain is provided in creation */
82
+ deposit?: {
83
+ /** "direct_transfer" (same-chain) or "gasyard" (cross-chain via gateway) */
84
+ type: string
85
+ /** Deposit address (for direct transfer) */
86
+ address?: string
87
+ /** Gateway contract address (for cross-chain) */
88
+ gatewayAddress?: string
89
+ /** Chain ID for payment */
90
+ chainId: number
91
+ /** Token details */
92
+ token: { address: string; symbol: string; decimals: number }
93
+ /** Amount to send */
94
+ amount: { raw: string; formatted: string }
95
+ /** Payment methods with calldata */
96
+ methods: Array<{
97
+ type: string
98
+ address?: string
99
+ gatewayAddress?: string
100
+ transactions?: Array<{ type: string; to: string; data: string; value: string; chainId: number }>
101
+ gaslessSupported?: boolean
102
+ gaslessEndpoint?: string
103
+ instructions?: string[]
104
+ }>
105
+ } | null
106
+ metadata: Record<string, string>
107
+ refundedAmount: number
108
+ createdAt: number
109
+ updatedAt: number
110
+ expiresAt: number
111
+ settledAt: number | null
112
+ requestId: string
113
+ }
114
+
115
+ export type QuoteResponse = {
116
+ fees: {
117
+ total: number
118
+ stableyard: number
119
+ }
120
+ merchantReceives: number
121
+ route: {
122
+ sourceChain: string
123
+ destChain: string
124
+ estimatedSeconds: number
125
+ }
126
+ intentOrderId?: string
127
+ deposit?: {
128
+ address: string
129
+ gatewayAddress: string
130
+ methods: Array<{
131
+ type: 'gateway' | 'deposit_address'
132
+ transactions?: Array<{ type: string; to: string; data: string }>
133
+ address?: string
134
+ }>
135
+ }
136
+ }
137
+
138
+ export type VerifyResponse = {
139
+ verified: boolean
140
+ status: string
141
+ sessionId: string
142
+ [key: string]: unknown
143
+ }
144
+
145
+ function detectBaseUrl(apiKey: string): string {
146
+ if (apiKey.startsWith('sy_test_')) return SANDBOX_URL
147
+ return PRODUCTION_URL
148
+ }
149
+
150
+ export class StableyardAPI {
151
+ private readonly apiKey: string
152
+ private readonly baseUrl: string
153
+
154
+ constructor(config: StableyardConfig) {
155
+ this.apiKey = config.apiKey
156
+ this.baseUrl = config.baseUrl ?? detectBaseUrl(config.apiKey)
157
+ }
158
+
159
+ private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
160
+ const url = `${this.baseUrl}${path}`
161
+ const res = await fetch(url, {
162
+ ...options,
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ 'Authorization': `Bearer ${this.apiKey}`,
166
+ ...options.headers,
167
+ },
168
+ })
169
+
170
+ if (!res.ok) {
171
+ const body = await res.text().catch(() => '')
172
+ throw new Error(`Stableyard API error ${res.status} on ${path}: ${body}`)
173
+ }
174
+
175
+ return res.json() as Promise<T>
176
+ }
177
+
178
+ /** Create a payment session */
179
+ async createSession(params: CreateSessionParams): Promise<Session> {
180
+ return this.request<Session>('/sessions', {
181
+ method: 'POST',
182
+ body: JSON.stringify({
183
+ amount: params.amount,
184
+ destination: params.destination,
185
+ currency: params.currency,
186
+ description: params.description,
187
+ metadata: params.metadata,
188
+ successUrl: params.successUrl,
189
+ resource: params.resource,
190
+ acceptedChains: params.acceptedChains,
191
+ qrFormats: params.qrFormats,
192
+ splits: params.splits,
193
+ expiresIn: params.expiresIn,
194
+ sourceChain: params.sourceChain,
195
+ sourceToken: params.sourceToken,
196
+ }),
197
+ })
198
+ }
199
+
200
+ /** Get session details */
201
+ async getSession(sessionId: string): Promise<Session> {
202
+ return this.request<Session>(`/sessions/${sessionId}`)
203
+ }
204
+
205
+ /** Get a quote for a specific source chain (preview without commit) */
206
+ async getQuote(sessionId: string, sourceChain: string, commit?: boolean): Promise<QuoteResponse> {
207
+ return this.request<QuoteResponse>(`/sessions/${sessionId}/quote`, {
208
+ method: 'POST',
209
+ body: JSON.stringify({ sourceChain, commit }),
210
+ })
211
+ }
212
+
213
+ /** Verify a payment session (for x402/MPP use case) */
214
+ async verifySession(sessionId: string): Promise<VerifyResponse> {
215
+ return this.request<VerifyResponse>(`/sessions/${sessionId}/verify`, {
216
+ method: 'POST',
217
+ })
218
+ }
219
+
220
+ /** Submit transaction hash for faster detection */
221
+ async submitTx(sessionId: string, txHash: string, chain: string): Promise<{ status: string }> {
222
+ return this.request(`/sessions/${sessionId}/submit-tx`, {
223
+ method: 'POST',
224
+ body: JSON.stringify({ txHash, chain }),
225
+ })
226
+ }
227
+
228
+ /** Pay a session from vault (gasless EIP-712) */
229
+ async payFromVault(
230
+ sessionId: string,
231
+ params: { method: 'vault_balance'; signedMessage: string; signerAddress: string },
232
+ ): Promise<{ status: string }> {
233
+ return this.request(`/sessions/${sessionId}/pay`, {
234
+ method: 'POST',
235
+ body: JSON.stringify(params),
236
+ })
237
+ }
238
+
239
+ /** Cancel a session */
240
+ async cancelSession(sessionId: string): Promise<void> {
241
+ await this.request(`/sessions/${sessionId}/cancel`, { method: 'POST' })
242
+ }
243
+
244
+ /** Resolve a wallet/username/ID to account details */
245
+ async resolveAccount(query: string): Promise<Record<string, unknown>> {
246
+ return this.request(`/accounts/resolve?q=${encodeURIComponent(query)}`)
247
+ }
248
+
249
+ /** Check multi-chain portfolio balances (no auth required) */
250
+ async getPortfolio(address: string): Promise<Record<string, unknown>> {
251
+ return this.request(`/network/portfolio?address=${encodeURIComponent(address)}`)
252
+ }
253
+
254
+ /** Poll session until settled or timeout */
255
+ async waitForSettlement(
256
+ sessionId: string,
257
+ timeoutMs: number = 60_000,
258
+ pollIntervalMs: number = 2_000,
259
+ ): Promise<Session> {
260
+ const deadline = Date.now() + timeoutMs
261
+
262
+ while (Date.now() < deadline) {
263
+ const session = await this.getSession(sessionId)
264
+
265
+ if (session.status === 'settled') return session
266
+ if (session.status === 'failed' || session.status === 'expired' || session.status === 'refunded') {
267
+ throw new Error(`Session ${sessionId} ${session.status}`)
268
+ }
269
+
270
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
271
+ }
272
+
273
+ throw new Error(`Session ${sessionId} timed out waiting for settlement`)
274
+ }
275
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true
14
+ },
15
+ "include": ["src"],
16
+ "exclude": ["node_modules", "dist", "demo"]
17
+ }