diff --git a/package-lock.json b/package-lock.json index 7094775ef..176aac435 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,20 +134,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "e2e/opensea-api-mock/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "e2e/opensea-api-mock/node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2305,6 +2291,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@bosonprotocol/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@bosonprotocol/common": { "resolved": "packages/common", "link": true @@ -22167,7 +22157,6 @@ "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || >=14" @@ -54510,6 +54499,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/cli": { + "name": "@bosonprotocol/cli", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@bosonprotocol/common": "^1.32.2", + "@bosonprotocol/core-sdk": "^1.47.0-alpha.4", + "@bosonprotocol/ethers-sdk": "^1.17.2", + "@bosonprotocol/ipfs-storage": "^1.13.0", + "commander": "^9.4.0", + "dotenv": "^16.4.5", + "ethers": "^5.5.0" + }, + "bin": { + "boson": "bin/run.js" + }, + "devDependencies": { + "eslint": "^8.10.0", + "rimraf": "^3.0.2", + "typescript": "^5.1.6" + } + }, "packages/common": { "name": "@bosonprotocol/common", "version": "1.32.2", diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js new file mode 100755 index 000000000..e3a16475a --- /dev/null +++ b/packages/cli/bin/run.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +"use strict"; +require("../dist/cjs/index.js"); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..c59badad2 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,52 @@ +{ + "name": "@bosonprotocol/cli", + "version": "0.1.0", + "description": "CLI for interacting with the Boson Protocol core-sdk", + "main": "./dist/cjs/index.js", + "types": "./dist/cjs/index.d.ts", + "bin": { + "boson": "./bin/run.js" + }, + "scripts": { + "dev": "tsc --build tsconfig.json --watch --preserveWatchOutput", + "lint": "eslint --ignore-path ../../.gitignore --ext .js,.ts .", + "lint:fix": "npm run lint -- --fix", + "build": "rimraf dist && tsc --build tsconfig.json", + "clean": "rimraf dist coverage .turbo node_modules" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bosonprotocol/core-components.git" + }, + "author": "Boson Protocol", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/bosonprotocol/core-components/issues" + }, + "homepage": "https://github.com/bosonprotocol/core-components/tree/main/packages/cli#readme", + "files": [ + "dist/*", + "src/*", + "bin/*" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@bosonprotocol/common": "^1.32.2", + "@bosonprotocol/core-sdk": "^1.47.0-alpha.4", + "@bosonprotocol/ethers-sdk": "^1.17.2", + "@bosonprotocol/ipfs-storage": "^1.13.0", + "commander": "^9.4.0", + "dotenv": "^16.4.5", + "ethers": "^5.5.0" + }, + "devDependencies": { + "eslint": "^8.10.0", + "rimraf": "^3.0.2", + "typescript": "^5.1.6" + }, + "overrides": { + "typescript": "^5.1.6" + } +} diff --git a/packages/cli/src/commands/complete-exchange.ts b/packages/cli/src/commands/complete-exchange.ts new file mode 100644 index 000000000..127702b5c --- /dev/null +++ b/packages/cli/src/commands/complete-exchange.ts @@ -0,0 +1,177 @@ +import fs from "fs"; +import { resolve } from "path"; +import { EnvironmentType, ConfigId, MetaTxConfig } from "@bosonprotocol/common"; +import { CoreSDK, getEnvConfigById, subgraph } from "@bosonprotocol/core-sdk"; +import { EthersAdapter } from "@bosonprotocol/ethers-sdk"; +import { Wallet, providers } from "ethers"; +import { Command } from "commander"; + +// TODO: read real value from the diamond (IBosonConfigHandler::getMaxExchangesPerBatch()) +const MAX_EXCHANGES_PER_BATCH = 140; +const MAX_EXCHANGES_PER_SELLER = 1000; + +export const completeExchangeCommand = new Command("complete-exchange") + .description( + "Complete one or several exchanges that have passed their dispute period." + ) + .argument( + "", + "Private key of the account issuing the transaction. Can also be set via PRIVATE_KEY env var." + ) + .option( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var.", + "testing" + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .option( + "-m, --metaTx ", + "Path to a JSON file with the meta-tx parameters (e.g. Biconomy config)" + ) + .option( + "--exchanges ", + "Comma-separated list of exchange IDs to complete" + ) + .option( + "--sellerId ", + "Id of the seller to complete all completable exchanges for" + ) + .option("--dryRun", "Do not send the transaction, only list exchanges") + .action(async (privateKey: string, opts) => { + const envName: string = opts.env || process.env.ENV_NAME || "testing"; + const configId: string = + opts.configId || process.env.ENV_CONFIG_ID || "testing-80002-0"; + const resolvedPrivateKey: string = + privateKey || process.env.PRIVATE_KEY || ""; + + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + const wallet = new Wallet(resolvedPrivateKey); + + let metaTx: Partial | undefined = undefined; + if (opts.metaTx) { + const rawMetaTx = fs.readFileSync(resolve(process.cwd(), opts.metaTx)); + metaTx = JSON.parse(rawMetaTx.toString()); + } + + const coreSDK = CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl), + wallet + ), + envName: envName as EnvironmentType, + metaTx, + configId: configId as ConfigId + }); + + if (opts.metaTx && !coreSDK.isMetaTxConfigSet) { + throw new Error( + `Invalid metaTx configuration:\n${JSON.stringify(metaTx)}` + ); + } + + const exchangeIds: string[] = opts.exchanges + ? (opts.exchanges as string).split(",") + : []; + + const exchanges = exchangeIds.length + ? await coreSDK.getExchanges({ + exchangesFilter: { + state_in: [subgraph.ExchangeState.REDEEMED], + id_in: exchangeIds + } + }) + : []; + + let limitReached = false; + if (opts.sellerId) { + const sellerExchanges = await coreSDK.getExchanges({ + exchangesFirst: MAX_EXCHANGES_PER_SELLER, + exchangesFilter: { + seller: opts.sellerId as string, + state_in: [subgraph.ExchangeState.REDEEMED], + ...(exchangeIds.length ? { id_not_in: exchangeIds } : {}) + } + }); + limitReached = sellerExchanges.length === MAX_EXCHANGES_PER_SELLER; + exchanges.push(...sellerExchanges); + } + + const now = Math.floor(Date.now() / 1000); + const exchangesToComplete = exchanges.filter( + (e) => + parseInt(e.redeemedDate as string) + + parseInt(e.offer.disputePeriodDuration) < + now + ); + + console.log( + `${exchangesToComplete.length} exchanges to complete:${exchangesToComplete.map( + (e) => + `\n${e.id}:${e.state}:${new Date( + 1000 * parseInt(e.redeemedDate as string) + ).toDateString()}:${new Date( + 1000 * + (parseInt(e.redeemedDate as string) + + parseInt(e.offer.disputePeriodDuration)) + ).toDateString()}` + )}` + ); + + if (limitReached) { + console.warn( + `WARNING: Reached limit of ${MAX_EXCHANGES_PER_SELLER} exchanges for seller ${opts.sellerId}.\n` + + `The command needs to be called again to complete all exchanges for this seller` + ); + } + + let nbCompleted = 0; + while (nbCompleted < exchangesToComplete.length) { + const batchSize = Math.min( + MAX_EXCHANGES_PER_BATCH, + exchangesToComplete.length - nbCompleted + ); + const exchangesToCompleteIds = exchangesToComplete + .slice(nbCompleted, nbCompleted + batchSize) + .map((e) => e.id); + + console.log("Nb exchanges in batch:", exchangesToCompleteIds.length); + nbCompleted += exchangesToCompleteIds.length; + + if (opts.dryRun) { + continue; + } + + let tx; + if (opts.metaTx && coreSDK.isMetaTxConfigSet) { + console.log(`Preparing meta-transaction...`); + const nonce = Date.now(); + const { functionName, functionSignature, r, s, v } = + await coreSDK.signMetaTxCompleteExchangeBatch({ + exchangeIds: exchangesToCompleteIds, + nonce + }); + tx = await coreSDK.relayMetaTransaction({ + functionName, + functionSignature, + sigR: r, + sigS: s, + sigV: v, + nonce + }); + console.log(`Meta-transaction ${tx.hash} sent`); + } else { + console.log(`Preparing transaction...`); + tx = await coreSDK.completeExchangeBatch(exchangesToCompleteIds); + console.log(`Transaction ${tx.hash} sent`); + } + await tx.wait(); + console.log(`Transaction ${tx.hash} completed`); + } + }); diff --git a/packages/cli/src/commands/create-offer.ts b/packages/cli/src/commands/create-offer.ts new file mode 100644 index 000000000..3d2db21f2 --- /dev/null +++ b/packages/cli/src/commands/create-offer.ts @@ -0,0 +1,63 @@ +import fs from "fs"; +import { EnvironmentType, ConfigId } from "@bosonprotocol/common"; +import { getEnvConfigById, CoreSDK } from "@bosonprotocol/core-sdk"; +import { EthersAdapter } from "@bosonprotocol/ethers-sdk"; +import { providers, Wallet } from "ethers"; +import { Command } from "commander"; + +export const createOfferCommand = new Command("create-offer") + .description("Create an Offer on the Boson Protocol.") + .argument( + "", + "Private key of the Seller account (assistant role). Can also be set via SELLER_PRIVATE_KEY env var." + ) + .argument( + "", + "Path to a JSON file with the Offer parameters. Can also be set via OFFER_DATA env var." + ) + .option( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var.", + "testing" + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .action(async (privateKey: string, offerDataJsonFile: string, opts) => { + const envName: string = opts.env || process.env.ENV_NAME || "testing"; + const configId: string = + opts.configId || process.env.ENV_CONFIG_ID || "testing-80002-0"; + const resolvedPrivateKey: string = + privateKey || process.env.SELLER_PRIVATE_KEY || ""; + const resolvedOfferDataFile: string = + offerDataJsonFile || process.env.OFFER_DATA || ""; + + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + const chainId = defaultConfig.chainId; + const rawData = fs.readFileSync(resolvedOfferDataFile); + const offerDataJson = JSON.parse(rawData.toString()); + + console.log(`Create Offer with Data ${JSON.stringify(offerDataJson)}`); + + const sellerWallet = new Wallet(resolvedPrivateKey); + const coreSDK = CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl), + sellerWallet + ), + envName: envName as EnvironmentType, + configId: configId as ConfigId + }); + + console.log(`Creating offer on env ${envName} on chain ${chainId}...`); + const txResponse = await coreSDK.createOffer(offerDataJson); + console.log(`Tx hash: ${txResponse.hash}`); + const receipt = await txResponse.wait(); + const offerId = coreSDK.getCreatedOfferIdFromLogs(receipt.logs); + console.log(`Offer with id ${offerId} created.`); + }); diff --git a/packages/cli/src/commands/create-seller.ts b/packages/cli/src/commands/create-seller.ts new file mode 100644 index 000000000..86ee4abba --- /dev/null +++ b/packages/cli/src/commands/create-seller.ts @@ -0,0 +1,73 @@ +import fs from "fs"; +import { EnvironmentType, ConfigId } from "@bosonprotocol/common"; +import { getEnvConfigById, CoreSDK } from "@bosonprotocol/core-sdk"; +import { EthersAdapter } from "@bosonprotocol/ethers-sdk"; +import { providers, Wallet } from "ethers"; +import { Command } from "commander"; + +export const createSellerCommand = new Command("create-seller") + .description("Create a Seller on the Boson Protocol.") + .argument( + "", + "Private key of the Seller account (assistant role). Can also be set via SELLER_PRIVATE_KEY env var." + ) + .option("-d, --data ", "JSON file with the Seller parameters") + .option( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var.", + "testing" + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .action(async (privateKey: string, opts) => { + const envName: string = opts.env || process.env.ENV_NAME || "testing"; + const configId: string = + opts.configId || process.env.ENV_CONFIG_ID || "testing-80002-0"; + const resolvedPrivateKey: string = + privateKey || process.env.SELLER_PRIVATE_KEY || ""; + + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + const chainId = defaultConfig.chainId; + const sellerWallet = new Wallet(resolvedPrivateKey); + + let sellerDataJson; + if (opts.data) { + const rawData = fs.readFileSync(opts.data); + sellerDataJson = JSON.parse(rawData.toString()); + } else { + sellerDataJson = { + assistant: sellerWallet.address, + admin: sellerWallet.address, + treasury: sellerWallet.address, + contractUri: "", + royaltyPercentage: "0", + authTokenId: "0", + authTokenType: "0", + metadataUri: "" + }; + } + + console.log(`Create Seller with Data ${JSON.stringify(sellerDataJson)}`); + + const coreSDK = CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl), + sellerWallet + ), + envName: envName as EnvironmentType, + configId: configId as ConfigId + }); + + console.log(`Creating seller on env ${envName} on chain ${chainId}...`); + const txResponse = await coreSDK.createSeller(sellerDataJson); + console.log(`Tx hash: ${txResponse.hash}`); + const receipt = await txResponse.wait(); + const sellerId = coreSDK.getCreatedSellerIdFromLogs(receipt.logs); + console.log(`Seller with id ${sellerId} created.`); + }); diff --git a/packages/cli/src/commands/deposit-funds.ts b/packages/cli/src/commands/deposit-funds.ts new file mode 100644 index 000000000..7cb9f1228 --- /dev/null +++ b/packages/cli/src/commands/deposit-funds.ts @@ -0,0 +1,98 @@ +import { EnvironmentType, ConfigId } from "@bosonprotocol/common"; +import { isAddress } from "@ethersproject/address"; +import { BigNumber } from "@ethersproject/bignumber"; +import { Wallet, providers } from "ethers"; +import { InvalidArgumentError, Command } from "commander"; +import { CoreSDK, getEnvConfigById } from "@bosonprotocol/core-sdk"; +import { EthersAdapter } from "@bosonprotocol/ethers-sdk"; + +function validAddress(value: string): string { + if (!isAddress(value)) { + throw new InvalidArgumentError("Not a valid Ethereum address."); + } + return value; +} + +function validBigNumber(value: string): BigNumber { + try { + return BigNumber.from(value); + } catch { + throw new InvalidArgumentError("Cannot be cast as a BigNumber."); + } +} + +function validPrivateKey(value: string): string { + if (!/^(0x)?[a-fA-F0-9]{64}$/.test(value)) { + throw new InvalidArgumentError("Cannot be cast as a private key."); + } + return value; +} + +export const depositFundsCommand = new Command("deposit-funds") + .description("Deposit funds into the Boson Protocol for a Seller.") + .requiredOption( + "-k, --key ", + "Private key of the account issuing the transaction. Overrides PRIVATE_KEY env var.", + (v) => validPrivateKey(v) + ) + .requiredOption( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var." + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .requiredOption("-s, --sellerId ", "Seller ID.", (v) => + validBigNumber(v) + ) + .requiredOption("-v, --value ", "Value to deposit.", (v) => + validBigNumber(v) + ) + .option( + "-t, --token ", + "Token Address (omit for native token).", + (v) => validAddress(v) + ) + .action(async (opts) => { + const privateKey: string = opts.key || process.env.PRIVATE_KEY || ""; + const envName: string = opts.env || process.env.ENV_NAME || "testing"; + const configId: string = + opts.configId || process.env.ENV_CONFIG_ID || "testing-80002-0"; + const { sellerId, value, token } = opts; + + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + const chainId = defaultConfig.chainId; + const wallet = new Wallet(privateKey); + const coreSDK = CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl), + wallet + ), + envName: envName as EnvironmentType, + configId: configId as ConfigId + }); + + console.log(`*********************`); + console.log(` Deposit Funds`); + console.log(`*********************`); + console.log(`Operator: ${wallet.address}`); + console.log(`Environment: ${envName}`); + console.log(`ChainId: ${chainId}`); + console.log(`SellerId: ${sellerId.toString()}`); + console.log(`Value: ${value.toString()}`); + console.log( + `Token: ${token ?? defaultConfig.nativeCoin?.name + " (Native)"}` + ); + console.log(`*********************`); + + const tx = await coreSDK.depositFunds(sellerId, value, token); + console.log("Tx pending", tx.hash, "..."); + await tx.wait(); + console.log("Tx executed", tx.hash); + console.log(`*********************`); + }); diff --git a/packages/cli/src/commands/explore-offer.ts b/packages/cli/src/commands/explore-offer.ts new file mode 100644 index 000000000..02193c507 --- /dev/null +++ b/packages/cli/src/commands/explore-offer.ts @@ -0,0 +1,84 @@ +import { Command } from "commander"; +import { buildReadOnlyCoreSDK, getEnvName, getConfigId } from "../utils"; +import { getEnvConfigById } from "@bosonprotocol/core-sdk"; +import { EnvironmentType, ConfigId } from "@bosonprotocol/common"; +import { BaseIpfsStorage } from "@bosonprotocol/ipfs-storage"; +import { buildInfuraHeaders } from "../utils/infura"; + +export const exploreOfferCommand = new Command("explore-offer") + .description( + "Explore an on-chain Offer: retrieve offer data, token info, IPFS metadata and reserved range." + ) + .argument( + "", + "Id of the Offer to explore. Can also be set via OFFER_ID env var." + ) + .option( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var.", + "testing" + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .option( + "--infura ", + "ProjectId and Secret required to address Infura IPFS gateway (format: /). " + + "Overrides INFURA_CREDENTIALS env var." + ) + .action(async (offerId: string, opts) => { + const envName = getEnvName(opts); + const configId = getConfigId(opts); + const resolvedOfferId = offerId || process.env.OFFER_ID || ""; + const infura = opts.infura || process.env.INFURA_CREDENTIALS; + + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + + console.log(`Explore Offer with Id ${resolvedOfferId}`); + + const coreSDK = buildReadOnlyCoreSDK(envName, configId); + + const offers = await coreSDK.getOffers({ + offersFilter: { id: resolvedOfferId } + }); + + if (!offers || offers.length === 0) { + console.log(`No offer found with id ${resolvedOfferId}`); + return; + } + + const offer = offers[0]; + console.log("Offer data:", JSON.stringify(offer, undefined, 2)); + + const metadataHash = offer.metadataHash; + if (metadataHash) { + console.log("Fetching offer metadata..."); + const ipfsStorage = new BaseIpfsStorage({ + url: infura + ? defaultConfig.ipfsMetadataUrl + : defaultConfig.theGraphIpfsUrl, + headers: infura ? buildInfuraHeaders(infura) : undefined + }); + try { + const metadata = await ipfsStorage.get(metadataHash); + console.log("Metadata:", JSON.stringify(metadata, undefined, 2)); + } catch (e) { + console.warn("Could not fetch metadata:", e); + } + } + + const reservedRange = await coreSDK.getRangeByOfferId(resolvedOfferId); + console.log({ + reservedRange: { + start: reservedRange.start.toString(), + length: reservedRange.length.toString(), + minted: reservedRange.minted.toString(), + lastBurnedTokenId: reservedRange.lastBurnedTokenId.toString() + } + }); + }); diff --git a/packages/cli/src/commands/get-seller.ts b/packages/cli/src/commands/get-seller.ts new file mode 100644 index 000000000..c80c829cf --- /dev/null +++ b/packages/cli/src/commands/get-seller.ts @@ -0,0 +1,28 @@ +import { Command } from "commander"; +import { buildReadOnlyCoreSDK, getEnvName, getConfigId } from "../utils"; + +export const getSellerCommand = new Command("get-seller") + .description("Get a Seller by address from the Boson Protocol.") + .argument( + "
", + "Address of the Seller to look up. Can also be set via SELLER_ADDRESS env var." + ) + .option( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var.", + "testing" + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .action(async (address: string, opts) => { + const envName = getEnvName(opts); + const configId = getConfigId(opts); + const resolvedAddress = address || process.env.SELLER_ADDRESS || ""; + + const coreSDK = buildReadOnlyCoreSDK(envName, configId); + const seller = await coreSDK.getSellersByAddress(resolvedAddress); + console.log(`Seller: ${JSON.stringify(seller)}`); + }); diff --git a/packages/cli/src/commands/search-products.ts b/packages/cli/src/commands/search-products.ts new file mode 100644 index 000000000..b59744d66 --- /dev/null +++ b/packages/cli/src/commands/search-products.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; +import { buildReadOnlyCoreSDK, getEnvName, getConfigId } from "../utils"; + +export const searchProductsCommand = new Command("search-products") + .description( + "Search for products by keywords in the Boson Protocol metadata." + ) + .argument( + "", + "Comma-separated list of keywords to search for in product metadata. " + + "Can also be set via SEARCH_KEYWORDS env var." + ) + .option( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var.", + "testing" + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .action(async (keywordsArg: string, opts) => { + const envName = getEnvName(opts); + const configId = getConfigId(opts); + const resolvedKeywordsArg = + keywordsArg || process.env.SEARCH_KEYWORDS || ""; + + const keywords = resolvedKeywordsArg + .split(",") + .map((keyword) => keyword.trim()) + .filter((keyword) => keyword.length > 0); + + if (keywords.length === 0) { + console.error("No keywords provided."); + process.exit(1); + } + + const coreSDK = buildReadOnlyCoreSDK(envName, configId); + const result = await coreSDK.searchProducts(keywords); + console.log(`Found ${result.length} products:`); + result.forEach((product, index) => { + console.log(`${index + 1}. ${product.title}`); + }); + }); diff --git a/packages/cli/src/commands/update-seller.ts b/packages/cli/src/commands/update-seller.ts new file mode 100644 index 000000000..0e1296288 --- /dev/null +++ b/packages/cli/src/commands/update-seller.ts @@ -0,0 +1,227 @@ +import fs from "fs"; +import { AddressZero } from "@ethersproject/constants"; +import { + EnvironmentType, + ConfigId, + UpdateSellerArgs, + abis, + TransactionResponse +} from "@bosonprotocol/common"; +import { getEnvConfigById, CoreSDK } from "@bosonprotocol/core-sdk"; +import { EthersAdapter } from "@bosonprotocol/ethers-sdk"; +import { providers, Contract, Wallet } from "ethers"; +import { Command } from "commander"; +import { extractSellerData } from "../utils/account"; + +export const updateSellerCommand = new Command("update-seller") + .description("Update a Seller on the Boson Protocol.") + .argument( + "", + "Private key of the Seller account (admin role). Can also be set via SELLER_PRIVATE_KEY env var." + ) + .option("-d, --data ", "JSON file with the Seller parameters") + .option( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var.", + "testing" + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .option("--id ", "Seller ID") + .option("--admin ", "New admin address") + .option("--treasury ", "New treasury address") + .option("--assistant ", "New assistant address") + .option("--authTokenId ", "New Auth Token Id") + .option("--authTokenType ", "New Auth Token Type") + .option("--metadataUri ", "New metadata URI") + .option( + "--privateKeys ", + "Comma-separated list of private keys used for opting in the update" + ) + .action(async (privateKey: string, opts) => { + const envName: string = opts.env || process.env.ENV_NAME || "testing"; + const configId: string = + opts.configId || process.env.ENV_CONFIG_ID || "testing-80002-0"; + const resolvedPrivateKey: string = + privateKey || process.env.SELLER_PRIVATE_KEY || ""; + + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + const chainId = defaultConfig.chainId; + const web3Provider = new providers.JsonRpcProvider( + defaultConfig.jsonRpcUrl + ); + const sellerWallet = new Wallet(resolvedPrivateKey); + + let sellerDataJson = {} as UpdateSellerArgs; + if (opts.data) { + const rawData = fs.readFileSync(opts.data); + sellerDataJson = JSON.parse(rawData.toString()); + } + + const sellerId = opts.id || sellerDataJson.id; + if (sellerId === undefined) { + throw new Error("Seller ID is required"); + } + + const optInSigners = new Map< + string, + { + sdk: CoreSDK; + fields: { + admin: boolean; + assistant: boolean; + authToken: boolean; + }; + } + >(); + + if (opts.privateKeys) { + const defaultFields = { + admin: false, + assistant: false, + authToken: false + }; + (opts.privateKeys as string).split(",").forEach((privKey: string) => { + const wallet = new Wallet(privKey.trim()); + const sdk = CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl), + wallet + ), + envName: envName as EnvironmentType, + configId: configId as ConfigId + }); + optInSigners.set(wallet.address.toLowerCase(), { + sdk, + fields: { ...defaultFields } + }); + }); + } + + const accountAbi = abis.IBosonAccountHandlerABI; + const accountHandler = new Contract( + defaultConfig.contracts.protocolDiamond, + accountAbi, + web3Provider + ); + const sellerDataRaw = await accountHandler.getSeller(sellerId); + const sellerData = extractSellerData(sellerDataRaw); + + console.log("Current Seller data", JSON.stringify(sellerData)); + + sellerDataJson = { + id: opts.id || sellerDataJson.id, + assistant: + opts.assistant || + sellerDataJson.assistant || + sellerData.seller.assistant, + admin: opts.admin || sellerDataJson.admin || sellerData.seller.admin, + treasury: + opts.treasury || sellerDataJson.treasury || sellerData.seller.treasury, + authTokenId: + opts.authTokenId || + sellerDataJson.authTokenId || + sellerData.authToken.tokenId, + authTokenType: + opts.authTokenType || + sellerDataJson.authTokenType || + sellerData.authToken.tokenType, + metadataUri: + opts.metadataUri || + sellerDataJson.metadataUri || + sellerData.seller.metadataUri + }; + + let modif = false; + for (const key of ["assistant", "treasury", "admin"]) { + if (sellerDataJson[key] !== sellerData.seller[key]) { + modif = true; + break; + } + } + modif = + modif || + sellerDataJson.authTokenId !== sellerData.authToken.tokenId || + sellerDataJson.authTokenType !== sellerData.authToken.tokenType || + sellerDataJson.metadataUri !== sellerData.seller.metadataUri; + + if (!modif) { + throw new Error("No updated value specified"); + } + + console.log(`Update Seller with Data ${JSON.stringify(sellerDataJson)}`); + + const coreSDK = CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl), + sellerWallet + ), + envName: envName as EnvironmentType, + configId: configId as ConfigId + }); + + console.log(`Updating seller on env ${envName} on chain ${chainId}...`); + const txResponse1 = await coreSDK.updateSellerAndOptIn(sellerDataJson); + console.log(`Tx hash: ${txResponse1.hash}`); + const txReceipt1 = await txResponse1.wait(); + const pendingSellerUpdate = coreSDK.getPendingSellerUpdateFromLogs( + txReceipt1.logs + ); + let updateComplete = true; + console.log( + `Pending Seller Updates: ${JSON.stringify(pendingSellerUpdate)}` + ); + for (const key of ["assistant", "admin", "tokenType"]) { + if ( + pendingSellerUpdate[key] && + pendingSellerUpdate[key] !== AddressZero + ) { + const address = (pendingSellerUpdate[key] as string).toLowerCase(); + // Map contract key "tokenType" to the fieldsToUpdate key "authToken" + const fieldKey = key === "tokenType" ? "authToken" : key; + if (optInSigners.has(address)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + optInSigners.get(address)!.fields[fieldKey] = true; + } else { + console.warn( + `No private key found to optIn for account ${address}. Update won't be complete` + ); + updateComplete = false; + } + } + } + const txOptIns: TransactionResponse[] = []; + for (const [account, optInSigner] of optInSigners.entries()) { + if ( + optInSigner.fields.admin || + optInSigner.fields.assistant || + optInSigner.fields.authToken + ) { + console.log( + `OptIn with account ${account} for fields ${JSON.stringify( + optInSigner.fields + )}` + ); + txOptIns.push( + await optInSigner.sdk.optInToSellerUpdate({ + id: sellerDataJson.id, + fieldsToUpdate: optInSigner.fields + }) + ); + } + } + await Promise.all(txOptIns.map((txOptIn) => txOptIn.wait())); + if (updateComplete) { + console.log(`Seller with id ${sellerId} updated.`); + } else { + console.warn( + `Seller with id ${sellerId} incomplete (you need to opt in for some accounts).` + ); + } + }); diff --git a/packages/cli/src/commands/upload-to-ipfs.ts b/packages/cli/src/commands/upload-to-ipfs.ts new file mode 100644 index 000000000..124ffa1c0 --- /dev/null +++ b/packages/cli/src/commands/upload-to-ipfs.ts @@ -0,0 +1,53 @@ +import fs from "fs"; +import { EnvironmentType, ConfigId } from "@bosonprotocol/common"; +import { getEnvConfigById } from "@bosonprotocol/core-sdk"; +import { BaseIpfsStorage } from "@bosonprotocol/ipfs-storage"; +import { Command } from "commander"; +import { buildInfuraHeaders } from "../utils/infura"; + +export const uploadToIpfsCommand = new Command("upload-to-ipfs") + .description("Upload a file to IPFS.") + .argument( + "", + "Path to the file to upload. Can also be set via FILE_PATH env var." + ) + .option( + "-e, --env ", + "Target environment (testing|staging|production). Overrides ENV_NAME env var.", + "testing" + ) + .option( + "-c, --configId ", + "Config id. Overrides ENV_CONFIG_ID env var.", + "testing-80002-0" + ) + .option( + "--infura ", + "ProjectId and Secret required to address Infura IPFS gateway (format: /). " + + "Overrides INFURA_CREDENTIALS env var." + ) + .action(async (filePath: string, opts) => { + const envName: string = opts.env || process.env.ENV_NAME || "testing"; + const configId: string = + opts.configId || process.env.ENV_CONFIG_ID || "testing-80002-0"; + const resolvedFilePath: string = filePath || process.env.FILE_PATH || ""; + const infura = opts.infura || process.env.INFURA_CREDENTIALS; + + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + const storage = new BaseIpfsStorage({ + url: defaultConfig.ipfsMetadataUrl, + headers: infura ? buildInfuraHeaders(infura) : undefined + }); + + const rawData = fs.readFileSync(resolvedFilePath); + + console.log(`*********************`); + console.log(` Upload file`); + console.log(`*********************`); + const hash = await storage.add(rawData); + console.log(`Hash: ${hash}`); + console.log(`*********************`); + }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 000000000..5d99fc6cb --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,49 @@ +import * as dotenv from "dotenv"; +// Load .env file from the current working directory before anything else +dotenv.config(); + +import path from "path"; +import fs from "fs"; +import { Command } from "commander"; +import { createSellerCommand } from "./commands/create-seller"; +import { updateSellerCommand } from "./commands/update-seller"; +import { getSellerCommand } from "./commands/get-seller"; +import { createOfferCommand } from "./commands/create-offer"; +import { exploreOfferCommand } from "./commands/explore-offer"; +import { depositFundsCommand } from "./commands/deposit-funds"; +import { completeExchangeCommand } from "./commands/complete-exchange"; +import { uploadToIpfsCommand } from "./commands/upload-to-ipfs"; +import { searchProductsCommand } from "./commands/search-products"; + +// Read version from package.json at runtime so it always reflects the published version +const packageRoot = path.resolve(__dirname, "../../"); +const packageJson = JSON.parse( + fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8") +); + +const program = new Command(); + +program + .name("boson") + .description( + "CLI for interacting with the Boson Protocol.\n\n" + + "Common arguments can be passed as environment variables in a local .env file.\n" + + "For example: ENV_NAME, ENV_CONFIG_ID, SELLER_PRIVATE_KEY, PRIVATE_KEY, etc.\n\n" + + "Run `boson help ` for details on a specific command." + ) + .version(packageJson.version); + +program.addCommand(createSellerCommand); +program.addCommand(updateSellerCommand); +program.addCommand(getSellerCommand); +program.addCommand(createOfferCommand); +program.addCommand(exploreOfferCommand); +program.addCommand(depositFundsCommand); +program.addCommand(completeExchangeCommand); +program.addCommand(uploadToIpfsCommand); +program.addCommand(searchProductsCommand); + +program.parseAsync(process.argv).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/cli/src/utils/account.ts b/packages/cli/src/utils/account.ts new file mode 100644 index 000000000..c5e93b44a --- /dev/null +++ b/packages/cli/src/utils/account.ts @@ -0,0 +1,61 @@ +type Seller = { + id: string; + assistant: string; + admin: string; + clerk: string; + treasury: string; + active: boolean; + metadataUri: string; +}; + +type AuthToken = { + tokenId: string; + tokenType: number; +}; + +export type SellerData = { + exists: boolean; + seller: Seller; + authToken: AuthToken; +}; + +function sellerFromStruct(struct: Array): Seller { + const [id, assistant, admin, clerk, treasury, active, metadataUri] = + struct as [ + { toString(): string }, + string, + string, + string, + string, + boolean, + string + ]; + return { + id: id.toString(), + assistant, + admin, + clerk, + treasury, + active, + metadataUri + }; +} + +function authTokenFromStruct(struct: Array): AuthToken { + const [tokenId, tokenType] = struct as [{ toString(): string }, number]; + return { + tokenId: tokenId.toString(), + tokenType + }; +} + +export function extractSellerData(sellerData: Array): SellerData { + const [exists, sellerStruct, authTokenStruct] = sellerData; + const seller = sellerFromStruct(sellerStruct as unknown[]); + const authToken = authTokenFromStruct(authTokenStruct as unknown[]); + return { + exists: exists as boolean, + seller, + authToken + }; +} diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts new file mode 100644 index 000000000..f43422a28 --- /dev/null +++ b/packages/cli/src/utils/index.ts @@ -0,0 +1,49 @@ +import { EnvironmentType, ConfigId } from "@bosonprotocol/common"; +import { CoreSDK, getEnvConfigById } from "@bosonprotocol/core-sdk"; +import { EthersAdapter } from "@bosonprotocol/ethers-sdk"; +import { providers, Wallet } from "ethers"; + +export function buildReadOnlyCoreSDK( + envName: string, + configId: string +): CoreSDK { + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + return CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl) + ), + envName: envName as EnvironmentType, + configId: configId as ConfigId + }); +} + +export function buildSignerCoreSDK( + privateKey: string, + envName: string, + configId: string +): CoreSDK { + const defaultConfig = getEnvConfigById( + envName as EnvironmentType, + configId as ConfigId + ); + const wallet = new Wallet(privateKey); + return CoreSDK.fromDefaultConfig({ + web3Lib: new EthersAdapter( + new providers.JsonRpcProvider(defaultConfig.jsonRpcUrl), + wallet + ), + envName: envName as EnvironmentType, + configId: configId as ConfigId + }); +} + +export function getEnvName(opts: { env?: string }): string { + return opts.env || process.env.ENV_NAME || "testing"; +} + +export function getConfigId(opts: { configId?: string }): string { + return opts.configId || process.env.ENV_CONFIG_ID || "testing-80002-0"; +} diff --git a/packages/cli/src/utils/infura.ts b/packages/cli/src/utils/infura.ts new file mode 100644 index 000000000..0ad8ebf47 --- /dev/null +++ b/packages/cli/src/utils/infura.ts @@ -0,0 +1,15 @@ +export function buildInfuraHeaders( + infuraOptions: string +): Record { + const [infuraProjectId, infuraProjectSecret] = infuraOptions.split("/"); + if (infuraProjectSecret) { + return { + authorization: `Basic ${Buffer.from( + infuraProjectId + ":" + infuraProjectSecret + ).toString("base64")}` + }; + } + return { + authorization: `Basic ${Buffer.from(infuraProjectId).toString("base64")}` + }; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..85d896d6c --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noImplicitAny": false, + "rootDir": "src", + "module": "commonjs", + "outDir": "dist/cjs", + "target": "es2016" + }, + "include": ["src"] +}