@zoralabs/coins-sdk 0.6.0 → 0.7.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.
@@ -8,6 +8,15 @@ export const apiGet = (path: string, data?: Record<string, unknown>) =>
8
8
  export const apiPost = (path: string, data?: Record<string, unknown>) =>
9
9
  client.post({ url: path, body: data, ...getApiKeyMeta() });
10
10
 
11
+ export const apiUrl = (path: string) => {
12
+ const baseUrl = client.getConfig().baseUrl ?? "";
13
+ // normalize the join boundary so we never end up with double slashes
14
+ // (or a missing slash) between the base URL and the path
15
+ const normalizedBase = baseUrl.replace(/\/+$/, "");
16
+ const normalizedPath = path.replace(/^\/+/, "");
17
+ return `${normalizedBase}/${normalizedPath}`;
18
+ };
19
+
11
20
  export const setApiBaseUrl = (baseUrl: string) => {
12
21
  client.setConfig(createConfig({ baseUrl }));
13
22
  };
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export {
2
2
  createCoin,
3
+ createCoinSmartWallet,
3
4
  createCoinCall,
5
+ validateCreateCoinCalls,
6
+ validateCreateCoinSmartWalletCalls,
4
7
  getCoinCreateFromLogs,
5
8
  CreateConstants,
6
9
  } from "./actions/createCoin";
@@ -12,18 +15,53 @@ export type {
12
15
  ContentCoinCurrency,
13
16
  } from "./actions/createCoin";
14
17
 
15
- export { updateCoinURI, updateCoinURICall } from "./actions/updateCoinURI";
18
+ export {
19
+ updateCoinURI,
20
+ updateCoinURISmartWallet,
21
+ updateCoinURICall,
22
+ validateUpdateCoinURI,
23
+ } from "./actions/updateCoinURI";
16
24
  export type { UpdateCoinURIArgs } from "./actions/updateCoinURI";
17
25
 
18
26
  export {
19
27
  updatePayoutRecipient,
28
+ updatePayoutRecipientSmartWallet,
20
29
  updatePayoutRecipientCall,
30
+ validateUpdatePayoutRecipient,
21
31
  } from "./actions/updatePayoutRecipient";
22
32
  export type { UpdatePayoutRecipientArgs } from "./actions/updatePayoutRecipient";
23
33
 
24
- export { tradeCoin, createTradeCall } from "./actions/tradeCoin";
34
+ export {
35
+ tradeCoin,
36
+ tradeCoinSmartWallet,
37
+ createTradeCall,
38
+ createQuote,
39
+ validateTradeParameters,
40
+ } from "./actions/tradeCoin";
25
41
  export type { TradeParameters } from "./actions/tradeCoin";
26
42
 
43
+ // Normalized call types + user-operation adapter
44
+ export {
45
+ toGenericCall,
46
+ toUserOperationCalls,
47
+ isContractCall,
48
+ isSendCall,
49
+ } from "./utils/calls";
50
+ export type {
51
+ GenericCall,
52
+ UserOperationCall,
53
+ ContractCall,
54
+ SendCall,
55
+ } from "./utils/calls";
56
+
57
+ // User Operation Utils
58
+ export {
59
+ prepareUserOperation,
60
+ submitUserOperation,
61
+ CoinbaseGasError,
62
+ } from "./utils/userOperation";
63
+ export type { PreparedUserOperation } from "./utils/userOperation";
64
+
27
65
  // API Read Actions
28
66
  export * from "./api/queries";
29
67
  export type * from "./api/queries";
@@ -40,7 +78,7 @@ export type * from "./api/social";
40
78
  export { setApiKey } from "./api/api-key";
41
79
 
42
80
  // Raw API helpers
43
- export { apiGet, apiPost, setApiBaseUrl } from "./api/api-raw";
81
+ export { apiGet, apiPost, apiUrl, setApiBaseUrl } from "./api/api-raw";
44
82
 
45
83
  // Metadata Validation Utils
46
84
  export * from "./metadata";
