@solana-mobile/dapp-store-cli 0.16.0 → 1.0.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.
- package/bin/dapp-store.js +3 -1
- package/lib/CliSetup.js +304 -505
- package/lib/CliUtils.js +6 -376
- package/lib/__tests__/CliSetupTest.js +484 -74
- package/lib/cli/__tests__/parseErrors.test.js +25 -0
- package/lib/cli/__tests__/signer.test.js +436 -0
- package/lib/cli/constants.js +23 -0
- package/lib/cli/messages.js +21 -0
- package/lib/cli/parseErrors.js +41 -0
- package/lib/{commands/publish/PublishCliSupport.js → cli/selfUpdate.js} +72 -38
- package/lib/{commands/publish/PublishCliRemove.js → cli/signer.js} +35 -56
- package/lib/index.js +96 -5
- package/lib/package.json +5 -24
- package/lib/portal/__tests__/releaseMetadata.test.js +647 -0
- package/lib/portal/__tests__/translators.test.js +76 -0
- package/lib/portal/__tests__/workflowClient.test.js +457 -0
- package/lib/portal/attestationClient.js +143 -0
- package/lib/portal/files.js +64 -0
- package/lib/portal/http.js +364 -0
- package/lib/portal/records.js +64 -0
- package/lib/portal/releaseMetadata.js +748 -0
- package/lib/portal/translators.js +460 -0
- package/lib/portal/types.js +1 -0
- package/lib/portal/workflowClient.js +704 -0
- package/lib/publication/PublicationProgressReporter.js +1051 -0
- package/lib/publication/__tests__/PublicationProgressReporter.test.js +174 -0
- package/lib/{commands/ValidateCommand.js → publication/__tests__/fundingPreflight.test.js} +90 -66
- package/lib/publication/__tests__/publicationSummary.test.js +26 -0
- package/lib/publication/cliValidation.js +482 -0
- package/lib/publication/fundingPreflight.js +246 -0
- package/lib/publication/publicationSummary.js +99 -0
- package/lib/{commands/utils.js → publication/runPublicationWorkflow.js} +16 -46
- package/package.json +5 -24
- package/src/CliSetup.ts +370 -505
- package/src/CliUtils.ts +9 -233
- package/src/__tests__/CliSetupTest.ts +272 -120
- package/src/cli/__tests__/parseErrors.test.ts +34 -0
- package/src/cli/__tests__/signer.test.ts +359 -0
- package/src/cli/constants.ts +3 -0
- package/src/cli/messages.ts +27 -0
- package/src/cli/parseErrors.ts +62 -0
- package/src/cli/selfUpdate.ts +59 -0
- package/src/cli/signer.ts +38 -0
- package/src/index.ts +31 -4
- package/src/portal/__tests__/releaseMetadata.test.ts +508 -0
- package/src/portal/__tests__/translators.test.ts +82 -0
- package/src/portal/__tests__/workflowClient.test.ts +278 -0
- package/src/portal/attestationClient.ts +19 -0
- package/src/portal/files.ts +73 -0
- package/src/portal/http.ts +170 -0
- package/src/portal/records.ts +38 -0
- package/src/portal/releaseMetadata.ts +489 -0
- package/src/portal/translators.ts +750 -0
- package/src/portal/types.ts +27 -0
- package/src/portal/workflowClient.ts +575 -0
- package/src/publication/PublicationProgressReporter.ts +1026 -0
- package/src/publication/__tests__/PublicationProgressReporter.test.ts +210 -0
- package/src/publication/__tests__/fundingPreflight.test.ts +78 -0
- package/src/publication/__tests__/publicationSummary.test.ts +30 -0
- package/src/publication/cliValidation.ts +264 -0
- package/src/publication/fundingPreflight.ts +123 -0
- package/src/publication/publicationSummary.ts +26 -0
- package/src/publication/runPublicationWorkflow.ts +46 -0
- package/lib/commands/create/CreateCliApp.js +0 -223
- package/lib/commands/create/CreateCliRelease.js +0 -290
- package/lib/commands/create/index.js +0 -40
- package/lib/commands/index.js +0 -3
- package/lib/commands/publish/PublishCliSubmit.js +0 -208
- package/lib/commands/publish/PublishCliUpdate.js +0 -211
- package/lib/commands/publish/index.js +0 -22
- package/lib/commands/scaffolding/ScaffoldInit.js +0 -15
- package/lib/commands/scaffolding/index.js +0 -1
- package/lib/config/EnvVariables.js +0 -59
- package/lib/config/PublishDetails.js +0 -915
- package/lib/config/S3StorageManager.js +0 -93
- package/lib/config/index.js +0 -2
- package/lib/generated/config_obj.json +0 -1
- package/lib/generated/config_schema.json +0 -1
- package/lib/prebuild_schema/publishing_source.yaml +0 -64
- package/lib/prebuild_schema/schemagen.js +0 -25
- package/lib/upload/CachedStorageDriver.js +0 -458
- package/lib/upload/TurboStorageDriver.js +0 -718
- package/lib/upload/__tests__/CachedStorageDriver.test.js +0 -437
- package/lib/upload/__tests__/TurboStorageDriver.test.js +0 -17
- package/lib/upload/__tests__/contentGateway.test.js +0 -17
- package/lib/upload/contentGateway.js +0 -23
- package/lib/upload/index.js +0 -2
- package/src/commands/ValidateCommand.ts +0 -82
- package/src/commands/create/CreateCliApp.ts +0 -93
- package/src/commands/create/CreateCliRelease.ts +0 -149
- package/src/commands/create/index.ts +0 -47
- package/src/commands/index.ts +0 -3
- package/src/commands/publish/PublishCliRemove.ts +0 -66
- package/src/commands/publish/PublishCliSubmit.ts +0 -93
- package/src/commands/publish/PublishCliSupport.ts +0 -66
- package/src/commands/publish/PublishCliUpdate.ts +0 -101
- package/src/commands/publish/index.ts +0 -29
- package/src/commands/scaffolding/ScaffoldInit.ts +0 -20
- package/src/commands/scaffolding/index.ts +0 -1
- package/src/commands/utils.ts +0 -33
- package/src/config/EnvVariables.ts +0 -39
- package/src/config/PublishDetails.ts +0 -456
- package/src/config/S3StorageManager.ts +0 -47
- package/src/config/index.ts +0 -2
- package/src/prebuild_schema/publishing_source.yaml +0 -64
- package/src/prebuild_schema/schemagen.js +0 -31
- package/src/upload/CachedStorageDriver.ts +0 -179
- package/src/upload/TurboStorageDriver.ts +0 -283
- package/src/upload/__tests__/CachedStorageDriver.test.ts +0 -246
- package/src/upload/__tests__/TurboStorageDriver.test.ts +0 -15
- package/src/upload/__tests__/contentGateway.test.ts +0 -31
- package/src/upload/contentGateway.ts +0 -37
- package/src/upload/index.ts +0 -2
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import type { MetaplexFile, StorageDriver } from "@metaplex-foundation/js";
|
|
4
|
-
import { createHash } from "crypto";
|
|
5
|
-
import { normalizePublicContentUrl } from "./contentGateway.js";
|
|
6
|
-
|
|
7
|
-
type URI = string;
|
|
8
|
-
|
|
9
|
-
type Asset = {
|
|
10
|
-
path: string;
|
|
11
|
-
sha256: string;
|
|
12
|
-
uri: URI;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export type AssetManifestSchema = {
|
|
16
|
-
schema_version: string;
|
|
17
|
-
assets: {
|
|
18
|
-
[path: string]: Asset;
|
|
19
|
-
};
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// TODO(jon): We need to manage the removal / replacement of assets in the manifest
|
|
23
|
-
export class CachedStorageDriver implements StorageDriver {
|
|
24
|
-
// NOTE: this schema version is independent of the publishing JSON schema. It should be updated
|
|
25
|
-
// when the AssetManifestSchema or Asset types are updated.
|
|
26
|
-
static readonly SCHEMA_VERSION = "0.1";
|
|
27
|
-
|
|
28
|
-
assetManifest: AssetManifestSchema;
|
|
29
|
-
assetManifestPath: string;
|
|
30
|
-
storageDriver: StorageDriver;
|
|
31
|
-
|
|
32
|
-
constructor(
|
|
33
|
-
storageDriver: StorageDriver,
|
|
34
|
-
{ assetManifestPath }: { assetManifestPath: string }
|
|
35
|
-
) {
|
|
36
|
-
this.assetManifestPath = assetManifestPath;
|
|
37
|
-
this.assetManifest = this.loadAssetManifest(assetManifestPath) ?? {
|
|
38
|
-
schema_version: CachedStorageDriver.SCHEMA_VERSION,
|
|
39
|
-
assets: {},
|
|
40
|
-
};
|
|
41
|
-
this.storageDriver = storageDriver;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async getUploadPrice(bytes: number) {
|
|
45
|
-
return this.storageDriver.getUploadPrice(bytes);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
private resolveAssetManifestPath(filename = this.assetManifestPath): string {
|
|
49
|
-
return path.resolve(process.cwd(), filename);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
private normalizeAsset(
|
|
53
|
-
filename: string,
|
|
54
|
-
asset: unknown
|
|
55
|
-
): Asset | undefined {
|
|
56
|
-
if (!asset || typeof asset !== "object") return;
|
|
57
|
-
|
|
58
|
-
const candidate = asset as Partial<Asset>;
|
|
59
|
-
const pathValue =
|
|
60
|
-
typeof candidate.path === "string" ? candidate.path : filename;
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
typeof candidate.sha256 !== "string" ||
|
|
64
|
-
typeof candidate.uri !== "string"
|
|
65
|
-
) {
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
path: pathValue,
|
|
71
|
-
sha256: candidate.sha256,
|
|
72
|
-
uri: candidate.uri,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private normalizeAssetManifest(
|
|
77
|
-
assetManifest: Partial<AssetManifestSchema> | undefined
|
|
78
|
-
): AssetManifestSchema | undefined {
|
|
79
|
-
if (!assetManifest || typeof assetManifest !== "object") return;
|
|
80
|
-
|
|
81
|
-
const assets: Record<string, Asset> = {};
|
|
82
|
-
const assetEntries =
|
|
83
|
-
assetManifest.assets && typeof assetManifest.assets === "object"
|
|
84
|
-
? Object.entries(assetManifest.assets)
|
|
85
|
-
: [];
|
|
86
|
-
|
|
87
|
-
for (const [filename, asset] of assetEntries) {
|
|
88
|
-
const normalizedAsset = this.normalizeAsset(filename, asset);
|
|
89
|
-
if (normalizedAsset) {
|
|
90
|
-
assets[filename] = normalizedAsset;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
schema_version:
|
|
96
|
-
typeof assetManifest.schema_version === "string"
|
|
97
|
-
? assetManifest.schema_version
|
|
98
|
-
: CachedStorageDriver.SCHEMA_VERSION,
|
|
99
|
-
assets,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
private async writeAssetManifest(): Promise<void> {
|
|
104
|
-
const normalizedAssetManifest = this.normalizeAssetManifest(
|
|
105
|
-
this.assetManifest
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
if (!normalizedAssetManifest) {
|
|
109
|
-
throw new Error("Asset manifest is not serializable");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
this.assetManifest = normalizedAssetManifest;
|
|
113
|
-
await fs.promises.writeFile(
|
|
114
|
-
this.resolveAssetManifestPath(),
|
|
115
|
-
JSON.stringify(this.assetManifest, null, 2),
|
|
116
|
-
"utf-8"
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
loadAssetManifest(filename: string): AssetManifestSchema | undefined {
|
|
121
|
-
try {
|
|
122
|
-
return this.normalizeAssetManifest(
|
|
123
|
-
JSON.parse(fs.readFileSync(this.resolveAssetManifestPath(filename), "utf-8"))
|
|
124
|
-
);
|
|
125
|
-
} catch (error) {
|
|
126
|
-
console.warn(`Failed opening ${filename}; initializing with a blank asset manifest`);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
uploadedAsset(filename: string, { sha256 }: { sha256: string }) {
|
|
132
|
-
if (this.assetManifest.assets[filename]?.sha256 === sha256) {
|
|
133
|
-
return this.assetManifest.assets[filename];
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async upload(file: MetaplexFile): Promise<string> {
|
|
139
|
-
// `inline.json` is the NFT-related metadata. This data is not stable so we'll skip the caching step
|
|
140
|
-
if (file.fileName === "inline.json") {
|
|
141
|
-
return normalizePublicContentUrl(await this.storageDriver.upload(file));
|
|
142
|
-
}
|
|
143
|
-
const hash = createHash("sha256").update(file.buffer).digest("base64");
|
|
144
|
-
|
|
145
|
-
const uploadedAsset = this.uploadedAsset(file.fileName, { sha256: hash });
|
|
146
|
-
if (uploadedAsset) {
|
|
147
|
-
const normalizedUri = normalizePublicContentUrl(uploadedAsset.uri);
|
|
148
|
-
if (normalizedUri !== uploadedAsset.uri) {
|
|
149
|
-
uploadedAsset.uri = normalizedUri;
|
|
150
|
-
try {
|
|
151
|
-
await this.writeAssetManifest();
|
|
152
|
-
} catch (error) {
|
|
153
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
-
console.warn(
|
|
155
|
-
`Failed to rewrite ${this.assetManifestPath}; continuing with normalized URL: ${message}`
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
console.log(
|
|
160
|
-
`Asset ${file.fileName} already uploaded at ${normalizedUri}`
|
|
161
|
-
);
|
|
162
|
-
return normalizedUri;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
console.log(`Uploading ${file.fileName}`);
|
|
166
|
-
const uri = normalizePublicContentUrl(await this.storageDriver.upload(file));
|
|
167
|
-
|
|
168
|
-
this.assetManifest.assets[file.fileName] = {
|
|
169
|
-
path: file.fileName,
|
|
170
|
-
sha256: hash,
|
|
171
|
-
uri,
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
await this.writeAssetManifest();
|
|
175
|
-
console.log(`${file.fileName} uploaded at ${uri}`)
|
|
176
|
-
|
|
177
|
-
return uri;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
@@ -1,283 +0,0 @@
|
|
|
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
|
-
import { buildPublicContentUrl, type StorageNetwork } from "./contentGateway.js";
|
|
7
|
-
|
|
8
|
-
const debug = debugModule("cli:turbo-storage");
|
|
9
|
-
|
|
10
|
-
interface TurboClient {
|
|
11
|
-
getUploadCosts(args: {
|
|
12
|
-
bytes: number[];
|
|
13
|
-
}): Promise<Array<{ winc: string | number | bigint }>>;
|
|
14
|
-
getBalance(): Promise<{ winc: string | number | bigint }>;
|
|
15
|
-
getWincForToken?(args: {
|
|
16
|
-
tokenAmount: number;
|
|
17
|
-
}): Promise<{ winc: string | number | bigint }>;
|
|
18
|
-
topUpWithTokens?(args: { tokenAmount: string | number }): Promise<unknown>;
|
|
19
|
-
uploadFile(args: {
|
|
20
|
-
fileStreamFactory: () => Buffer;
|
|
21
|
-
fileSizeFactory: () => number;
|
|
22
|
-
dataItemOpts?: { tags?: Array<{ name: string; value: string }> };
|
|
23
|
-
}): Promise<{ id: string }>;
|
|
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
|
-
|
|
36
|
-
const CONSTANTS = {
|
|
37
|
-
FREE_UPLOAD_LIMIT: 97_280, // 95 KiB
|
|
38
|
-
UPLOAD_DELAY_MS: 2000,
|
|
39
|
-
MAX_RETRIES: 5,
|
|
40
|
-
SOL_IN_LAMPORTS,
|
|
41
|
-
MIN_TOP_UP_SOL,
|
|
42
|
-
MIN_TOP_UP_LAMPORTS,
|
|
43
|
-
BACKOFF: {
|
|
44
|
-
BASE_MS: 500,
|
|
45
|
-
MAX_MS: 8000,
|
|
46
|
-
},
|
|
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
|
-
},
|
|
53
|
-
},
|
|
54
|
-
} as const;
|
|
55
|
-
|
|
56
|
-
const delay = (ms: number): Promise<void> =>
|
|
57
|
-
new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
|
-
|
|
59
|
-
export class TurboStorageDriver {
|
|
60
|
-
private turbo: TurboClient;
|
|
61
|
-
private bufferPercentage: number;
|
|
62
|
-
private network: StorageNetwork;
|
|
63
|
-
|
|
64
|
-
private uploadQueue: Array<{
|
|
65
|
-
file: MetaplexFile;
|
|
66
|
-
resolve: (url: string) => void;
|
|
67
|
-
reject: (error: Error) => void;
|
|
68
|
-
}> = [];
|
|
69
|
-
private isProcessingQueue = false;
|
|
70
|
-
|
|
71
|
-
constructor(
|
|
72
|
-
keypair: Keypair,
|
|
73
|
-
network: StorageNetwork = "mainnet",
|
|
74
|
-
bufferPercentage = 20
|
|
75
|
-
) {
|
|
76
|
-
this.network = network;
|
|
77
|
-
this.bufferPercentage = bufferPercentage;
|
|
78
|
-
|
|
79
|
-
this.turbo = TurboFactory.authenticated({
|
|
80
|
-
privateKey: bs58.encode(keypair.secretKey),
|
|
81
|
-
token: "solana",
|
|
82
|
-
...getTurboServiceConfig(network),
|
|
83
|
-
}) as TurboClient;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async getUploadPrice(bytes: number): Promise<bigint> {
|
|
87
|
-
if (bytes <= CONSTANTS.FREE_UPLOAD_LIMIT) return BigInt(0);
|
|
88
|
-
|
|
89
|
-
const [cost] = await this.turbo.getUploadCosts({ bytes: [bytes] });
|
|
90
|
-
const base = BigInt(String(cost.winc));
|
|
91
|
-
return (base * BigInt(100 + this.bufferPercentage)) / BigInt(100);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
private async withRetry<T>(
|
|
95
|
-
operation: () => Promise<T>,
|
|
96
|
-
isRetriable: (error: string) => boolean = (msg) =>
|
|
97
|
-
msg.includes("429") || msg.includes("Too Many Requests")
|
|
98
|
-
): Promise<T> {
|
|
99
|
-
for (let retry = 0; retry <= CONSTANTS.MAX_RETRIES; retry++) {
|
|
100
|
-
try {
|
|
101
|
-
return await operation();
|
|
102
|
-
} catch (error) {
|
|
103
|
-
const errorMessage =
|
|
104
|
-
error instanceof Error ? error.message : String(error);
|
|
105
|
-
|
|
106
|
-
if (retry < CONSTANTS.MAX_RETRIES && isRetriable(errorMessage)) {
|
|
107
|
-
const delayMs = Math.min(
|
|
108
|
-
CONSTANTS.BACKOFF.BASE_MS * Math.pow(2, retry),
|
|
109
|
-
CONSTANTS.BACKOFF.MAX_MS
|
|
110
|
-
);
|
|
111
|
-
console.log(
|
|
112
|
-
`Rate limited, retrying after ${delayMs}ms (attempt ${retry + 1}/${
|
|
113
|
-
CONSTANTS.MAX_RETRIES
|
|
114
|
-
})...`
|
|
115
|
-
);
|
|
116
|
-
await delay(delayMs);
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
throw error;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
throw new Error("Max retries exceeded");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
private formatInsufficientFundsError(errorMessage: string): void {
|
|
126
|
-
const match = errorMessage.match(/insufficient lamports (\d+), need (\d+)/);
|
|
127
|
-
if (!match) return;
|
|
128
|
-
|
|
129
|
-
const [current, needed] = [BigInt(match[1]), BigInt(match[2])];
|
|
130
|
-
const [currentSOL, neededSOL] = [
|
|
131
|
-
Number(current) / 1e9,
|
|
132
|
-
Number(needed) / 1e9,
|
|
133
|
-
];
|
|
134
|
-
|
|
135
|
-
console.error(`\nInsufficient SOL balance for top-up:`);
|
|
136
|
-
console.error(` Current: ${currentSOL.toFixed(9)} SOL`);
|
|
137
|
-
console.error(` Required: ${neededSOL.toFixed(9)} SOL`);
|
|
138
|
-
console.error(` Shortfall: ${(neededSOL - currentSOL).toFixed(9)} SOL\n`);
|
|
139
|
-
}
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
try {
|
|
148
|
-
await this.withRetry(async () => {
|
|
149
|
-
const exchangeRate = await this.turbo.getWincForToken?.({
|
|
150
|
-
tokenAmount: CONSTANTS.SOL_IN_LAMPORTS,
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
if (!exchangeRate) {
|
|
154
|
-
throw new Error("Unable to get Winston Credits exchange rate");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const wincPerSol = BigInt(String(exchangeRate.winc));
|
|
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;
|
|
178
|
-
|
|
179
|
-
debug(
|
|
180
|
-
`Buying at least ${wincAmount} Winston Credits (~${solAmount.toFixed(9)} SOL / ${lamportsToUse} lamports)`
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
await this.turbo.topUpWithTokens?.({
|
|
184
|
-
tokenAmount: String(
|
|
185
|
-
lamportToTokenAmount(lamportsToUse.toString())
|
|
186
|
-
),
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
debug(`Top-up initiated for ${wincAmount} Winston Credits`);
|
|
190
|
-
});
|
|
191
|
-
} catch (error) {
|
|
192
|
-
const errorMessage =
|
|
193
|
-
error instanceof Error ? error.message : String(error);
|
|
194
|
-
debug("Top-up failed:", error);
|
|
195
|
-
|
|
196
|
-
if (errorMessage.includes("insufficient lamports")) {
|
|
197
|
-
this.formatInsufficientFundsError(errorMessage);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
throw new Error(
|
|
201
|
-
`Failed to top up ${wincAmount} Winston Credits: ${errorMessage}`
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private async checkBalanceAndTopUp(requiredWinc: bigint): Promise<void> {
|
|
207
|
-
if (requiredWinc === BigInt(0)) return;
|
|
208
|
-
|
|
209
|
-
const current = BigInt(String((await this.turbo.getBalance()).winc));
|
|
210
|
-
|
|
211
|
-
if (current >= requiredWinc) {
|
|
212
|
-
debug(
|
|
213
|
-
`Sufficient balance: ${current} Winston Credits (required: ${requiredWinc})`
|
|
214
|
-
);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const deficit = requiredWinc - current;
|
|
219
|
-
debug(
|
|
220
|
-
`Current: ${current}, Required: ${requiredWinc}, Topping up: ${deficit}`
|
|
221
|
-
);
|
|
222
|
-
await this.topUpCredits(deficit);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
private async processQueue(): Promise<void> {
|
|
226
|
-
if (this.isProcessingQueue || !this.uploadQueue.length) return;
|
|
227
|
-
|
|
228
|
-
this.isProcessingQueue = true;
|
|
229
|
-
|
|
230
|
-
while (this.uploadQueue.length > 0) {
|
|
231
|
-
const item = this.uploadQueue.shift();
|
|
232
|
-
if (!item) continue;
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
debug(
|
|
236
|
-
`Processing upload for ${item.file.fileName} (${item.file.buffer.length} bytes)`
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
const estimated = await this.getUploadPrice(item.file.buffer.length);
|
|
240
|
-
await this.checkBalanceAndTopUp(estimated);
|
|
241
|
-
|
|
242
|
-
const tags = [...(item.file.tags ?? [])];
|
|
243
|
-
if (item.file.contentType) {
|
|
244
|
-
tags.push({ name: "Content-Type", value: item.file.contentType });
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const uploadResult = await this.turbo.uploadFile({
|
|
248
|
-
fileStreamFactory: () => item.file.buffer,
|
|
249
|
-
fileSizeFactory: () => item.file.buffer.byteLength,
|
|
250
|
-
dataItemOpts: { tags },
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
const url = buildPublicContentUrl(uploadResult.id, this.network);
|
|
254
|
-
debug(`Upload complete: ${url}`);
|
|
255
|
-
item.resolve(url);
|
|
256
|
-
|
|
257
|
-
if (this.uploadQueue.length > 0) {
|
|
258
|
-
debug(`Waiting ${CONSTANTS.UPLOAD_DELAY_MS}ms before next upload...`);
|
|
259
|
-
await delay(CONSTANTS.UPLOAD_DELAY_MS);
|
|
260
|
-
}
|
|
261
|
-
} catch (error) {
|
|
262
|
-
item.reject(error instanceof Error ? error : new Error(String(error)));
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
this.isProcessingQueue = false;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async upload(file: MetaplexFile): Promise<string> {
|
|
270
|
-
return new Promise((resolve, reject) => {
|
|
271
|
-
debug(
|
|
272
|
-
`Queueing upload for ${file.fileName} (${file.buffer.length} bytes)`
|
|
273
|
-
);
|
|
274
|
-
this.uploadQueue.push({ file, resolve, reject });
|
|
275
|
-
this.processQueue().catch(reject);
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export const getTurboServiceConfig = (
|
|
281
|
-
network: StorageNetwork
|
|
282
|
-
): TurboServiceConfig =>
|
|
283
|
-
network === "devnet" ? CONSTANTS.SERVICE_URLS.devnet : {};
|
|
@@ -1,246 +0,0 @@
|
|
|
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");
|