@zerox1/sdk 0.2.18 → 0.2.20

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/index.ts CHANGED
@@ -91,6 +91,306 @@ export interface SendFeedbackParams {
91
91
  role: 'participant' | 'notary'
92
92
  }
93
93
 
94
+ // ============================================================================
95
+ // COUNTER negotiation types
96
+ //
97
+ // PROPOSE and COUNTER envelopes share a structured payload layout:
98
+ //
99
+ // [bytes 0-15] LE i128 — bid amount in USDC microunits (0 = unspecified)
100
+ // [bytes 16..] JSON — {"max_rounds": u8, "message": str} (PROPOSE)
101
+ // {"round": u8, "max_rounds": u8, "message": str} (COUNTER)
102
+ //
103
+ // Both sides can counter-propose up to maxRounds times (default: 2).
104
+ // The proposer gets maxRounds = 3 if their average reputation score >= 70.
105
+ // Round numbering is 1-indexed: first counter = round 1, second = round 2.
106
+ // ============================================================================
107
+
108
+ /** Decoded content of an incoming PROPOSE envelope payload. */
109
+ export interface ProposePayload {
110
+ /** Amount in USDC microunits (e.g. 1_000_000n = 1 USDC). 0n = unspecified. */
111
+ amount: bigint
112
+ /** Maximum counter rounds the proposer allows. Default: 2. */
113
+ maxRounds: number
114
+ /** Human-readable proposal message. */
115
+ message: string
116
+ }
117
+
118
+ /** Decoded content of an incoming COUNTER envelope payload. */
119
+ export interface CounterPayload {
120
+ /** Counter-offered amount in USDC microunits. */
121
+ amount: bigint
122
+ /** Which counter round this is (1-indexed). */
123
+ round: number
124
+ /** Maximum rounds as originally set in the PROPOSE. */
125
+ maxRounds: number
126
+ /** Human-readable counter message. */
127
+ message: string
128
+ }
129
+
130
+ export interface SendProposeParams {
131
+ /** Hex-encoded 32-byte agent ID of the target agent. */
132
+ recipient: string
133
+ /**
134
+ * 16-byte hex conversation ID. Auto-generated if omitted.
135
+ * The returned object includes the final conversation_id used.
136
+ */
137
+ conversationId?: string
138
+ /** Bid amount in USDC microunits. Default: 0n (unspecified). */
139
+ amount?: bigint
140
+ /**
141
+ * Max counter rounds allowed. Default: 2.
142
+ * Set to 3 if your average reputation score is >= 70.
143
+ */
144
+ maxRounds?: number
145
+ /** Proposal text (task description, terms, etc.). */
146
+ message: string
147
+ }
148
+
149
+ export interface SendCounterParams {
150
+ /** Hex-encoded 32-byte agent ID of the counterparty. */
151
+ recipient: string
152
+ /** Conversation ID from the original PROPOSE. */
153
+ conversationId: string
154
+ /** Counter-offered amount in USDC microunits. */
155
+ amount: bigint
156
+ /** Counter round number (1-indexed). Must be <= maxRounds. */
157
+ round: number
158
+ /** maxRounds from the original PROPOSE. */
159
+ maxRounds: number
160
+ /** Explanation of your counter-offer. */
161
+ message?: string
162
+ }
163
+
164
+ /** Decoded content of an incoming ACCEPT envelope payload. */
165
+ export interface AcceptPayload {
166
+ /**
167
+ * The amount being accepted in USDC microunits.
168
+ * Matches the most-recent COUNTER amount, or the original PROPOSE amount
169
+ * if no COUNTER was issued. Use this value for `lockPayment`.
170
+ */
171
+ amount: bigint
172
+ /** Optional acceptance message. */
173
+ message: string
174
+ }
175
+
176
+ export interface SendAcceptParams {
177
+ /** Hex-encoded 32-byte agent ID of the agent whose offer you are accepting. */
178
+ recipient: string
179
+ /** Conversation ID from the original PROPOSE. */
180
+ conversationId: string
181
+ /**
182
+ * The agreed amount in USDC microunits — must match the most-recent COUNTER
183
+ * (or original PROPOSE if no COUNTER was sent). Both parties use this
184
+ * to call `lockPayment` with the correct amount.
185
+ */
186
+ amount: bigint
187
+ /** Optional acceptance message. */
188
+ message?: string
189
+ }
190
+
191
+ export interface LockPaymentParams {
192
+ /** Hex-encoded 32-byte agent_id of the provider who will receive payment. */
193
+ provider: string
194
+ /** Hex-encoded 16-byte conversation ID from the negotiation. */
195
+ conversationId: string
196
+ /** Amount to lock in USDC microunits (must match the ACCEPT amount). */
197
+ amount: bigint
198
+ /** Notary fee in USDC microunits. Default: amount / 10n. */
199
+ notaryFee?: bigint
200
+ /** Solana slot timeout before provider can claim without approval. Default: 1000. */
201
+ timeoutSlots?: number
202
+ /** Hex-encoded 32-byte agent_id of a designated notary (optional). */
203
+ notary?: string
204
+ }
205
+
206
+ export interface ApprovePaymentParams {
207
+ /** Hex-encoded 32-byte agent_id of the requester (payer). */
208
+ requester: string
209
+ /** Hex-encoded 32-byte agent_id of the provider (payee). */
210
+ provider: string
211
+ /** Hex-encoded 16-byte conversation ID from the negotiation. */
212
+ conversationId: string
213
+ /** Hex-encoded 32-byte agent_id of the notary. Defaults to this agent (self-approval). */
214
+ notary?: string
215
+ }
216
+
217
+ // ============================================================================
218
+ // Token swap whitelist
219
+ // ============================================================================
220
+
221
+ /**
222
+ * Default token mint addresses allowed in agent-to-agent swaps.
223
+ * Prevents agents from being tricked into swapping into fraudulent tokens.
224
+ *
225
+ * Both devnet and mainnet mints are included; the node validates against
226
+ * whichever network it is connected to.
227
+ *
228
+ * Override per-agent with `Zerox1Agent.setSwapWhitelist()`.
229
+ */
230
+ export const DEFAULT_SWAP_WHITELIST: ReadonlySet<string> = new Set([
231
+ // SOL (wrapped)
232
+ 'So11111111111111111111111111111111111111112',
233
+ // USDC — mainnet
234
+ 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
235
+ // USDC — devnet
236
+ '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
237
+ // USDT — mainnet
238
+ 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
239
+ // JUP
240
+ 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
241
+ // BONK
242
+ 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
243
+ // RAY
244
+ '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
245
+ // WIF
246
+ 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
247
+ // BAGS — mainnet
248
+ 'Bags4uLBdNscWBnHmqBozrjSScnEqPx5qZBzLiqnRVN7',
249
+ ])
250
+
251
+ export interface SwapParams {
252
+ /** Solana base58 mint address of the token to sell. */
253
+ inputMint: string
254
+ /** Solana base58 mint address of the token to buy. */
255
+ outputMint: string
256
+ /** Amount in input-token native units (e.g. lamports for SOL). */
257
+ amount: bigint
258
+ /** Max slippage in basis points. Default: 50 (0.5%). */
259
+ slippageBps?: number
260
+ /** Custom whitelist to use instead of DEFAULT_SWAP_WHITELIST. Pass an empty set to disable. */
261
+ whitelist?: ReadonlySet<string>
262
+ }
263
+
264
+ export interface SwapResult {
265
+ /** Input amount actually consumed (native units). */
266
+ inAmount: bigint
267
+ /** Output amount received (native units). */
268
+ outAmount: bigint
269
+ /** Transaction signature. */
270
+ signature: string
271
+ }
272
+
273
+ // ============================================================================
274
+ // PROPOSE / COUNTER payload encode + decode helpers
275
+ // ============================================================================
276
+
277
+ function writeBidPrefix(amount: bigint): Buffer {
278
+ const buf = Buffer.alloc(16)
279
+ buf.writeBigUInt64LE(amount & 0xFFFFFFFFFFFFFFFFn, 0)
280
+ buf.writeBigUInt64LE(amount >> 64n, 8)
281
+ return buf
282
+ }
283
+
284
+ function readBidPrefix(raw: Buffer): bigint {
285
+ const lo = raw.readBigUInt64LE(0)
286
+ const hi = raw.readBigUInt64LE(8)
287
+ return (hi << 64n) | lo
288
+ }
289
+
290
+ /**
291
+ * Encode a PROPOSE payload into the structured wire format:
292
+ * `[16-byte LE i128 amount][JSON {"max_rounds": N, "message": "..."}]`
293
+ */
294
+ export function encodeProposePayload(
295
+ message: string,
296
+ amount: bigint = 0n,
297
+ maxRounds: number = 2,
298
+ ): Buffer {
299
+ const prefix = writeBidPrefix(amount)
300
+ const json = Buffer.from(JSON.stringify({ max_rounds: maxRounds, message }))
301
+ return Buffer.concat([prefix, json])
302
+ }
303
+
304
+ /**
305
+ * Encode a COUNTER payload into the structured wire format:
306
+ * `[16-byte LE i128 amount][JSON {"round": N, "max_rounds": M, "message": "..."}]`
307
+ */
308
+ export function encodeCounterPayload(
309
+ amount: bigint,
310
+ round: number,
311
+ maxRounds: number,
312
+ message: string = '',
313
+ ): Buffer {
314
+ const prefix = writeBidPrefix(amount)
315
+ const json = Buffer.from(JSON.stringify({ round, max_rounds: maxRounds, message }))
316
+ return Buffer.concat([prefix, json])
317
+ }
318
+
319
+ /**
320
+ * Decode a PROPOSE envelope payload.
321
+ * Returns `null` if the payload is not in the structured format
322
+ * (e.g. a raw-string PROPOSE from an older agent).
323
+ */
324
+ export function decodeProposePayload(payloadB64: string): ProposePayload | null {
325
+ const raw = Buffer.from(payloadB64, 'base64')
326
+ if (raw.length < 17 || raw[16] !== 0x7b /* '{' */) return null
327
+ try {
328
+ const body = JSON.parse(raw.slice(16).toString('utf8')) as Record<string, unknown>
329
+ return {
330
+ amount: readBidPrefix(raw),
331
+ maxRounds: Number(body['max_rounds'] ?? 2),
332
+ message: String(body['message'] ?? ''),
333
+ }
334
+ } catch {
335
+ return null
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Encode an ACCEPT payload.
341
+ * `[16-byte LE i128 amount][JSON {"message": "..."}]`
342
+ *
343
+ * Both parties must use the same `amount` — it is the agreed price that
344
+ * will be passed to `lockPayment` on-chain.
345
+ */
346
+ export function encodeAcceptPayload(
347
+ amount: bigint,
348
+ message: string = '',
349
+ ): Buffer {
350
+ const prefix = writeBidPrefix(amount)
351
+ const json = Buffer.from(JSON.stringify({ message }))
352
+ return Buffer.concat([prefix, json])
353
+ }
354
+
355
+ /**
356
+ * Decode an ACCEPT envelope payload.
357
+ * Returns `null` if the payload is not in the structured format
358
+ * (older agents may send a plain-text ACCEPT).
359
+ */
360
+ export function decodeAcceptPayload(payloadB64: string): AcceptPayload | null {
361
+ const raw = Buffer.from(payloadB64, 'base64')
362
+ if (raw.length < 17 || raw[16] !== 0x7b /* '{' */) return null
363
+ try {
364
+ const body = JSON.parse(raw.slice(16).toString('utf8')) as Record<string, unknown>
365
+ return {
366
+ amount: readBidPrefix(raw),
367
+ message: String(body['message'] ?? ''),
368
+ }
369
+ } catch {
370
+ return null
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Decode a COUNTER envelope payload.
376
+ * Returns `null` if the payload is not in the structured format.
377
+ */
378
+ export function decodeCounterPayload(payloadB64: string): CounterPayload | null {
379
+ const raw = Buffer.from(payloadB64, 'base64')
380
+ if (raw.length < 17 || raw[16] !== 0x7b /* '{' */) return null
381
+ try {
382
+ const body = JSON.parse(raw.slice(16).toString('utf8')) as Record<string, unknown>
383
+ return {
384
+ amount: readBidPrefix(raw),
385
+ round: Number(body['round'] ?? 1),
386
+ maxRounds: Number(body['max_rounds'] ?? 2),
387
+ message: String(body['message'] ?? ''),
388
+ }
389
+ } catch {
390
+ return null
391
+ }
392
+ }
393
+
94
394
  // ============================================================================
95
395
  // Hosting types
96
396
  // ============================================================================
@@ -265,6 +565,7 @@ export class Zerox1Agent {
265
565
  private port: number = 0
266
566
  private nodeUrl: string = ''
267
567
  private _reconnectDelay: number = 1000
568
+ private _swapWhitelist: ReadonlySet<string> = DEFAULT_SWAP_WHITELIST
268
569
 
269
570
  private constructor() { }
270
571
 
@@ -561,6 +862,203 @@ export class Zerox1Agent {
561
862
  })
562
863
  }
563
864
 
865
+ /**
866
+ * Send a PROPOSE envelope.
867
+ *
868
+ * Calls POST /negotiate/propose — the node handles binary payload encoding.
869
+ * Returns the conversation ID used (auto-generated if not supplied)
870
+ * along with the send confirmation.
871
+ */
872
+ async sendPropose(
873
+ params: SendProposeParams
874
+ ): Promise<{ conversationId: string; confirmation: SentConfirmation }> {
875
+ const res = await fetch(`${this.nodeUrl}/negotiate/propose`, {
876
+ method: 'POST',
877
+ headers: { 'Content-Type': 'application/json' },
878
+ body: JSON.stringify({
879
+ recipient: params.recipient,
880
+ conversation_id: params.conversationId,
881
+ amount_usdc_micro: params.amount !== undefined ? Number(params.amount) : undefined,
882
+ max_rounds: params.maxRounds,
883
+ message: params.message,
884
+ }),
885
+ })
886
+ if (!res.ok) {
887
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) as Record<string, unknown>
888
+ throw new Error((err['error'] as string) ?? `HTTP ${res.status}`)
889
+ }
890
+ const json = await res.json() as Record<string, unknown>
891
+ return {
892
+ conversationId: json['conversation_id'] as string,
893
+ confirmation: { nonce: json['nonce'] as number, payloadHash: json['payload_hash'] as string },
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Send a COUNTER envelope.
899
+ *
900
+ * Calls POST /negotiate/counter — the node handles binary payload encoding.
901
+ * Protocol rules: `round` must be 1-indexed and <= `maxRounds`.
902
+ */
903
+ async sendCounter(params: SendCounterParams): Promise<SentConfirmation> {
904
+ if (params.round < 1 || params.round > params.maxRounds) {
905
+ throw new RangeError(`round ${params.round} is out of range [1, ${params.maxRounds}]`)
906
+ }
907
+ const res = await fetch(`${this.nodeUrl}/negotiate/counter`, {
908
+ method: 'POST',
909
+ headers: { 'Content-Type': 'application/json' },
910
+ body: JSON.stringify({
911
+ recipient: params.recipient,
912
+ conversation_id: params.conversationId,
913
+ amount_usdc_micro: Number(params.amount),
914
+ round: params.round,
915
+ max_rounds: params.maxRounds,
916
+ message: params.message,
917
+ }),
918
+ })
919
+ if (!res.ok) {
920
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) as Record<string, unknown>
921
+ throw new Error((err['error'] as string) ?? `HTTP ${res.status}`)
922
+ }
923
+ const json = await res.json() as Record<string, unknown>
924
+ return { nonce: json['nonce'] as number, payloadHash: json['payload_hash'] as string }
925
+ }
926
+
927
+ /**
928
+ * Send an ACCEPT envelope with the agreed amount.
929
+ *
930
+ * Calls POST /negotiate/accept — the node handles binary payload encoding.
931
+ * The `amount` must match the most-recent COUNTER (or original PROPOSE if
932
+ * there was no counter). Both parties use this value to call `lockPayment`.
933
+ */
934
+ async sendAccept(params: SendAcceptParams): Promise<SentConfirmation> {
935
+ const res = await fetch(`${this.nodeUrl}/negotiate/accept`, {
936
+ method: 'POST',
937
+ headers: { 'Content-Type': 'application/json' },
938
+ body: JSON.stringify({
939
+ recipient: params.recipient,
940
+ conversation_id: params.conversationId,
941
+ amount_usdc_micro: Number(params.amount),
942
+ message: params.message,
943
+ }),
944
+ })
945
+ if (!res.ok) {
946
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) as Record<string, unknown>
947
+ throw new Error((err['error'] as string) ?? `HTTP ${res.status}`)
948
+ }
949
+ const json = await res.json() as Record<string, unknown>
950
+ return { nonce: json['nonce'] as number, payloadHash: json['payload_hash'] as string }
951
+ }
952
+
953
+ /**
954
+ * Lock USDC in the escrow program on-chain.
955
+ *
956
+ * Call this after `sendAccept()` to fund the escrow account before the
957
+ * provider begins work. The node signs the Solana transaction using its
958
+ * own keypair (this agent is the requester / payer).
959
+ *
960
+ * The automatic lock triggered by `sendAccept()` (via the node loop) uses
961
+ * default parameters. Use this method for explicit control — e.g. a custom
962
+ * notary or timeout.
963
+ *
964
+ * @param params.amount — must match the amount in the ACCEPT payload exactly.
965
+ */
966
+ async lockPayment(params: LockPaymentParams): Promise<void> {
967
+ const res = await fetch(`${this.nodeUrl}/escrow/lock`, {
968
+ method: 'POST',
969
+ headers: { 'Content-Type': 'application/json' },
970
+ body: JSON.stringify({
971
+ provider: params.provider,
972
+ conversation_id: params.conversationId,
973
+ amount_usdc_micro: Number(params.amount),
974
+ notary_fee: params.notaryFee !== undefined ? Number(params.notaryFee) : undefined,
975
+ timeout_slots: params.timeoutSlots,
976
+ notary: params.notary,
977
+ }),
978
+ })
979
+ if (!res.ok) {
980
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) as Record<string, unknown>
981
+ throw new Error(`lockPayment failed: ${(err['error'] as string) ?? res.status}`)
982
+ }
983
+ }
984
+
985
+ /**
986
+ * Approve and release a locked escrow payment to the provider.
987
+ *
988
+ * Call this after verifying the provider's DELIVER output is satisfactory.
989
+ * The node signs as the approver (notary or requester).
990
+ *
991
+ * @param params.notary — defaults to this agent (self-approval when no separate notary).
992
+ */
993
+ async approvePayment(params: ApprovePaymentParams): Promise<void> {
994
+ const res = await fetch(`${this.nodeUrl}/escrow/approve`, {
995
+ method: 'POST',
996
+ headers: { 'Content-Type': 'application/json' },
997
+ body: JSON.stringify({
998
+ requester: params.requester,
999
+ provider: params.provider,
1000
+ conversation_id: params.conversationId,
1001
+ notary: params.notary,
1002
+ }),
1003
+ })
1004
+ if (!res.ok) {
1005
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) as Record<string, unknown>
1006
+ throw new Error(`approvePayment failed: ${(err['error'] as string) ?? res.status}`)
1007
+ }
1008
+ }
1009
+
1010
+ // ── Token swap ────────────────────────────────────────────────────────────
1011
+
1012
+ /**
1013
+ * Override the token whitelist for this agent instance.
1014
+ * Pass an empty Set to disable whitelist enforcement (not recommended).
1015
+ */
1016
+ setSwapWhitelist(whitelist: ReadonlySet<string>): void {
1017
+ this._swapWhitelist = whitelist
1018
+ }
1019
+
1020
+ /**
1021
+ * Execute a Jupiter token swap via the node's `/trade/swap` endpoint.
1022
+ *
1023
+ * Both `inputMint` and `outputMint` must be in the active whitelist
1024
+ * (DEFAULT_SWAP_WHITELIST unless overridden via `setSwapWhitelist()`).
1025
+ * This prevents agents from being deceived into swapping fraudulent tokens.
1026
+ *
1027
+ * @throws If either mint is not whitelisted, or the node rejects the swap.
1028
+ */
1029
+ async swap(params: SwapParams): Promise<SwapResult> {
1030
+ const whitelist = params.whitelist ?? this._swapWhitelist
1031
+ if (whitelist.size > 0) {
1032
+ if (!whitelist.has(params.inputMint)) {
1033
+ throw new Error(`swap: inputMint ${params.inputMint} is not in the token whitelist`)
1034
+ }
1035
+ if (!whitelist.has(params.outputMint)) {
1036
+ throw new Error(`swap: outputMint ${params.outputMint} is not in the token whitelist`)
1037
+ }
1038
+ }
1039
+
1040
+ const res = await fetch(`${this.nodeUrl}/trade/swap`, {
1041
+ method: 'POST',
1042
+ headers: { 'Content-Type': 'application/json' },
1043
+ body: JSON.stringify({
1044
+ input_mint: params.inputMint,
1045
+ output_mint: params.outputMint,
1046
+ amount: params.amount.toString(),
1047
+ slippage_bps: params.slippageBps ?? 50,
1048
+ }),
1049
+ })
1050
+ if (!res.ok) {
1051
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) as Record<string, unknown>
1052
+ throw new Error(`swap failed: ${(err['error'] as string) ?? res.status}`)
1053
+ }
1054
+ const data = await res.json() as { in_amount: string; out_amount: string; signature: string }
1055
+ return {
1056
+ inAmount: BigInt(data.in_amount),
1057
+ outAmount: BigInt(data.out_amount),
1058
+ signature: data.signature,
1059
+ }
1060
+ }
1061
+
564
1062
  // ── Utilities ─────────────────────────────────────────────────────────────
