@solana-mobile/dapp-store-cli 0.13.0 → 0.14.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.
@@ -1 +1,2 @@
1
1
  export * from "./CachedStorageDriver.js";
2
+ export * from "./TurboStorageDriver.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solana-mobile/dapp-store-cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -44,13 +44,15 @@
44
44
  "ts-node": "^10.9.1"
45
45
  },
46
46
  "dependencies": {
47
+ "@ardrive/turbo-sdk": "^1.31.1",
47
48
  "@aws-sdk/client-s3": "^3.321.1",
48
49
  "@metaplex-foundation/js-plugin-aws": "^0.20.0",
49
- "@solana-mobile/dapp-store-publishing-tools": "0.13.0",
50
+ "@solana-mobile/dapp-store-publishing-tools": "0.14.0",
50
51
  "@solana/web3.js": "1.92.1",
51
52
  "@types/semver": "^7.3.13",
52
53
  "ajv": "^8.11.0",
53
54
  "boxen": "^7.0.1",
55
+ "bs58": "^5.0.0",
54
56
  "chokidar": "^3.5.3",
55
57
  "commander": "^9.4.1",
56
58
  "debug": "^4.3.4",
package/src/CliSetup.ts CHANGED
@@ -52,6 +52,7 @@ function latestReleaseMessage() {
52
52
  const messages = [
53
53
  `- Banner Graphic image of size 1200x600px is now manadatory for publishing updates.`,
54
54
  `- Feature Graphic image of size 1200x1200px is required to be featured in Editor's choice carousel. (optional)`,
55
+ `- Release metadata now publishes publisher.support_email when provided; otherwise we reuse publisher.email for end-user support.`,
55
56
  ].join('\n\n')
56
57
  showMessage(
57
58
  `Publishing Tools Version ${ Constants.CLI_VERSION }`,
@@ -91,6 +92,15 @@ export const createCliCmd = mainCli
91
92
  .command("create")
92
93
  .description("Create a `app`, or `release`")
93
94
 
95
+ createCliCmd.addHelpText(
96
+ "after",
97
+ [
98
+ "",
99
+ "Release metadata notes:",
100
+ " We include publisher.support_email when provided; if omitted we fall back to publisher.email.",
101
+ ].join("\n")
102
+ );
103
+
94
104
  export const createAppCliCmd = createCliCmd
95
105
  .command("app")
96
106
  .description("Create a app")
package/src/CliUtils.ts CHANGED
@@ -2,11 +2,7 @@ import fs from "fs";
2
2
  import type { Connection } from "@solana/web3.js";
3
3
  import { Keypair, PublicKey } from "@solana/web3.js";
4
4
  import debugModule from "debug";
5
- import {
6
- IrysStorageDriver,
7
- keypairIdentity,
8
- Metaplex,
9
- } from "@metaplex-foundation/js";
5
+ import { keypairIdentity, Metaplex, type MetaplexFile, type Amount, lamports } from "@metaplex-foundation/js";
10
6
  import updateNotifier from "update-notifier";
11
7
  import { readFile } from 'fs/promises';
12
8
  const cliPackage = JSON.parse((await readFile(new URL("./package.json", import.meta.url))).toString());
@@ -14,13 +10,14 @@ import boxen from "boxen";
14
10
  import ver from "semver";
15
11
  import path from "path";
16
12
  import { CachedStorageDriver } from "./upload/CachedStorageDriver.js";
13
+ import { TurboStorageDriver } from "./upload/TurboStorageDriver.js";
17
14
  import { EnvVariables } from "./config/index.js";
18
15
  import { S3Client } from "@aws-sdk/client-s3";
19
16
  import { awsStorage } from "@metaplex-foundation/js-plugin-aws";
20
17
  import { S3StorageManager } from "./config/index.js";
21
18
 
22
19
  export class Constants {
23
- static CLI_VERSION = "0.13.0";
20
+ static CLI_VERSION = "0.14.0";
24
21
  static CONFIG_FILE_NAME = "config.yaml";
25
22
  static DEFAULT_RPC_DEVNET = "https://api.devnet.solana.com";
26
23
  static DEFAULT_PRIORITY_FEE = 500000;
@@ -205,16 +202,23 @@ export const getMetaplexInstance = (
205
202
  const bucketPlugin = awsStorage(awsClient, s3Mgr.s3Config.bucketName);
206
203
  metaplex.use(bucketPlugin);
207
204
  } else {
208
- const irysStorageDriver = isDevnet
209
- ? new IrysStorageDriver(metaplex, {
210
- address: "https://turbo.ardrive.dev",
211
- providerUrl: Constants.DEFAULT_RPC_DEVNET,
212
- })
213
- : new IrysStorageDriver(metaplex, {
214
- address: "https://turbo.ardrive.io",
215
- });
216
-
217
- metaplex.storage().setDriver(irysStorageDriver);
205
+ const turboDriver = new TurboStorageDriver(
206
+ keypair,
207
+ isDevnet ? "devnet" : "mainnet",
208
+ Number(process.env.TURBO_BUFFER_PERCENTAGE || 20)
209
+ );
210
+
211
+ const metaplexAdapter = {
212
+ async upload(file: MetaplexFile): Promise<string> {
213
+ return turboDriver.upload(file);
214
+ },
215
+ async getUploadPrice(bytes: number): Promise<Amount> {
216
+ const price = await turboDriver.getUploadPrice(bytes);
217
+ return lamports(price);
218
+ },
219
+ };
220
+
221
+ metaplex.storage().setDriver(metaplexAdapter);
218
222
  }
219
223
 
220
224
  metaplex.storage().setDriver(
@@ -24,11 +24,11 @@ describe("Cli Setup & Execution", () => {
24
24
  getErrHelpWidth(): number { return 250;},
25
25
 
26
26
  writeOut(str: string) {
27
- otherOutput = str;
27
+ otherOutput += str;
28
28
  },
29
29
 
30
30
  writeErr(str: string) {
31
- errorOutput = str;
31
+ errorOutput += str;
32
32
  }
33
33
  });
34
34
  });
@@ -136,7 +136,7 @@ Options:
136
136
 
137
137
  const keyPairArgHelp = "error: required option '-k, --keypair <path-to-keypair-file>' not specified"
138
138
 
139
- const createHelp = `Usage: dapp-store create [options] [command]
139
+ const createHelp = `Usage: dapp-store create [options] [command]
140
140
 
141
141
  Create a \`app\`, or \`release\`
142
142
 
@@ -147,6 +147,9 @@ Commands:
147
147
  app [options] Create a app
148
148
  release [options] Create a release
149
149
  help [command] display help for command
150
+
151
+ Release metadata notes:
152
+ We include publisher.support_email when provided; if omitted we fall back to publisher.email.
150
153
  `;
151
154
 
152
155
  const createAppHelp = `Usage: dapp-store create app [options]
@@ -177,4 +180,4 @@ Options:
177
180
  -h, --help display help for command
178
181
  `;
179
182
 
180
- });
183
+ });
@@ -15,7 +15,7 @@ export * from "./CreateCliRelease.js";
15
15
  // Publisher