@@ -10,11 +10,25 @@ import { validateMetadataJSON } from "./validateMetadataJSON";
10
10
  export async function validateMetadataURIContent(
11
11
  metadataURI: ValidMetadataURI,
12
12
  ) {
13
+ let response: Response;
13
14
  const cleanedURI = cleanAndValidateMetadataURI(metadataURI);
14
- const response = await fetch(cleanedURI);
15
+
16
+ try {
17
+ response = await fetch(cleanedURI);
18
+ } catch (error) {
19
+ // handle actual fetch failures (i.e. network errors)
20
+ const errorMessage = error instanceof Error ? error.message : String(error);
21
+ throw new Error(
22
+ `Metadata fetch failed for URL '${cleanedURI}': ${errorMessage}`,
23
+ );
24
+ }
25
+
15
26
  if (!response.ok) {
16
- throw new Error("Metadata fetch failed");
27
+ throw new Error(
28
+ `Metadata fetch failed for URL '${cleanedURI}': ${response.statusText ? `${response.statusText} (HTTP ${response.status})` : `HTTP ${response.status}`}`,
29
+ );
17
30
  }
31
+
18
32
  if (
19
33
  !["application/json", "text/plain"].includes(
20
34
  response.headers.get("content-type") ?? "",
@@ -22,6 +36,7 @@ export async function validateMetadataURIContent(
22
36
  ) {
23
37
  throw new Error("Metadata is not a valid JSON or plain text response type");
24
38
  }
39
+
25
40
  const metadataJson = await response.json();
26
41
  return validateMetadataJSON(metadataJson);
27
42
  }
@@ -0,0 +1,129 @@
1
+ import {
2
+ type Abi,
3
+ type Address,
4
+ concatHex,
5
+ type ContractFunctionArgs,
6
+ type ContractFunctionName,
7
+ type ContractFunctionParameters,
8
+ encodeFunctionData,
9
+ type Hex,
10
+ } from "viem";
11
+
12
+ const EMPTY_HEX: Hex = "0x";
13
+
14
+ type WritableMutability = "payable" | "nonpayable";
15
+
16
+ type WritableFunction<abi extends Abi | readonly unknown[]> =
17
+ ContractFunctionName<abi, WritableMutability>;
18
+
19
+ type WritableArgs<
20
+ abi extends Abi | readonly unknown[],
21
+ functionName extends WritableFunction<abi>,
22
+ > = ContractFunctionArgs<abi, WritableMutability, functionName>;
23
+
24
+ export type ContractCall<
25
+ abi extends Abi | readonly unknown[] = readonly unknown[],
26
+ fn extends WritableFunction<abi> = WritableFunction<abi>,
27
+ args extends WritableArgs<abi, fn> = WritableArgs<abi, fn>,
28
+ > = ContractFunctionParameters<abi, WritableMutability, fn, args> & {
29
+ /** Optional ETH value to send with the call. */
30
+ value?: bigint;
31
+ /**
32
+ * Optional calldata appended after the encoded function data, e.g. for
33
+ * attribution. Mirrors viem's `dataSuffix`; concatenated by {@link toGenericCall}.
34
+ */
35
+ dataSuffix?: Hex;
36
+ };
37
+
38
+ export const isContractCall = (
39
+ call: ContractCall | SendCall,
40
+ ): call is ContractCall => {
41
+ return (
42
+ (call as ContractCall).address !== undefined &&
43
+ (call as ContractCall).abi !== undefined &&
44
+ (call as ContractCall).functionName !== undefined
45
+ );
46
+ };
47
+
48
+ export type SendCall = {
49
+ to: Address;
50
+ value?: bigint;
51
+ };
52
+
53
+ export const isSendCall = (call: ContractCall | SendCall): call is SendCall => {
54
+ return !isContractCall(call) && (call as SendCall).to !== undefined;
55
+ };
56
+
57
+ /**
58
+ * A normalized, fully-encoded contract call.
59
+ *
60
+ * This is the canonical call shape emitted by the action `createAndValidate*Calls`
61
+ * builders. It intentionally matches the encoded-call form accepted by both
62
+ * `walletClient.sendTransaction` (EOA execution) and viem's bundler client
63
+ * `prepareUserOperation` / `sendUserOperation` (smart wallet / user operation
64
+ * execution), so a single call list can drive either flow.
65
+ */
66
+ export type GenericCall = {
67
+ to: Address;
68
+ data: Hex;
69
+ value: bigint;
70
+ };
71
+
72
+ /**
73
+ * The encoded-call shape accepted by viem's bundler client `calls` parameter
74
+ * (`prepareUserOperation` / `sendUserOperation`).
75
+ *
76
+ * `data` and `value` are optional on viem's side; we always populate them from a
77
+ * {@link GenericCall}.
78
+ */
79
+ export type UserOperationCall = {
80
+ to: Address;
81
+ data?: Hex;
82
+ value?: bigint;
83
+ };
84
+
85
+ /**
86
+ * Converts a contract call or send call to a generic call.
87
+ */
88
+ export function toGenericCall(call: ContractCall | SendCall): GenericCall {
89
+ // convert a simple send call to a user operation call
90
+ if (isSendCall(call)) {
91
+ return {
92
+ to: call.to,
93
+ value: call.value ?? 0n,
94
+ data: EMPTY_HEX,
95
+ };
96
+ }
97
+
98
+ // convert a contract call to a user operation call
99
+ // if the call has a data suffix, we need to manually concatenate it with the call data
100
+ // it is used to add the attribution to the call data
101
+ const { dataSuffix } = call;
102
+ const callData = encodeFunctionData(call);
103
+ const data = dataSuffix ? concatHex([callData, dataSuffix]) : callData;
104
+
105
+ return {
106
+ to: call.address,
107
+ value: call.value ?? 0n,
108
+ data,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Adapts a list of {@link GenericCall} into the call shape expected by viem's
114
+ * bundler client.
115
+ *
116
+ * A {@link GenericCall} is already fully encoded calldata, so this is a thin
117
+ * adapter rather than an encoder. It exists as an explicit seam: it owns any
118
+ * future divergence between our `GenericCall` shape and viem's user-operation
119
+ * call type, and keeps the conversion point obvious at call sites.
120
+ */
121
+ export function toUserOperationCalls(
122
+ calls: GenericCall[],
123
+ ): UserOperationCall[] {
124
+ return calls.map((call) => ({
125
+ to: call.to,
126
+ data: call.data,
127
+ value: call.value,
128
+ }));
129
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CoinbaseGasError } from "./userOperation";
3
+
4
+ // The constructor only reads `details`, `cause`, and `message`, but the param
5
+ // is typed with the full viem bundler-error shape, so fill the rest with stubs.
6
+ const makeBundlerError = (
7
+ overrides: Partial<{ details: string; cause: unknown; message: string }> = {},
8
+ ) => ({
9
+ stack: "stack",
10
+ message: overrides.message ?? "bundler error message",
11
+ cause: "cause" in overrides ? overrides.cause : { code: -32000 },
12
+ details: overrides.details ?? "",
13
+ docsPath: "",
14
+ shortMessage: "",
15
+ version: "viem@2.x",
16
+ name: "UserOperationExecutionError",
17
+ });
18
+
19
+ const precheck = (balance: string, required: string) =>
20
+ `precheck failed: sender balance and deposit together is ${balance} but must be at least ${required} to pay for this operation`;
21
+
22
+ describe("CoinbaseGasError", () => {
23
+ it("formats both balance and required when present", () => {
24
+ // 1001597376034823 wei = 0.001001597376034823 ETH
25
+ // 10704882000000000 wei = 0.010704882 ETH
26
+ const error = new CoinbaseGasError(
27
+ makeBundlerError({
28
+ details: precheck("1001597376034823", "10704882000000000"),
29
+ }),
30
+ );
31
+
32
+ expect(error.message).toBe(
33
+ "Insufficient balance. You need at least 0.010704882 ETH to pay for this operation, but you only have 0.001001597376034823 ETH.",
34
+ );
35
+ });
36
+
37
+ it("falls back to required-only when balance is missing", () => {
38
+ const error = new CoinbaseGasError(
39
+ makeBundlerError({ details: precheck("", "10704882000000000") }),
40
+ );
41
+
42
+ expect(error.message).toBe(
43
+ "Insufficient balance. Make sure you have at least 0.010704882 ETH in your wallet.",
44
+ );
45
+ });
46
+
47
+ it("falls back to generic message when only balance is present", () => {
48
+ const error = new CoinbaseGasError(
49
+ makeBundlerError({ details: precheck("1001597376034823", "") }),
50
+ );
51
+
52
+ expect(error.message).toBe(
53
+ "Insufficient balance. Make sure you have enough ETH to pay for this operation.",
54
+ );
55
+ });
56
+
57
+ it("falls back to generic message when both amounts are missing", () => {
58
+ const error = new CoinbaseGasError(
59
+ makeBundlerError({ details: precheck("", "") }),
60
+ );
61
+
62
+ expect(error.message).toBe(
63
+ "Insufficient balance. Make sure you have enough ETH to pay for this operation.",
64
+ );
65
+ });
66
+
67
+ it("uses the raw details when the precheck pattern does not match", () => {
68
+ const details = "AA13 initCode failed or OOG";
69
+ const error = new CoinbaseGasError(makeBundlerError({ details }));
70
+
71
+ expect(error.message).toBe(details);
72
+ });
73
+
74
+ it("propagates cause and details onto the instance", () => {
75
+ const cause = { code: -32000 };
76
+ const details = precheck("1001597376034823", "10704882000000000");
77
+ const error = new CoinbaseGasError(makeBundlerError({ details, cause }));
78
+
79
+ expect(error).toBeInstanceOf(Error);
80
+ expect(error).toBeInstanceOf(CoinbaseGasError);
81
+ expect(error.cause).toBe(cause);
82
+ expect(error.details).toBe(details);
83
+ });
84
+ });
@@ -0,0 +1,124 @@
1
+ import { formatEther, Hex } from "viem";
2
+ import type {
3
+ BundlerClient,
4
+ SmartAccount,
5
+ UserOperation,
6
+ UserOperationReceipt,
7
+ } from "viem/account-abstraction";
8
+ import { UserOperationCall } from "./calls";
9
+
10
+ // Coinbase Smart Wallet uses ERC-4337 entry point 0.6.
11
+ export type PreparedUserOperation = UserOperation<"0.6">;
12
+
13
+ /**
14
+ * Prepares a user operation from a list of contract calls.
15
+ * Returns a fully-populated UserOperation (gas estimated, nonce filled) with a
16
+ * stub signature from gas estimation. Must be re-signed before submitting.
17
+ */
18
+ export const prepareUserOperation = async ({
19
+ bundlerClient,
20
+ account,
21
+ calls,
22
+ }: {
23
+ bundlerClient: BundlerClient;
24
+ account: SmartAccount;
25
+ calls: readonly UserOperationCall[];
26
+ }): Promise<PreparedUserOperation> => {
27
+ const prepared = await bundlerClient.prepareUserOperation({
28
+ account,
29
+ calls,
30
+ });
31
+ return prepared as PreparedUserOperation;
32
+ };
33
+
34
+ /**
35
+ * Signs and submits a prepared user operation, then waits for the receipt.
36
+ *
37
+ * The prepared op carries a stub signature from gas estimation, so we re-sign
38
+ * here before sending. Otherwise viem's sendUserOperation would forward the
39
+ * stub and the bundler would reject it as invalid.
40
+ */
41
+ export const submitUserOperation = async ({
42
+ bundlerClient,
43
+ account,
44
+ userOperation,
45
+ }: {
46
+ bundlerClient: BundlerClient;
47
+ account: SmartAccount;
48
+ userOperation: PreparedUserOperation;
49
+ }): Promise<UserOperationReceipt> => {
50
+ let hash: Hex;
51
+ const signature = await account.signUserOperation(userOperation);
52
+
53
+ try {
54
+ hash = await bundlerClient.sendUserOperation({
55
+ account,
56
+ ...userOperation,
57
+ signature,
58
+ });
59
+ } catch (error) {
60
+ // handle gas errors to provide better user feedback
61
+ if (isGasError(error)) {
62
+ throw new CoinbaseGasError(error);
63
+ }
64
+ throw error;
65
+ }
66
+
67
+ return bundlerClient.waitForUserOperationReceipt({ hash });
68
+ };
69
+
70
+ type CoinbaseBundlerError = {
71
+ stack: string;
72
+ message: string;
73
+ cause: unknown;
74
+ details: string;
75
+ docsPath: string;
76
+ shortMessage: string;
77
+ version: string;
78
+ name: string;
79
+ };
80
+
81
+ export class CoinbaseGasError extends Error {
82
+ cause: unknown;
83
+ details: string;
84
+ required?: bigint;
85
+ available?: bigint;
86
+ constructor(error: CoinbaseBundlerError) {
87
+ let message: string;
88
+ let available: bigint | undefined;
89
+ let required: bigint | undefined;
90
+
91
+ const match = error.details.match(
92
+ /precheck failed: sender balance and deposit together is (\d+)? but must be at least (\d+)? to pay for this operation/,
93
+ );
94
+
95
+ if (match) {
96
+ available = match[1] ? BigInt(match[1]) : undefined;
97
+ required = match[2] ? BigInt(match[2]) : undefined;
98
+
99
+ if (available !== undefined && required !== undefined) {
100
+ message = `Insufficient balance. You need at least ${formatEther(required)} ETH to pay for this operation, but you only have ${formatEther(available)} ETH.`;
101
+ } else if (required !== undefined) {
102
+ message = `Insufficient balance. Make sure you have at least ${formatEther(required)} ETH in your wallet.`;
103
+ } else {
104
+ message = `Insufficient balance. Make sure you have enough ETH to pay for this operation.`;
105
+ }
106
+ } else {
107
+ message = error.details ?? error.message;
108
+ }
109
+
110
+ super(message);
111
+ this.cause = error.cause;
112
+ this.details = error.details;
113
+ this.available = available;
114
+ this.required = required;
115
+ }
116
+ }
117
+
118
+ function isGasError(error: unknown): error is CoinbaseBundlerError {
119
+ return (
120
+ (error as CoinbaseBundlerError).details?.startsWith(
121
+ "precheck failed: sender balance and deposit together is",
122
+ ) ?? false
123
+ );
124
+ }