@solana-mobile/dapp-store-cli 0.14.0 → 0.16.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.
@@ -3,6 +3,7 @@ import type { MetaplexFile } from "@metaplex-foundation/js";
3
3
  import { TurboFactory, lamportToTokenAmount } from "@ardrive/turbo-sdk";
4
4
  import bs58 from "bs58";
5
5
  import debugModule from "debug";
6
+ import { buildPublicContentUrl, type StorageNetwork } from "./contentGateway.js";
6
7
 
7
8
  const debug = debugModule("cli:turbo-storage");
8
9
 
@@ -22,18 +23,33 @@ interface TurboClient {
22
23
  }): Promise<{ id: string }>;
23
24
  }
24
25
 
26
+ type TurboServiceConfig = {
27
+ gatewayUrl?: string;
28
+ paymentServiceConfig?: { url: string };
29
+ uploadServiceConfig?: { url: string };
30
+ };
31
+
32
+ const SOL_IN_LAMPORTS = 1_000_000_000;
33
+ const MIN_TOP_UP_LAMPORTS = 1_000_000;
34
+ const MIN_TOP_UP_SOL = MIN_TOP_UP_LAMPORTS / SOL_IN_LAMPORTS;
35
+
25
36
  const CONSTANTS = {
26
37
  FREE_UPLOAD_LIMIT: 97_280, // 95 KiB
27
38
  UPLOAD_DELAY_MS: 2000,
28
39
  MAX_RETRIES: 5,
29
- SOL_IN_LAMPORTS: 1_000_000_000,
40
+ SOL_IN_LAMPORTS,
41
+ MIN_TOP_UP_SOL,
42
+ MIN_TOP_UP_LAMPORTS,
30
43
  BACKOFF: {
31
44
  BASE_MS: 500,
32
45
  MAX_MS: 8000,
33
46
  },
34
- GATEWAYS: {
35
- devnet: "https://turbo.ardrive.dev/raw",
36
- mainnet: "https://arweave.net",
47
+ SERVICE_URLS: {
48
+ devnet: {
49
+ gatewayUrl: "https://api.devnet.solana.com",
50
+ uploadServiceConfig: { url: "https://upload.ardrive.dev" },
51
+ paymentServiceConfig: { url: "https://payment.ardrive.dev" },
52
+ },
37
53
  },
38
54
  } as const;
39
55
 
@@ -43,7 +59,7 @@ const delay = (ms: number): Promise<void> =>
43
59
  export class TurboStorageDriver {
44
60
  private turbo: TurboClient;
45
61
  private bufferPercentage: number;
46
- private network: "devnet" | "mainnet";
62
+ private network: StorageNetwork;
47
63
 
48
64
  private uploadQueue: Array<{
49
65
  file: MetaplexFile;
@@ -54,7 +70,7 @@ export class TurboStorageDriver {
54
70
 
55
71
  constructor(
56
72
  keypair: Keypair,
57
- network: "devnet" | "mainnet" = "mainnet",
73
+ network: StorageNetwork = "mainnet",
58
74
  bufferPercentage = 20
59
75
  ) {
60
76
  this.network = network;
@@ -63,18 +79,10 @@ export class TurboStorageDriver {
63
79
  this.turbo = TurboFactory.authenticated({
64
80
  privateKey: bs58.encode(keypair.secretKey),
65
81
  token: "solana",
66
- ...this.getServiceUrls(network === "devnet"),
82
+ ...getTurboServiceConfig(network),
67
83
  }) as TurboClient;
68
84
  }
69
85
 
70
- private getServiceUrls(isDev: boolean) {
71
- const base = isDev ? "ardrive.dev" : "ardrive.io";
72
- return {
73
- uploadUrl: `https://upload.${base}`,
74
- paymentUrl: `https://payment.${base}`,
75
- };
76
- }
77
-
78
86
  async getUploadPrice(bytes: number): Promise<bigint> {
79
87
  if (bytes <= CONSTANTS.FREE_UPLOAD_LIMIT) return BigInt(0);
80
88
 
@@ -131,6 +139,11 @@ export class TurboStorageDriver {
131
139
  }
132
140
 
133
141
  private async topUpCredits(wincAmount: bigint): Promise<void> {
142
+ if (wincAmount === 0n) {
143
+ debug("No Winston Credits requested; skipping top-up.");
144
+ return;
145
+ }
146
+
134
147
  try {
135
148
  await this.withRetry(async () => {
136
149
  const exchangeRate = await this.turbo.getWincForToken?.({
@@ -142,17 +155,35 @@ export class TurboStorageDriver {
142
155
  }
143
156
 
144
157
  const wincPerSol = BigInt(String(exchangeRate.winc));
145
- const lamportsNeeded =
146
- (wincAmount * BigInt(CONSTANTS.SOL_IN_LAMPORTS)) / wincPerSol;
158
+ if (wincPerSol <= 0n) {
159
+ throw new Error("Invalid Winston Credits exchange rate");
160
+ }
161
+
162
+ const solInLamports = BigInt(CONSTANTS.SOL_IN_LAMPORTS);
163
+ const numerator = wincAmount * solInLamports;
164
+ const lamportsCalculated =
165
+ (numerator + (wincPerSol - 1n)) / wincPerSol;
166
+
167
+ const minLamports = BigInt(CONSTANTS.MIN_TOP_UP_LAMPORTS);
168
+ const lamportsToUse =
169
+ lamportsCalculated < minLamports ? minLamports : lamportsCalculated;
170
+
171
+ if (lamportsToUse > lamportsCalculated) {
172
+ debug(
173
+ `Applying minimum top-up of ${CONSTANTS.MIN_TOP_UP_SOL} SOL (${CONSTANTS.MIN_TOP_UP_LAMPORTS} lamports)`
174
+ );
175
+ }
176
+ const solAmount =
177
+ Number(lamportsToUse) / CONSTANTS.SOL_IN_LAMPORTS;
147
178
 
148
179
  debug(
149
- `Buying ${wincAmount} Winston Credits for ~${
150
- Number(lamportsNeeded) / 1e9
151
- } SOL`
180
+ `Buying at least ${wincAmount} Winston Credits (~${solAmount.toFixed(9)} SOL / ${lamportsToUse} lamports)`
152
181
  );
153
182
 
154
183
  await this.turbo.topUpWithTokens?.({
155
- tokenAmount: String(lamportToTokenAmount(lamportsNeeded.toString())),
184
+ tokenAmount: String(
185
+ lamportToTokenAmount(lamportsToUse.toString())
186
+ ),
156
187
  });
157
188
 
158
189
  debug(`Top-up initiated for ${wincAmount} Winston Credits`);
@@ -219,8 +250,7 @@ export class TurboStorageDriver {
219
250
  dataItemOpts: { tags },
220
251
  });
221
252
 
222
- const gateway = CONSTANTS.GATEWAYS[this.network];
223
- const url = `${gateway}/${uploadResult.id}`;
253
+ const url = buildPublicContentUrl(uploadResult.id, this.network);
224
254
  debug(`Upload complete: ${url}`);
225
255
  item.resolve(url);
226
256
 
@@ -246,3 +276,8 @@ export class TurboStorageDriver {
246
276
  });
247
277
  }
248
278
  }
279
+
280
+ export const getTurboServiceConfig = (
281
+ network: StorageNetwork
282
+ ): TurboServiceConfig =>
283
+ network === "devnet" ? CONSTANTS.SERVICE_URLS.devnet : {};
@@ -0,0 +1,246 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { createHash } from "crypto";
5
+ import { jest } from "@jest/globals";
6
+ import type { MetaplexFile, StorageDriver } from "@metaplex-foundation/js";
7
+ import { CachedStorageDriver } from "../CachedStorageDriver";
8
+
9
+ type MockStorageDriver = Pick<StorageDriver, "getUploadPrice" | "upload">;
10
+
11
+ describe("CachedStorageDriver", () => {
12
+ const originalCwd = process.cwd();
13
+ let tempDir: string;
14
+
15
+ beforeEach(() => {
16
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cached-storage-driver-"));
17
+ process.chdir(tempDir);
18
+ });
19
+
20
+ afterEach(() => {
21
+ process.chdir(originalCwd);
22
+ fs.rmSync(tempDir, { recursive: true, force: true });
23
+ });
24
+
25
+ test("normalizes a cached legacy arweave URL and persists the rewritten manifest", async () => {
26
+ const file = makeMetaplexFile("icon.png", Buffer.from("cached-asset"));
27
+ const hash = hashBuffer(file.buffer);
28
+ const manifestPath = ".asset-manifest.json";
29
+
30
+ fs.writeFileSync(
31
+ path.join(tempDir, manifestPath),
32
+ JSON.stringify(
33
+ {
34
+ assets: {
35
+ [file.fileName]: {
36
+ path: file.fileName,
37
+ sha256: hash,
38
+ uri: "https://arweave.net/legacy-id",
39
+ },
40
+ },
41
+ },
42
+ null,
43
+ 2
44
+ )
45
+ );
46
+
47
+ let uploadCalls = 0;
48
+ const storageDriver: MockStorageDriver = {
49
+ getUploadPrice: async (_bytes: number) => {
50
+ throw new Error("getUploadPrice should not be called in this test");
51
+ },
52
+ upload: async (_file: MetaplexFile) => {
53
+ uploadCalls += 1;
54
+ throw new Error("upload should not be called in this test");
55
+ },
56
+ };
57
+
58
+ const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
59
+ assetManifestPath: manifestPath,
60
+ });
61
+
62
+ await expect(driver.upload(file)).resolves.toBe(
63
+ "https://dappstorecontent.com/legacy-id"
64
+ );
65
+ expect(uploadCalls).toBe(0);
66
+
67
+ const rewrittenManifest = JSON.parse(
68
+ fs.readFileSync(path.join(tempDir, manifestPath), "utf-8")
69
+ );
70
+ expect(rewrittenManifest.schema_version).toBe("0.1");
71
+ expect(rewrittenManifest.assets[file.fileName].uri).toBe(
72
+ "https://dappstorecontent.com/legacy-id"
73
+ );
74
+ });
75
+
76
+ test("supports absolute asset manifest paths", async () => {
77
+ const file = makeMetaplexFile("absolute-icon.png", Buffer.from("cached-asset"));
78
+ const hash = hashBuffer(file.buffer);
79
+ const manifestPath = path.join(tempDir, ".asset-manifest.json");
80
+
81
+ fs.writeFileSync(
82
+ manifestPath,
83
+ JSON.stringify(
84
+ {
85
+ assets: {
86
+ [file.fileName]: {
87
+ path: file.fileName,
88
+ sha256: hash,
89
+ uri: "https://arweave.net/absolute-id",
90
+ },
91
+ },
92
+ },
93
+ null,
94
+ 2
95
+ )
96
+ );
97
+
98
+ let uploadCalls = 0;
99
+ const storageDriver: MockStorageDriver = {
100
+ getUploadPrice: async (_bytes: number) => {
101
+ throw new Error("getUploadPrice should not be called in this test");
102
+ },
103
+ upload: async (_file: MetaplexFile) => {
104
+ uploadCalls += 1;
105
+ throw new Error("upload should not be called in this test");
106
+ },
107
+ };
108
+
109
+ const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
110
+ assetManifestPath: manifestPath,
111
+ });
112
+
113
+ await expect(driver.upload(file)).resolves.toBe(
114
+ "https://dappstorecontent.com/absolute-id"
115
+ );
116
+ expect(uploadCalls).toBe(0);
117
+
118
+ const rewrittenManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
119
+ expect(rewrittenManifest.assets[file.fileName].uri).toBe(
120
+ "https://dappstorecontent.com/absolute-id"
121
+ );
122
+ });
123
+
124
+ test("normalizes fresh uploads before persisting them in the manifest", async () => {
125
+ const file = makeMetaplexFile("banner.png", Buffer.from("fresh-asset"));
126
+ const manifestPath = ".asset-manifest.json";
127
+
128
+ let uploadCalls = 0;
129
+ const storageDriver: MockStorageDriver = {
130
+ getUploadPrice: async (_bytes: number) => {
131
+ throw new Error("getUploadPrice should not be called in this test");
132
+ },
133
+ upload: async (_file: MetaplexFile) => {
134
+ uploadCalls += 1;
135
+ return "https://arweave.com/fresh-id";
136
+ },
137
+ };
138
+
139
+ const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
140
+ assetManifestPath: manifestPath,
141
+ });
142
+
143
+ await expect(driver.upload(file)).resolves.toBe(
144
+ "https://dappstorecontent.com/fresh-id"
145
+ );
146
+ expect(uploadCalls).toBe(1);
147
+
148
+ const persistedManifest = JSON.parse(
149
+ fs.readFileSync(path.join(tempDir, manifestPath), "utf-8")
150
+ );
151
+ expect(persistedManifest.schema_version).toBe("0.1");
152
+ expect(persistedManifest.assets[file.fileName].uri).toBe(
153
+ "https://dappstorecontent.com/fresh-id"
154
+ );
155
+ });
156
+
157
+ test("normalizes inline metadata uploads without caching them", async () => {
158
+ const file = makeMetaplexFile("inline.json", Buffer.from("{}"));
159
+ let uploadCalls = 0;
160
+ const storageDriver: MockStorageDriver = {
161
+ getUploadPrice: async (_bytes: number) => {
162
+ throw new Error("getUploadPrice should not be called in this test");
163
+ },
164
+ upload: async (_file: MetaplexFile) => {
165
+ uploadCalls += 1;
166
+ return "https://arweave.net/metadata-id";
167
+ },
168
+ };
169
+
170
+ const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
171
+ assetManifestPath: ".asset-manifest.json",
172
+ });
173
+
174
+ await expect(driver.upload(file)).resolves.toBe(
175
+ "https://dappstorecontent.com/metadata-id"
176
+ );
177
+ expect(uploadCalls).toBe(1);
178
+ expect(fs.existsSync(path.join(tempDir, ".asset-manifest.json"))).toBe(
179
+ false
180
+ );
181
+ });
182
+
183
+ test("returns a normalized cached URL even when manifest rewrite persistence fails", async () => {
184
+ const file = makeMetaplexFile("icon.png", Buffer.from("cached-asset"));
185
+ const hash = hashBuffer(file.buffer);
186
+ const manifestPath = ".asset-manifest.json";
187
+
188
+ fs.writeFileSync(
189
+ path.join(tempDir, manifestPath),
190
+ JSON.stringify(
191
+ {
192
+ assets: {
193
+ [file.fileName]: {
194
+ path: file.fileName,
195
+ sha256: hash,
196
+ uri: "https://arweave.net/rewrite-failure-id",
197
+ },
198
+ },
199
+ },
200
+ null,
201
+ 2
202
+ )
203
+ );
204
+
205
+ const writeFileSpy = jest
206
+ .spyOn(fs.promises, "writeFile")
207
+ .mockRejectedValueOnce(new Error("disk full"));
208
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
209
+
210
+ const storageDriver: MockStorageDriver = {
211
+ getUploadPrice: async (_bytes: number) => {
212
+ throw new Error("getUploadPrice should not be called in this test");
213
+ },
214
+ upload: async (_file: MetaplexFile) => {
215
+ throw new Error("upload should not be called in this test");
216
+ },
217
+ };
218
+
219
+ const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
220
+ assetManifestPath: manifestPath,
221
+ });
222
+
223
+ try {
224
+ await expect(driver.upload(file)).resolves.toBe(
225
+ "https://dappstorecontent.com/rewrite-failure-id"
226
+ );
227
+ expect(warnSpy).toHaveBeenCalledWith(
228
+ expect.stringContaining(
229
+ "Failed to rewrite .asset-manifest.json; continuing with normalized URL"
230
+ )
231
+ );
232
+ } finally {
233
+ writeFileSpy.mockRestore();
234
+ warnSpy.mockRestore();
235
+ }
236
+ });
237
+ });
238
+
239
+ const makeMetaplexFile = (fileName: string, buffer: Buffer): MetaplexFile =>
240
+ ({
241
+ fileName,
242
+ buffer,
243
+ }) as MetaplexFile;
244
+
245
+ const hashBuffer = (buffer: Buffer): string =>
246
+ createHash("sha256").update(buffer).digest("base64");
@@ -0,0 +1,15 @@
1
+ import { getTurboServiceConfig } from "../TurboStorageDriver";
2
+
3
+ describe("TurboStorageDriver", () => {
4
+ test("uses the latest Turbo SDK service config shape for devnet", () => {
5
+ expect(getTurboServiceConfig("devnet")).toEqual({
6
+ gatewayUrl: "https://api.devnet.solana.com",
7
+ uploadServiceConfig: { url: "https://upload.ardrive.dev" },
8
+ paymentServiceConfig: { url: "https://payment.ardrive.dev" },
9
+ });
10
+ });
11
+
12
+ test("relies on the SDK defaults for mainnet service URLs", () => {
13
+ expect(getTurboServiceConfig("mainnet")).toEqual({});
14
+ });
15
+ });
@@ -0,0 +1,31 @@
1
+ import { buildPublicContentUrl, normalizePublicContentUrl } from "../contentGateway";
2
+
3
+ describe("contentGateway", () => {
4
+ test("builds a mainnet dappstorecontent URL from an upload id", () => {
5
+ expect(buildPublicContentUrl("abc123", "mainnet")).toBe(
6
+ "https://dappstorecontent.com/abc123"
7
+ );
8
+ });
9
+
10
+ test("builds a devnet Turbo raw URL from an upload id", () => {
11
+ expect(buildPublicContentUrl("abc123", "devnet")).toBe(
12
+ "https://turbo.ardrive.dev/raw/abc123"
13
+ );
14
+ });
15
+
16
+ test("normalizes arweave.net and arweave.com URLs to dappstorecontent", () => {
17
+ expect(normalizePublicContentUrl("https://arweave.net/abc123")).toBe(
18
+ "https://dappstorecontent.com/abc123"
19
+ );
20
+ expect(
21
+ normalizePublicContentUrl("https://www.arweave.com/abc123?ext=png")
22
+ ).toBe("https://dappstorecontent.com/abc123?ext=png");
23
+ });
24
+
25
+ test("leaves non-arweave URLs unchanged", () => {
26
+ expect(
27
+ normalizePublicContentUrl("https://turbo.ardrive.dev/raw/abc123")
28
+ ).toBe("https://turbo.ardrive.dev/raw/abc123");
29
+ expect(normalizePublicContentUrl("not-a-url")).toBe("not-a-url");
30
+ });
31
+ });
@@ -0,0 +1,37 @@
1
+ export type StorageNetwork = "devnet" | "mainnet";
2
+
3
+ const MAINNET_PUBLIC_CONTENT_GATEWAY = "https://dappstorecontent.com";
4
+ const DEVNET_PUBLIC_CONTENT_GATEWAY = "https://turbo.ardrive.dev/raw";
5
+
6
+ const LEGACY_MAINNET_CONTENT_HOSTS = new Set([
7
+ "arweave.net",
8
+ "www.arweave.net",
9
+ "arweave.com",
10
+ "www.arweave.com",
11
+ ]);
12
+
13
+ export const buildPublicContentUrl = (
14
+ id: string,
15
+ network: StorageNetwork
16
+ ): string => {
17
+ const gateway =
18
+ network === "devnet"
19
+ ? DEVNET_PUBLIC_CONTENT_GATEWAY
20
+ : MAINNET_PUBLIC_CONTENT_GATEWAY;
21
+
22
+ return `${gateway}/${id}`;
23
+ };
24
+
25
+ export const normalizePublicContentUrl = (url: string): string => {
26
+ try {
27
+ const parsed = new URL(url);
28
+
29
+ if (!LEGACY_MAINNET_CONTENT_HOSTS.has(parsed.host.toLowerCase())) {
30
+ return url;
31
+ }
32
+
33
+ return `${MAINNET_PUBLIC_CONTENT_GATEWAY}${parsed.pathname}${parsed.search}${parsed.hash}`;
34
+ } catch {
35
+ return url;
36
+ }
37
+ };