@zoralabs/coins-sdk 0.6.0 → 0.7.1

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.
@@ -2,14 +2,22 @@ import { permit2ABI, permit2Address } from "@zoralabs/protocol-deployments";
2
2
  import {
3
3
  Account,
4
4
  Address,
5
+ encodeFunctionData,
5
6
  erc20Abi,
6
- WalletClient,
7
- maxUint256,
8
7
  Hex,
8
+ maxUint256,
9
+ TransactionReceipt,
10
+ WalletClient,
9
11
  } from "viem";
12
+ import { BundlerClient, SmartAccount } from "viem/account-abstraction";
10
13
  import { base } from "viem/chains";
11
14
  import { postQuote, PostQuoteResponse } from "../client";
15
+ import { GenericCall, toUserOperationCalls } from "../utils/calls";
12
16
  import { GenericPublicClient } from "../utils/genericPublicClient";
17
+ import {
18
+ prepareUserOperation,
19
+ submitUserOperation,
20
+ } from "../utils/userOperation";
13
21
 
14
22
  type TradeERC20 = {
15
23
  type: "erc20";
@@ -92,6 +100,110 @@ export type TradeParameters = {
92
100
  permitActiveSeconds?: number;
93
101
  };
94
102
 
103
+ type SignPermitTypedData = (params: {
104
+ domain: { name: string; chainId: number; verifyingContract: Address };
105
+ types: typeof PERMIT_SINGLE_TYPES;
106
+ primaryType: "PermitSingle";
107
+ message: Permit;
108
+ }) => Promise<Hex>;
109
+
110
+ /**
111
+ * Resolves the permit2 requirements for a trade quote.
112
+ *
113
+ * For each permit the quote requires, reads the on-chain permit2 nonce and the
114
+ * token's permit2 allowance, signs the permit2 `PermitSingle` typed data with the
115
+ * provided signer, and — when the token's permit2 allowance is insufficient —
116
+ * collects the ERC20 `approve(permit2, max)` call needed before the trade.
117
+ *
118
+ * The approval is returned as a {@link GenericCall} rather than executed, so the
119
+ * caller decides how to run it: `tradeCoin` (EOA) sends it as a prior
120
+ * transaction; `tradeCoinSmartWallet` batches it into the trade's user operation.
121
+ */
122
+ async function resolveTradePermits({
123
+ quote,
124
+ owner,
125
+ publicClient,
126
+ signTypedData,
127
+ }: {
128
+ quote: PostQuoteResponse;
129
+ owner: Address;
130
+ publicClient: GenericPublicClient;
131
+ signTypedData: SignPermitTypedData;
132
+ }): Promise<{
133
+ signatures: SignatureWithPermit<PermitStringAmounts>[];
134
+ approvalCalls: GenericCall[];
135
+ }> {
136
+ const signatures: SignatureWithPermit<PermitStringAmounts>[] = [];
137
+ const approvalCalls: GenericCall[] = [];
138
+
139
+ if (!quote.permits) {
140
+ return { signatures, approvalCalls };
141
+ }
142
+
143
+ for (const permit of quote.permits) {
144
+ // permit2 allowance returns [amount, expiration, nonce]
145
+ const [, , nonce] = await publicClient.readContract({
146
+ abi: permit2ABI,
147
+ address: permit2Address[base.id],
148
+ functionName: "allowance",
149
+ args: [
150
+ owner,
151
+ permit.permit.details.token as Address,
152
+ permit.permit.spender as Address,
153
+ ],
154
+ });
155
+
156
+ const permitToken = permit.permit.details.token as Address;
157
+ const allowance = await publicClient.readContract({
158
+ abi: erc20Abi,
159
+ address: permitToken,
160
+ functionName: "allowance",
161
+ args: [owner, permit2Address[base.id]],
162
+ });
163
+
164
+ if (allowance < BigInt(permit.permit.details.amount)) {
165
+ approvalCalls.push({
166
+ to: permitToken,
167
+ data: encodeFunctionData({
168
+ abi: erc20Abi,
169
+ functionName: "approve",
170
+ args: [permit2Address[base.id], maxUint256],
171
+ }),
172
+ value: 0n,
173
+ });
174
+ }
175
+
176
+ const message: Permit = {
177
+ details: {
178
+ token: permit.permit.details.token as Address,
179
+ amount: BigInt(permit.permit.details.amount!),
180
+ expiration: Number(permit.permit.details.expiration!),
181
+ nonce,
182
+ },
183
+ spender: permit.permit.spender as Address,
184
+ sigDeadline: BigInt(permit.permit.sigDeadline!),
185
+ };
186
+
187
+ const signature = await signTypedData({
188
+ domain: {
189
+ name: "Permit2",
190
+ chainId: base.id,
191
+ verifyingContract: permit2Address[base.id],
192
+ },
193
+ primaryType: "PermitSingle",
194
+ types: PERMIT_SINGLE_TYPES,
195
+ message,
196
+ });
197
+
198
+ signatures.push({
199
+ signature,
200
+ permit: convertBigIntToString(message),
201
+ });
202
+ }
203
+
204
+ return { signatures, approvalCalls };
205
+ }
206
+
95
207
  export async function tradeCoin({
96
208
  tradeParameters,
97
209
  walletClient,
@@ -104,7 +216,7 @@ export async function tradeCoin({
104
216
  account?: Account | Address;
105
217
  publicClient: GenericPublicClient;
106
218
  validateTransaction?: boolean;
107
- }) {
219
+ }): Promise<TransactionReceipt> {
108
220
  const quote = await createTradeCall(tradeParameters);
109
221
 
110
222
  if (!account) {
@@ -113,77 +225,33 @@ export async function tradeCoin({
113
225
  if (!account) {
114
226
  throw new Error("Account is required");
115
227
  }
228
+ const resolvedAccount = account;
229
+ const owner =
230
+ typeof resolvedAccount === "string"
231
+ ? resolvedAccount
232
+ : resolvedAccount.address;
116
233
 
117
234
  // Set default recipient to wallet sender address if not provided
118
235
  if (!tradeParameters.recipient) {
119
- tradeParameters.recipient =
120
- typeof account === "string" ? account : account.address;
236
+ tradeParameters.recipient = owner;
121
237
  }
122
238
 
123
- // todo replace any
124
- const signatures: { signature: Hex; permit: any }[] = [];
125
- if (quote.permits) {
126
- for (const permit of quote.permits) {
127
- // return values: amount, expiration, nonce
128
- const [, , nonce] = await publicClient.readContract({
129
- abi: permit2ABI,
130
- address: permit2Address[base.id],
131
- functionName: "allowance",
132
- args: [
133
- typeof account === "string" ? account : account.address,
134
- permit.permit.details.token as Address,
135
- permit.permit.spender as Address,
136
- ],
137
- });
138
- const permitToken = permit.permit.details.token as Address;
139
- const allowance = await publicClient.readContract({
140
- abi: erc20Abi,
141
- address: permitToken,
142
- functionName: "allowance",
143
- args: [
144
- typeof account === "string" ? account : account.address,
145
- permit2Address[base.id],
146
- ],
147
- });
148
- if (allowance < BigInt(permit.permit.details.amount)) {
149
- const approvalTx = await walletClient.writeContract({
150
- abi: erc20Abi,
151
- address: permitToken,
152
- functionName: "approve",
153
- chain: base,
154
- args: [permit2Address[base.id], maxUint256],
155
- account,
156
- });
157
- await publicClient.waitForTransactionReceipt({
158
- hash: approvalTx,
159
- });
160
- }
161
- const message = {
162
- details: {
163
- token: permit.permit.details.token as Address,
164
- amount: BigInt(permit.permit.details.amount!),
165
- expiration: Number(permit.permit.details.expiration!),
166
- nonce: nonce,
167
- },
168
- spender: permit.permit.spender as Address,
169
- sigDeadline: BigInt(permit.permit.sigDeadline!),
170
- };
171
- const signature = await walletClient.signTypedData({
172
- domain: {
173
- name: "Permit2",
174
- chainId: base.id,
175
- verifyingContract: permit2Address[base.id],
176
- },
177
- primaryType: "PermitSingle",
178
- types: PERMIT_SINGLE_TYPES,
179
- message,
180
- account,
181
- });
182
- signatures.push({
183
- signature,
184
- permit: convertBigIntToString(message),
185
- });
186
- }
239
+ const { signatures, approvalCalls } = await resolveTradePermits({
240
+ quote,
241
+ owner,
242
+ publicClient,
243
+ signTypedData: (typedData) =>
244
+ walletClient.signTypedData({ ...typedData, account: resolvedAccount }),
245
+ });
246
+
247
+ // EOA path: execute each required permit2 approval as its own transaction
248
+ for (const approvalCall of approvalCalls) {
249
+ const approvalTx = await walletClient.sendTransaction({
250
+ ...approvalCall,
251
+ account: resolvedAccount,
252
+ chain: base,
253
+ });
254
+ await publicClient.waitForTransactionReceipt({ hash: approvalTx });
187
255
  }
188
256
 
189
257
  const newQuote = await createTradeCall({
@@ -196,7 +264,7 @@ export async function tradeCoin({
196
264
  data: newQuote.call.data as Hex,
197
265
  value: BigInt(newQuote.call.value),
198
266
  chain: base,
199
- account,
267
+ account: resolvedAccount,
200
268
  };
201
269
 
202
270
  // simulate call
@@ -222,15 +290,112 @@ export async function tradeCoin({
222
290
  return receipt;
223
291
  }
224
292
 
225
- export async function createTradeCall(
293
+ /**
294
+ * Executes a trade from the caller's smart wallet via a user operation.
295
+ *
296
+ * Mirrors {@link tradeCoin} but routes through a bundler client: the smart
297
+ * account is both the token holder and the permit2 signer (ERC-1271), and any
298
+ * required permit2 approval is batched into the same user operation as the trade
299
+ * (rather than sent as a prior transaction). Returns the settled transaction
300
+ * receipt.
301
+ */
302
+ export async function tradeCoinSmartWallet({
303
+ tradeParameters,
304
+ bundlerClient,
305
+ account,
306
+ publicClient,
307
+ }: {
308
+ tradeParameters: TradeParameters;
309
+ bundlerClient: BundlerClient;
310
+ account?: SmartAccount;
311
+ publicClient: GenericPublicClient;
312
+ }) {
313
+ const resolvedAccount = account ?? bundlerClient.account;
314
+ if (!resolvedAccount) {
315
+ throw new Error("Account is required");
316
+ }
317
+
318
+ const owner = resolvedAccount.address;
319
+
320
+ // The smart wallet is both the sender (token holder) and the permit signer.
321
+ const params: TradeParameters = {
322
+ ...tradeParameters,
323
+ sender: owner,
324
+ recipient: tradeParameters.recipient ?? owner,
325
+ };
326
+
327
+ const quote = await createTradeCall(params);
328
+
329
+ const { signatures, approvalCalls } = await resolveTradePermits({
330
+ quote,
331
+ owner,
332
+ publicClient,
333
+ signTypedData: (typedData) => resolvedAccount.signTypedData(typedData),
334
+ });
335
+
336
+ const newQuote = await createTradeCall({
337
+ ...params,
338
+ signatures,
339
+ });
340
+
341
+ const tradeCall: GenericCall = {
342
+ to: newQuote.call.target as Address,
343
+ data: newQuote.call.data as Hex,
344
+ value: BigInt(newQuote.call.value),
345
+ };
346
+
347
+ // Batch any required permit2 approvals + the trade into one user operation
348
+ const calls = toUserOperationCalls([...approvalCalls, tradeCall]);
349
+
350
+ const userOp = await prepareUserOperation({
351
+ bundlerClient,
352
+ account: resolvedAccount,
353
+ calls,
354
+ });
355
+
356
+ const userOpReceipt = await submitUserOperation({
357
+ bundlerClient,
358
+ account: resolvedAccount,
359
+ userOperation: userOp,
360
+ });
361
+
362
+ if (!userOpReceipt.success) {
363
+ throw new Error(
364
+ `User operation reverted${userOpReceipt.reason ? `: ${userOpReceipt.reason}` : ""}`,
365
+ );
366
+ }
367
+
368
+ return userOpReceipt.receipt;
369
+ }
370
+
371
+ /**
372
+ * Validates the parameters for a trade.
373
+ *
374
+ * Asserts slippage is within bounds and a non-zero input amount is provided.
375
+ * Shared by the quote builder (`createTradeCall`) and the user-operation path so
376
+ * both validate identically before any network request is made.
377
+ */
378
+ export function validateTradeParameters(
226
379
  tradeParameters: TradeParameters,
227
- ): Promise<PostQuoteResponse> {
380
+ ): void {
228
381
  if (tradeParameters.slippage && tradeParameters.slippage > 1) {
229
382
  throw new Error("Slippage must be less than 1, max 0.99");
230
383
  }
231
384
  if (tradeParameters.amountIn === BigInt(0)) {
232
385
  throw new Error("Amount in must be greater than 0");
233
386
  }
387
+ }
388
+
389
+ export async function createTradeCall(
390
+ tradeParameters: TradeParameters,
391
+ ): Promise<PostQuoteResponse> {
392
+ return createQuote(tradeParameters);
393
+ }
394
+
395
+ export async function createQuote(
396
+ tradeParameters: TradeParameters,
397
+ ): Promise<PostQuoteResponse> {
398
+ validateTradeParameters(tradeParameters);
234
399
 
235
400
  const quote = await postQuote({
236
401
  body: {
@@ -1,5 +1,4 @@
1
1
  import { coinABI } from "@zoralabs/protocol-deployments";
2
- import { validateClientNetwork } from "../utils/validateClientNetwork";
3
2
  import {
4
3
  Account,
5
4
  Address,
@@ -7,21 +6,39 @@ import {
7
6
  SimulateContractParameters,
8
7
  WalletClient,
9
8
  } from "viem";
10
- import { GenericPublicClient } from "../utils/genericPublicClient";
9
+ import { BundlerClient, SmartAccount } from "viem/account-abstraction";
11
10
  import { getAttribution } from "../utils/attribution";
11
+ import { toGenericCall, toUserOperationCalls } from "../utils/calls";
12
+ import { GenericPublicClient } from "../utils/genericPublicClient";
13
+ import {
14
+ prepareUserOperation,
15
+ submitUserOperation,
16
+ } from "../utils/userOperation";
17
+ import { validateClientNetwork } from "../utils/validateClientNetwork";
12
18
 
13
19
  export type UpdateCoinURIArgs = {
14
20
  coin: Address;
15
21
  newURI: string;
16
22
  };
17
23
 
18
- export function updateCoinURICall({
19
- newURI,
20
- coin,
21
- }: UpdateCoinURIArgs): SimulateContractParameters {
24
+ /**
25
+ * Validates the arguments for updating a coin's URI.
26
+ *
27
+ * Asserts the new URI is an `ipfs://` URI. Shared by the contract-call builder
28
+ * (`updateCoinURICall`) and the user-operation path so both validate identically.
29
+ */
30
+ export function validateUpdateCoinURI({ newURI }: UpdateCoinURIArgs): void {
22
31
  if (!newURI.startsWith("ipfs://")) {
23
32
  throw new Error("URI needs to be an ipfs:// prefix uri");
24
33
  }
34
+ }
35
+
36
+ export function updateCoinURICall(
37
+ args: UpdateCoinURIArgs,
38
+ ): SimulateContractParameters {
39
+ validateUpdateCoinURI(args);
40
+
41
+ const { coin, newURI } = args;
25
42
 
26
43
  return {
27
44
  abi: coinABI,
@@ -39,13 +56,18 @@ export async function updateCoinURI(
39
56
  account?: Account | Address,
40
57
  ) {
41
58
  validateClientNetwork(publicClient);
59
+
42
60
  const call = updateCoinURICall(args);
61
+
43
62
  const { request } = await publicClient.simulateContract({
44
63
  ...call,
45
64
  account: account ?? walletClient.account,
46
65
  });
66
+
47
67
  const hash = await walletClient.writeContract(request);
68
+
48
69
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
70
+
49
71
  const eventLogs = parseEventLogs({ abi: coinABI, logs: receipt.logs });
50
72
  const uriUpdated = eventLogs.find(
51
73
  (log) => log.eventName === "ContractURIUpdated",
@@ -53,3 +75,59 @@ export async function updateCoinURI(
53
75
 
54
76
  return { hash, receipt, uriUpdated };
55
77
  }
78
+
79
+ /**
80
+ * Updates a coin's URI from the caller's smart wallet via a user operation.
81
+ *
82
+ * Builds the `setContractURI` call, submits it through the bundler client (which
83
+ * wraps it in the smart wallet's `execute`), and parses the result. Mirrors
84
+ * {@link updateCoinURI}'s return shape (`hash` is the settled transaction hash,
85
+ * `receipt` the underlying transaction receipt).
86
+ */
87
+ export async function updateCoinURISmartWallet(
88
+ args: UpdateCoinURIArgs,
89
+ bundlerClient: BundlerClient,
90
+ publicClient: GenericPublicClient,
91
+ account?: SmartAccount,
92
+ ) {
93
+ const resolvedAccount = account ?? bundlerClient.account;
94
+ if (!resolvedAccount) {
95
+ throw new Error("Account is required");
96
+ }
97
+
98
+ validateClientNetwork(publicClient);
99
+
100
+ // updateCoinURICall validates the args and assembles the contract call
101
+ const call = updateCoinURICall(args);
102
+
103
+ const calls = toUserOperationCalls([toGenericCall(call)]);
104
+
105
+ const userOp = await prepareUserOperation({
106
+ bundlerClient,
107
+ account: resolvedAccount,
108
+ calls,
109
+ });
110
+
111
+ const userOpReceipt = await submitUserOperation({
112
+ bundlerClient,
113
+ account: resolvedAccount,
114
+ userOperation: userOp,
115
+ });
116
+
117
+ if (!userOpReceipt.success) {
118
+ throw new Error(
119
+ `User operation reverted${userOpReceipt.reason ? `: ${userOpReceipt.reason}` : ""}`,
120
+ );
121
+ }
122
+
123
+ const eventLogs = parseEventLogs({ abi: coinABI, logs: userOpReceipt.logs });
124
+ const uriUpdated = eventLogs.find(
125
+ (log) => log.eventName === "ContractURIUpdated",
126
+ );
127
+
128
+ return {
129
+ hash: userOpReceipt.receipt.transactionHash,
130
+ receipt: userOpReceipt.receipt,
131
+ uriUpdated,
132
+ };
133
+ }
@@ -1,24 +1,49 @@
1
1
  import { coinABI } from "@zoralabs/protocol-deployments";
2
- import { validateClientNetwork } from "../utils/validateClientNetwork";
3
2
  import {
4
3
  Account,
5
4
  Address,
5
+ isAddress,
6
6
  parseEventLogs,
7
7
  SimulateContractParameters,
8
8
  WalletClient,
9
9
  } from "viem";
10
- import { GenericPublicClient } from "../utils/genericPublicClient";
10
+ import { BundlerClient, SmartAccount } from "viem/account-abstraction";
11
11
  import { getAttribution } from "../utils/attribution";
12
+ import { toGenericCall, toUserOperationCalls } from "../utils/calls";
13
+ import { GenericPublicClient } from "../utils/genericPublicClient";
14
+ import {
15
+ prepareUserOperation,
16
+ submitUserOperation,
17
+ } from "../utils/userOperation";
18
+ import { validateClientNetwork } from "../utils/validateClientNetwork";
12
19
 
13
20
  export type UpdatePayoutRecipientArgs = {
14
21
  coin: Address;
15
22
  newPayoutRecipient: string;
16
23
  };
17
24
 
18
- export function updatePayoutRecipientCall({
25
+ /**
26
+ * Validates the arguments for updating a coin's payout recipient.
27
+ *
28
+ * Asserts the new payout recipient is a valid address. Shared by the
29
+ * contract-call builder (`updatePayoutRecipientCall`) and the user-operation path
30
+ * so both validate identically.
31
+ */
32
+ export function validateUpdatePayoutRecipient({
19
33
  newPayoutRecipient,
20
- coin,
21
- }: UpdatePayoutRecipientArgs): SimulateContractParameters {
34
+ }: UpdatePayoutRecipientArgs): void {
35
+ if (!isAddress(newPayoutRecipient)) {
36
+ throw new Error("Payout recipient must be a valid address");
37
+ }
38
+ }
39
+
40
+ export function updatePayoutRecipientCall(
41
+ args: UpdatePayoutRecipientArgs,
42
+ ): SimulateContractParameters {
43
+ validateUpdatePayoutRecipient(args);
44
+
45
+ const { coin, newPayoutRecipient } = args;
46
+
22
47
  return {
23
48
  abi: coinABI,
24
49
  address: coin,
@@ -35,13 +60,18 @@ export async function updatePayoutRecipient(
35
60
  account?: Account | Address,
36
61
  ) {
37
62
  validateClientNetwork(publicClient);
63
+
38
64
  const call = updatePayoutRecipientCall(args);
65
+
39
66
  const { request } = await publicClient.simulateContract({
40
67
  ...call,
41
68
  account: account ?? walletClient.account!,
42
69
  });
70
+
43
71
  const hash = await walletClient.writeContract(request);
72
+
44
73
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
74
+
45
75
  const eventLogs = parseEventLogs({ abi: coinABI, logs: receipt.logs });
46
76
  const payoutRecipientUpdated = eventLogs.find(
47
77
  (log) => log.eventName === "CoinPayoutRecipientUpdated",
@@ -49,3 +79,60 @@ export async function updatePayoutRecipient(
49
79
 
50
80
  return { hash, receipt, payoutRecipientUpdated };
51
81
  }
82
+
83
+ /**
84
+ * Updates a coin's payout recipient from the caller's smart wallet via a user
85
+ * operation.
86
+ *
87
+ * Builds the `setPayoutRecipient` call, submits it through the bundler client
88
+ * (which wraps it in the smart wallet's `execute`), and parses the result.
89
+ * Mirrors {@link updatePayoutRecipient}'s return shape (`hash` is the settled
90
+ * transaction hash, `receipt` the underlying transaction receipt).
91
+ */
92
+ export async function updatePayoutRecipientSmartWallet(
93
+ args: UpdatePayoutRecipientArgs,
94
+ bundlerClient: BundlerClient,
95
+ publicClient: GenericPublicClient,
96
+ account?: SmartAccount,
97
+ ) {
98
+ const resolvedAccount = account ?? bundlerClient.account;
99
+ if (!resolvedAccount) {
100
+ throw new Error("Account is required");
101
+ }
102
+
103
+ validateClientNetwork(publicClient);
104
+
105
+ // updatePayoutRecipientCall validates the args and assembles the contract call
106
+ const call = updatePayoutRecipientCall(args);
107
+
108
+ const calls = toUserOperationCalls([toGenericCall(call)]);
109
+
110
+ const userOp = await prepareUserOperation({
111
+ bundlerClient,
112
+ account: resolvedAccount,
113
+ calls,
114
+ });
115
+
116
+ const userOpReceipt = await submitUserOperation({
117
+ bundlerClient,
118
+ account: resolvedAccount,
119
+ userOperation: userOp,
120
+ });
121
+
122
+ if (!userOpReceipt.success) {
123
+ throw new Error(
124
+ `User operation reverted${userOpReceipt.reason ? `: ${userOpReceipt.reason}` : ""}`,
125
+ );
126
+ }
127
+
128
+ const eventLogs = parseEventLogs({ abi: coinABI, logs: userOpReceipt.logs });
129
+ const payoutRecipientUpdated = eventLogs.find(
130
+ (log) => log.eventName === "CoinPayoutRecipientUpdated",
131
+ );
132
+
133
+ return {
134
+ hash: userOpReceipt.receipt.transactionHash,
135
+ receipt: userOpReceipt.receipt,
136
+ payoutRecipientUpdated,
137
+ };
138
+ }
@@ -0,0 +1,61 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { client } from "../client/client.gen";
3
+ import { apiUrl } from "./api-raw";
4
+
5
+ const mockBaseUrl = (baseUrl: string | undefined) =>
6
+ vi
7
+ .spyOn(client, "getConfig")
8
+ .mockReturnValue({ baseUrl } as ReturnType<typeof client.getConfig>);
9
+
10
+ describe("apiUrl", () => {
11
+ const expected = "https://api.example.com/some/path";
12
+
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it("joins base without trailing slash and path with leading slash", () => {
18
+ mockBaseUrl("https://api.example.com");
19
+ expect(apiUrl("/some/path")).toBe(expected);
20
+ });
21
+
22
+ it("joins base without trailing slash and path without leading slash", () => {
23
+ mockBaseUrl("https://api.example.com");
24
+ expect(apiUrl("some/path")).toBe(expected);
25
+ });
26
+
27
+ it("joins base with trailing slash and path with leading slash", () => {
28
+ mockBaseUrl("https://api.example.com/");
29
+ expect(apiUrl("/some/path")).toBe(expected);
30
+ });
31
+
32
+ it("joins base with trailing slash and path without leading slash", () => {
33
+ mockBaseUrl("https://api.example.com/");
34
+ expect(apiUrl("some/path")).toBe(expected);
35
+ });
36
+
37
+ it("collapses multiple trailing slashes on the base", () => {
38
+ mockBaseUrl("https://api.example.com///");
39
+ expect(apiUrl("some/path")).toBe(expected);
40
+ });
41
+
42
+ it("collapses multiple leading slashes on the path", () => {
43
+ mockBaseUrl("https://api.example.com");
44
+ expect(apiUrl("///some/path")).toBe(expected);
45
+ });
46
+
47
+ it("preserves the protocol's double slashes", () => {
48
+ mockBaseUrl("https://api.example.com/");
49
+ expect(apiUrl("/some/path")).toContain("https://");
50
+ });
51
+
52
+ it("handles an undefined base url", () => {
53
+ mockBaseUrl(undefined);
54
+ expect(apiUrl("/some/path")).toBe("/some/path");
55
+ });
56
+
57
+ it("handles an empty path", () => {
58
+ mockBaseUrl("https://api.example.com");
59
+ expect(apiUrl("")).toBe("https://api.example.com/");
60
+ });
61
+ });