565
1063
 
566
1064
  /** Generate a random 16-byte conversation ID as hex. */
@@ -573,6 +1071,7 @@ export class Zerox1Agent {
573
1071
  /**
574
1072
  * Encode a bid value (i128 LE) into the first 16 bytes of a payload,
575
1073
  * followed by optional extra bytes (your terms).
1074
+ * @deprecated Use `encodeProposePayload()` or `encodeCounterPayload()` instead.
576
1075
  */
577
1076
  encodeBidValue(value: bigint, rest: Buffer = Buffer.alloc(0)): Buffer {
578
1077
  const buf = Buffer.alloc(16)
@@ -808,6 +1307,79 @@ export class HostedAgent {
808
1307
  return this.send({ msgType: 'FEEDBACK', conversationId: params.conversationId, payload })
809
1308
  }
810
1309
 
1310
+ /** Send a PROPOSE envelope via POST /hosted/negotiate/propose. */
1311
+ async sendPropose(
1312
+ params: SendProposeParams
1313
+ ): Promise<{ conversationId: string }> {
1314
+ const res = await fetch(`${this.baseUrl}/hosted/negotiate/propose`, {
1315
+ method: 'POST',
1316
+ headers: {
1317
+ 'Content-Type': 'application/json',
1318
+ 'Authorization': `Bearer ${this.token}`,
1319
+ },
1320
+ body: JSON.stringify({
1321
+ recipient: params.recipient,
1322
+ conversation_id: params.conversationId,
1323
+ amount_usdc_micro: params.amount !== undefined ? Number(params.amount) : undefined,
1324
+ max_rounds: params.maxRounds,
1325
+ message: params.message,
1326
+ }),
1327
+ })
1328
+ if (!res.ok) {
1329
+ const body = await res.text()
1330
+ throw new Error(`hosted propose failed (${res.status}): ${body}`)
1331
+ }
1332
+ const json = await res.json() as Record<string, unknown>
1333
+ return { conversationId: json['conversation_id'] as string }
1334
+ }
1335
+
1336
+ /** Send a COUNTER envelope via POST /hosted/negotiate/counter. */
1337
+ async sendCounter(params: SendCounterParams): Promise<void> {
1338
+ if (params.round < 1 || params.round > params.maxRounds) {
1339
+ throw new RangeError(`round ${params.round} is out of range [1, ${params.maxRounds}]`)
1340
+ }
1341
+ const res = await fetch(`${this.baseUrl}/hosted/negotiate/counter`, {
1342
+ method: 'POST',
1343
+ headers: {
1344
+ 'Content-Type': 'application/json',
1345
+ 'Authorization': `Bearer ${this.token}`,
1346
+ },
1347
+ body: JSON.stringify({
1348
+ recipient: params.recipient,
1349
+ conversation_id: params.conversationId,
1350
+ amount_usdc_micro: Number(params.amount),
1351
+ round: params.round,
1352
+ max_rounds: params.maxRounds,
1353
+ message: params.message,
1354
+ }),
1355
+ })
1356
+ if (!res.ok && res.status !== 204) {
1357
+ const body = await res.text()
1358
+ throw new Error(`hosted counter failed (${res.status}): ${body}`)
1359
+ }
1360
+ }
1361
+
1362
+ /** Send an ACCEPT envelope via POST /hosted/negotiate/accept. */
1363
+ async sendAccept(params: SendAcceptParams): Promise<void> {
1364
+ const res = await fetch(`${this.baseUrl}/hosted/negotiate/accept`, {
1365
+ method: 'POST',
1366
+ headers: {
1367
+ 'Content-Type': 'application/json',
1368
+ 'Authorization': `Bearer ${this.token}`,
1369
+ },
1370
+ body: JSON.stringify({
1371
+ recipient: params.recipient,
1372
+ conversation_id: params.conversationId,
1373
+ amount_usdc_micro: Number(params.amount),
1374
+ message: params.message,
1375
+ }),
1376
+ })
1377
+ if (!res.ok && res.status !== 204) {
1378
+ const body = await res.text()
1379
+ throw new Error(`hosted accept failed (${res.status}): ${body}`)
1380
+ }
1381
+ }
1382
+
811
1383
  /** Generate a random 16-byte conversation ID as hex. */
812
1384
  newConversationId(): string {
813
1385
  const bytes = new Uint8Array(16)