diff --git a/package-lock.json b/package-lock.json index 7faf350..df4d3c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@oasisprotocol/sapphire-paratime": "^1.3.2", "@oceanprotocol/contracts": "^2.5.0", "@oceanprotocol/ddo-js": "^0.3.0", - "@oceanprotocol/lib": "^8.0.4", + "@oceanprotocol/lib": "^8.0.6", "commander": "^13.1.0", "cross-fetch": "^3.1.5", "crypto-js": "^4.1.1", @@ -3184,9 +3184,9 @@ } }, "node_modules/@oceanprotocol/lib": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@oceanprotocol/lib/-/lib-8.0.4.tgz", - "integrity": "sha512-bg8I11uGbBIGtswK1a3g1MUsQ8H3FWeT/CIjrAOgvVgCDntzEVzouxgCsFxRapPZEGxAwWt5U4xvx4B8Mk9Bjw==", + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@oceanprotocol/lib/-/lib-8.0.6.tgz", + "integrity": "sha512-QjyS7qOlpJHrVaUDDhuNMwTvu9fCIhfgkL6emGZ5MGHnn0Rk8G6q7giOTR8DjuCBK8+YJHMC1CuUnYrgDClaEw==", "license": "Apache-2.0", "dependencies": { "@oasisprotocol/sapphire-paratime": "^1.3.2", diff --git a/package.json b/package.json index c6ee0bb..35203f3 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@oasisprotocol/sapphire-paratime": "^1.3.2", "@oceanprotocol/contracts": "^2.5.0", "@oceanprotocol/ddo-js": "^0.3.0", - "@oceanprotocol/lib": "^8.0.4", + "@oceanprotocol/lib": "^8.0.6", "commander": "^13.1.0", "cross-fetch": "^3.1.5", "crypto-js": "^4.1.1", diff --git a/src/cli.ts b/src/cli.ts index 8c251e4..f59eda1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -74,31 +74,45 @@ export async function createCLI() { chalk.cyan("libp2p node started. Waiting for peer connections...") ); - // Wait for at least one active P2P connection before running commands + // Wait for the TARGET peer (the one in NODE_URL) to be connected, + // not just any bootstrap peer — otherwise signed commands fail with + // "Cannot reach peer ...". + const targetPeerId = isFullMultiaddr + ? nodeUrl.split("/p2p/").pop()! + : nodeUrl; const maxWait = 20_000; const interval = 500; let waited = 0; const libp2p = (ProviderInstance as any).p2pProvider?.libp2pNode; + const isTargetConnected = () => + (libp2p?.getPeers() ?? []).some( + (p: { toString(): string }) => p.toString() === targetPeerId + ); while (waited < maxWait) { - const conns = libp2p?.getConnections()?.length ?? 0; - if (conns > 0) { + if (isTargetConnected()) { + const total = libp2p?.getConnections()?.length ?? 0; console.log( - chalk.green(`Connected to ${conns} peer(s) in ${waited}ms`) + chalk.green( + `Connected to target peer ${targetPeerId.slice(0, 12)}… in ${waited}ms (total peers: ${total})` + ) ); break; } await new Promise((r) => setTimeout(r, interval)); waited += interval; if (waited % 3000 === 0) { + const total = libp2p?.getConnections()?.length ?? 0; console.log( - chalk.yellow(` Still waiting for peers... (${waited / 1000}s)`) + chalk.yellow( + ` Waiting for target peer ${targetPeerId.slice(0, 12)}… (${waited / 1000}s, ${total} other peer(s))` + ) ); } } - if ((libp2p?.getConnections()?.length ?? 0) === 0) { + if (!isTargetConnected()) { console.error( chalk.red( - `No P2P peers connected after ${maxWait / 1000}s. Commands may fail.` + `Target peer ${targetPeerId} not reachable after ${maxWait / 1000}s. Commands will fail.` ) ); } @@ -802,5 +816,69 @@ export async function createCLI() { ]); }); + program + .command("createBucket") + .description("Create a new persistent-storage bucket gated by a single access list (chain inferred from RPC)") + .argument("", "Access list contract address (0x…)") + .action(async (accessListAddress) => { + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.createBucket([null, accessListAddress]); + }); + + program + .command("addFileToBucket") + .description("Upload a local file into a bucket") + .argument("", "Bucket id") + .argument("", "Path to local file") + .argument("[fileName]", "Name under which to store the file (defaults to basename)") + .action(async (bucketId, filePath, fileName) => { + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.addFileToBucket([null, bucketId, filePath, fileName]); + }); + + program + .command("listBuckets") + .description("List buckets owned by an address (defaults to signer)") + .option("-o, --owner
", "Owner address") + .action(async (options) => { + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.listBuckets([null, options.owner]); + }); + + program + .command("listFilesInBucket") + .description("List files in a bucket") + .argument("", "Bucket id") + .action(async (bucketId) => { + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.listFilesInBucket([null, bucketId]); + }); + + program + .command("getFileObject") + .description("Get the file-object descriptor for a file in a bucket") + .argument("", "Bucket id") + .argument("", "File name") + .action(async (bucketId, fileName) => { + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.getFileObject([null, bucketId, fileName]); + }); + + program + .command("deleteFile") + .description("Delete a file from a bucket") + .argument("", "Bucket id") + .argument("", "File name") + .action(async (bucketId, fileName) => { + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.deleteFile([null, bucketId, fileName]); + }); + return program; } diff --git a/src/commands.ts b/src/commands.ts index 3ebcf98..fdeb47f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1795,4 +1795,134 @@ export class Commands { console.error(chalk.red("Error downloading node logs: "), error); } } + + public async createBucket(args: string[]): Promise { + try { + const accessListAddress = args[1]; + if (!accessListAddress) { + console.error(chalk.red("accessListAddress is required")); + return; + } + if (!/^0x[a-fA-F0-9]{40}$/.test(accessListAddress)) { + console.error(chalk.red(`Invalid access list address: ${accessListAddress}`)); + return; + } + + const { chainId } = await this.signer.provider.getNetwork(); + const accessLists = [{ [String(chainId)]: [accessListAddress] }]; + const result = await ProviderInstance.createPersistentStorageBucket( + this.oceanNodeUrl, + this.signer, + { accessLists } + ); + console.log(chalk.green("Bucket created.")); + console.log(util.inspect(result, false, null, true)); + } catch (error) { + console.error(chalk.red("Error creating bucket: "), error); + } + } + + public async addFileToBucket(args: string[]): Promise { + try { + const bucketId = args[1]; + const filePath = args[2]; + const fileName = args[3] || (filePath ? path.basename(filePath) : undefined); + if (!bucketId || !filePath) { + console.error(chalk.red("bucketId and filePath are required")); + return; + } + if (!fs.existsSync(filePath)) { + console.error(chalk.red(`File not found: ${filePath}`)); + return; + } + + const stream = fs.createReadStream(filePath); + const result = await ProviderInstance.uploadPersistentStorageFile( + this.oceanNodeUrl, + this.signer, + bucketId, + fileName, + stream as unknown as AsyncIterable + ); + console.log(chalk.green(`File '${fileName}' uploaded to bucket ${bucketId}.`)); + console.log(util.inspect(result, false, null, true)); + } catch (error) { + console.error(chalk.red("Error uploading file: "), error); + } + } + + public async listBuckets(args: string[]): Promise { + try { + const owner = args[1] || (await this.signer.getAddress()); + const buckets = await ProviderInstance.getPersistentStorageBuckets( + this.oceanNodeUrl, + this.signer, + owner + ); + console.log(chalk.cyan(`Buckets owned by ${owner}:`)); + console.log(util.inspect(buckets, false, null, true)); + } catch (error) { + console.error(chalk.red("Error listing buckets: "), error); + } + } + + public async listFilesInBucket(args: string[]): Promise { + try { + const bucketId = args[1]; + if (!bucketId) { + console.error(chalk.red("bucketId is required")); + return; + } + const files = await ProviderInstance.listPersistentStorageFiles( + this.oceanNodeUrl, + this.signer, + bucketId + ); + console.log(chalk.cyan(`Files in bucket ${bucketId}:`)); + console.log(util.inspect(files, false, null, true)); + } catch (error) { + console.error(chalk.red("Error listing files: "), error); + } + } + + public async getFileObject(args: string[]): Promise { + try { + const bucketId = args[1]; + const fileName = args[2]; + if (!bucketId || !fileName) { + console.error(chalk.red("bucketId and fileName are required")); + return; + } + const fileObject = await ProviderInstance.getPersistentStorageFileObject( + this.oceanNodeUrl, + this.signer, + bucketId, + fileName + ); + console.log(JSON.stringify(fileObject, null, 2)); + } catch (error) { + console.error(chalk.red("Error getting file object: "), error); + } + } + + public async deleteFile(args: string[]): Promise { + try { + const bucketId = args[1]; + const fileName = args[2]; + if (!bucketId || !fileName) { + console.error(chalk.red("bucketId and fileName are required")); + return; + } + const result = await ProviderInstance.deletePersistentStorageFile( + this.oceanNodeUrl, + this.signer, + bucketId, + fileName + ); + console.log(chalk.green(`File '${fileName}' deleted from bucket ${bucketId}.`)); + console.log(util.inspect(result, false, null, true)); + } catch (error) { + console.error(chalk.red("Error deleting file: "), error); + } + } } diff --git a/test/storage.test.ts b/test/storage.test.ts new file mode 100644 index 0000000..f97a56d --- /dev/null +++ b/test/storage.test.ts @@ -0,0 +1,125 @@ +import { expect } from "chai"; +import fs from "fs"; +import path from "path"; +import { homedir } from "os"; +import { JsonRpcProvider, ethers } from "ethers"; +import { runCommand, runCommandAs, projectRoot } from "./util.js"; + +describe("Ocean CLI Persistent Storage", function () { + this.timeout(200000); + + const ALICE_KEY = + "0xc594c6e5def4bab63ac29eed19a134c130388f74f019bc74b8f4389df2837a58"; + const BOB_KEY = + "0xef4b441145c1d0f3b4bc6d61d29f5c6e502359481152f869247c7a4244d45209"; + + let alice: ethers.Wallet; + let bob: ethers.Wallet; + let accessListAddress: string; + let bucketId: string; + const fileName = "alice-bob.txt"; + const tempFilePath = path.join(projectRoot, fileName); + + before(async function () { + process.env.PRIVATE_KEY = ALICE_KEY; + process.env.RPC = "http://127.0.0.1:8545"; + process.env.NODE_URL = "http://127.0.0.1:8001"; + process.env.ADDRESS_FILE = `${homedir()}/.ocean/ocean-contracts/artifacts/address.json`; + + const provider = new JsonRpcProvider(process.env.RPC); + alice = new ethers.Wallet(ALICE_KEY, provider); + bob = new ethers.Wallet(BOB_KEY, provider); + + fs.writeFileSync(tempFilePath, "hello from alice"); + }); + + after(function () { + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + }); + + it("should create an access list and add Alice and Bob", async function () { + const createOutput = await runCommand( + `npm run cli createAccessList StorageTestACL STACL false` + ); + expect(createOutput).to.include("Access list created successfully"); + const addressMatch = createOutput.match( + /Contract address: (0x[a-fA-F0-9]{40})/ + ); + if (!addressMatch) { + throw new Error("Could not extract access list address"); + } + accessListAddress = addressMatch[1]; + + const addOutput = await runCommand( + `npm run cli addToAccessList ${accessListAddress} ${alice.address},${bob.address}` + ); + expect(addOutput).to.include("Successfully added"); + }); + + it("should create a bucket gated by the access list", async function () { + const output = await runCommand( + `npm run cli createBucket ${accessListAddress}` + ); + expect(output).to.include("Bucket created."); + const idMatch = output.match( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ); + if (!idMatch) { + throw new Error("Could not extract bucketId from output"); + } + bucketId = idMatch[0]; + expect(output.toLowerCase()).to.include(alice.address.toLowerCase()); + }); + + it("Alice should upload a file to the bucket", async function () { + const output = await runCommand( + `npm run cli addFileToBucket ${bucketId} ${tempFilePath}` + ); + expect(output).to.include(fileName); + expect(output).to.include("size:"); + }); + + it("Alice should list files and see the uploaded file", async function () { + const output = await runCommand( + `npm run cli listFilesInBucket ${bucketId}` + ); + expect(output).to.include(fileName); + }); + + it("Alice should list her buckets and see the new one", async function () { + const output = await runCommand(`npm run cli listBuckets`); + expect(output).to.include(bucketId); + expect(output.toLowerCase()).to.include(alice.address.toLowerCase()); + }); + + it("Alice should get the file-object descriptor", async function () { + const output = await runCommand( + `npm run cli getFileObject ${bucketId} ${fileName}` + ); + expect(output).to.include('"type": "nodePersistentStorage"'); + expect(output).to.include(bucketId); + expect(output).to.include(fileName); + }); + + it("Bob (on the ACL) should list files in the bucket", async function () { + const output = await runCommandAs( + BOB_KEY, + `npm run cli listFilesInBucket ${bucketId}` + ); + expect(output).to.include(fileName); + }); + + it("Alice should delete the file from the bucket", async function () { + const output = await runCommand( + `npm run cli deleteFile ${bucketId} ${fileName}` + ); + expect(output).to.match(/deleted|success/i); + + const listOutput = await runCommand( + `npm run cli listFilesInBucket ${bucketId}` + ); + expect(listOutput).to.not.include(fileName); + }); +}); diff --git a/test/util.ts b/test/util.ts index a00cfcc..d830ecb 100644 --- a/test/util.ts +++ b/test/util.ts @@ -23,4 +23,22 @@ export const runCommand = async (command: string): Promise => { console.error(`[ERROR]:\n${error.stderr || error.message}`); throw error; } +}; + +export const runCommandAs = async ( + privateKey: string, + command: string +): Promise => { + console.log(`\n[CMD as ${privateKey.slice(0, 6)}…]: ${command}`); + try { + const { stdout } = await execPromise(command, { + cwd: projectRoot, + env: { ...process.env, PRIVATE_KEY: privateKey }, + }); + console.log(`[OUTPUT]:\n${stdout}`); + return stdout; + } catch (error: any) { + console.error(`[ERROR]:\n${error.stderr || error.message}`); + throw error; + } }; \ No newline at end of file