16
16
  // Public key attached to a publisher must also verify applications and releases
17
17
  // Most information here can be be edited after the fact
18
- // Only required fields are name, address, publisher website, and contact
18
+ // Only required fields are name, address, publisher website, and contact email
19
19
  // Optional fields are: description, image_url (need dimensions!)
20
20
 
21
21
  // App
@@ -2,6 +2,7 @@ publisher:
2
2
  name: <<[REQUIRED] YOUR_PUBLISHER_NAME>>
3
3
  website: <<[REQUIRED] URL_OF_PUBLISHER_WEBSITE>>
4
4
  email: <<[REQUIRED] EMAIL_ADDRESS_TO_CONTACT_PUBLISHER>>
5
+ support_email: <<[Optional] SUPPORT_EMAIL_ADDRESS_FOR_END_USERS>>
5
6
  app:
6
7
  name: <<[REQUIRED] APP_NAME>>
7
8
  address: ""
@@ -16,6 +16,10 @@ try {
16
16
  ["properties"]
17
17
  ["en-US"].required = ["short_description"];
18
18
 
19
+ schema["properties"]
20
+ ["publisher"]
21
+ .required = ["name", "website", "email"];
22
+
19
23
  // Generator adds some keys/values we don't need & mess up validation
20
24
  delete schema.$schema;
21
25
  delete schema.title;
@@ -0,0 +1,248 @@
1
+ import type { Keypair } from "@solana/web3.js";
2
+ import type { MetaplexFile } from "@metaplex-foundation/js";
3
+ import { TurboFactory, lamportToTokenAmount } from "@ardrive/turbo-sdk";
4
+ import bs58 from "bs58";
5
+ import debugModule from "debug";
6
+
7
+ const debug = debugModule("cli:turbo-storage");
8
+
9
+ interface TurboClient {
10
+ getUploadCosts(args: {
11
+ bytes: number[];
12
+ }): Promise<Array<{ winc: string | number | bigint }>>;
13
+ getBalance(): Promise<{ winc: string | number | bigint }>;
14
+ getWincForToken?(args: {
15
+ tokenAmount: number;
16
+ }): Promise<{ winc: string | number | bigint }>;
17
+ topUpWithTokens?(args: { tokenAmount: string | number }): Promise<unknown>;
18
+ uploadFile(args: {
19
+ fileStreamFactory: () => Buffer;
20
+ fileSizeFactory: () => number;
21
+ dataItemOpts?: { tags?: Array<{ name: string; value: string }> };
22
+ }): Promise<{ id: string }>;
23
+ }
24
+
25
+ const CONSTANTS = {
26
+ FREE_UPLOAD_LIMIT: 97_280, // 95 KiB
27
+ UPLOAD_DELAY_MS: 2000,
28
+ MAX_RETRIES: 5,
29
+ SOL_IN_LAMPORTS: 1_000_000_000,
30
+ BACKOFF: {
31
+ BASE_MS: 500,
32
+ MAX_MS: 8000,
33
+ },
34
+ GATEWAYS: {
35
+ devnet: "https://turbo.ardrive.dev/raw",
36
+ mainnet: "https://arweave.net",
37
+ },
38
+ } as const;
39
+
40
+ const delay = (ms: number): Promise<void> =>
41
+ new Promise((resolve) => setTimeout(resolve, ms));
42
+
43
+ export class TurboStorageDriver {
44
+ private turbo: TurboClient;
45
+ private bufferPercentage: number;
46
+ private network: "devnet" | "mainnet";
47
+
48
+ private uploadQueue: Array<{
49
+ file: MetaplexFile;
50
+ resolve: (url: string) => void;
51
+ reject: (error: Error) => void;
52
+ }> = [];
53
+ private isProcessingQueue = false;
54
+
55
+ constructor(
56
+ keypair: Keypair,
57
+ network: "devnet" | "mainnet" = "mainnet",
58
+ bufferPercentage = 20
59
+ ) {
60
+ this.network = network;
61
+ this.bufferPercentage = bufferPercentage;
62
+
63
+ this.turbo = TurboFactory.authenticated({
64
+ privateKey: bs58.encode(keypair.secretKey),
65
+ token: "solana",
66
+ ...this.getServiceUrls(network === "devnet"),
67
+ }) as TurboClient;
68
+ }
69
+
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
+ async getUploadPrice(bytes: number): Promise<bigint> {
79
+ if (bytes <= CONSTANTS.FREE_UPLOAD_LIMIT) return BigInt(0);
80
+
81
+ const [cost] = await this.turbo.getUploadCosts({ bytes: [bytes] });
82
+ const base = BigInt(String(cost.winc));
83
+ return (base * BigInt(100 + this.bufferPercentage)) / BigInt(100);
84
+ }
85
+
86
+ private async withRetry<T>(
87
+ operation: () => Promise<T>,
88
+ isRetriable: (error: string) => boolean = (msg) =>
89
+ msg.includes("429") || msg.includes("Too Many Requests")
90
+ ): Promise<T> {
91
+ for (let retry = 0; retry <= CONSTANTS.MAX_RETRIES; retry++) {
92
+ try {
93
+ return await operation();
94
+ } catch (error) {
95
+ const errorMessage =
96
+ error instanceof Error ? error.message : String(error);
97
+
98
+ if (retry < CONSTANTS.MAX_RETRIES && isRetriable(errorMessage)) {
99
+ const delayMs = Math.min(
100
+ CONSTANTS.BACKOFF.BASE_MS * Math.pow(2, retry),
101
+ CONSTANTS.BACKOFF.MAX_MS
102
+ );
103
+ console.log(
104
+ `Rate limited, retrying after ${delayMs}ms (attempt ${retry + 1}/${
105
+ CONSTANTS.MAX_RETRIES
106
+ })...`
107
+ );
108
+ await delay(delayMs);
109
+ continue;
110
+ }
111
+ throw error;
112
+ }
113
+ }
114
+ throw new Error("Max retries exceeded");
115
+ }
116
+
117
+ private formatInsufficientFundsError(errorMessage: string): void {
118
+ const match = errorMessage.match(/insufficient lamports (\d+), need (\d+)/);
119
+ if (!match) return;
120
+
121
+ const [current, needed] = [BigInt(match[1]), BigInt(match[2])];
122
+ const [currentSOL, neededSOL] = [
123
+ Number(current) / 1e9,
124
+ Number(needed) / 1e9,
125
+ ];
126
+
127
+ console.error(`\nInsufficient SOL balance for top-up:`);
128
+ console.error(` Current: ${currentSOL.toFixed(9)} SOL`);
129
+ console.error(` Required: ${neededSOL.toFixed(9)} SOL`);
130
+ console.error(` Shortfall: ${(neededSOL - currentSOL).toFixed(9)} SOL\n`);
131
+ }
132
+
133
+ private async topUpCredits(wincAmount: bigint): Promise<void> {
134
+ try {
135
+ await this.withRetry(async () => {
136
+ const exchangeRate = await this.turbo.getWincForToken?.({
137
+ tokenAmount: CONSTANTS.SOL_IN_LAMPORTS,
138
+ });
139
+
140
+ if (!exchangeRate) {
141
+ throw new Error("Unable to get Winston Credits exchange rate");
142
+ }
143
+
144
+ const wincPerSol = BigInt(String(exchangeRate.winc));
145
+ const lamportsNeeded =
146
+ (wincAmount * BigInt(CONSTANTS.SOL_IN_LAMPORTS)) / wincPerSol;
147
+
148
+ debug(
149
+ `Buying ${wincAmount} Winston Credits for ~${
150
+ Number(lamportsNeeded) / 1e9
151
+ } SOL`
152
+ );
153
+
154
+ await this.turbo.topUpWithTokens?.({
155
+ tokenAmount: String(lamportToTokenAmount(lamportsNeeded.toString())),
156
+ });
157
+
158
+ debug(`Top-up initiated for ${wincAmount} Winston Credits`);
159
+ });
160
+ } catch (error) {
161
+ const errorMessage =
162
+ error instanceof Error ? error.message : String(error);
163
+ debug("Top-up failed:", error);
164
+
165
+ if (errorMessage.includes("insufficient lamports")) {
166
+ this.formatInsufficientFundsError(errorMessage);
167
+ }
168
+
169
+ throw new Error(
170
+ `Failed to top up ${wincAmount} Winston Credits: ${errorMessage}`
171
+ );
172
+ }
173
+ }
174
+
175
+ private async checkBalanceAndTopUp(requiredWinc: bigint): Promise<void> {
176
+ if (requiredWinc === BigInt(0)) return;
177
+
178
+ const current = BigInt(String((await this.turbo.getBalance()).winc));
179
+
180
+ if (current >= requiredWinc) {
181
+ debug(
182
+ `Sufficient balance: ${current} Winston Credits (required: ${requiredWinc})`
183
+ );
184
+ return;
185
+ }
186
+
187
+ const deficit = requiredWinc - current;
188
+ debug(
189
+ `Current: ${current}, Required: ${requiredWinc}, Topping up: ${deficit}`
190
+ );
191
+ await this.topUpCredits(deficit);
192
+ }
193
+
194
+ private async processQueue(): Promise<void> {
195
+ if (this.isProcessingQueue || !this.uploadQueue.length) return;
196
+
197
+ this.isProcessingQueue = true;
198
+
199
+ while (this.uploadQueue.length > 0) {
200
+ const item = this.uploadQueue.shift();
201
+ if (!item) continue;
202
+
203
+ try {
204
+ debug(
205
+ `Processing upload for ${item.file.fileName} (${item.file.buffer.length} bytes)`
206
+ );
207
+
208
+ const estimated = await this.getUploadPrice(item.file.buffer.length);
209
+ await this.checkBalanceAndTopUp(estimated);
210
+
211
+ const tags = [...(item.file.tags ?? [])];
212
+ if (item.file.contentType) {
213
+ tags.push({ name: "Content-Type", value: item.file.contentType });
214
+ }
215
+
216
+ const uploadResult = await this.turbo.uploadFile({
217
+ fileStreamFactory: () => item.file.buffer,
218
+ fileSizeFactory: () => item.file.buffer.byteLength,
219
+ dataItemOpts: { tags },
220
+ });
221
+
222
+ const gateway = CONSTANTS.GATEWAYS[this.network];
223
+ const url = `${gateway}/${uploadResult.id}`;
224
+ debug(`Upload complete: ${url}`);
225
+ item.resolve(url);
226
+
227
+ if (this.uploadQueue.length > 0) {
228
+ debug(`Waiting ${CONSTANTS.UPLOAD_DELAY_MS}ms before next upload...`);
229
+ await delay(CONSTANTS.UPLOAD_DELAY_MS);
230
+ }
231
+ } catch (error) {
232
+ item.reject(error instanceof Error ? error : new Error(String(error)));
233
+ }
234
+ }
235
+
236
+ this.isProcessingQueue = false;
237
+ }
238
+
239
+ async upload(file: MetaplexFile): Promise<string> {
240
+ return new Promise((resolve, reject) => {
241
+ debug(
242
+ `Queueing upload for ${file.fileName} (${file.buffer.length} bytes)`
243
+ );
244
+ this.uploadQueue.push({ file, resolve, reject });
245
+ this.processQueue().catch(reject);
246
+ });
247
+ }
248
+ }
@@ -1 +1,2 @@
1
1
  export * from "./CachedStorageDriver.js";
2
+ export * from "./TurboStorageDriver.js";