From 2bb531e75cc4dd55bd443bb65f713d4b3f3a7e74 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Thu, 26 May 2022 16:51:12 +0100 Subject: [PATCH 1/2] Remove wallet adapter dependency (#116) * chore: define WalletAdapter type explicitly * chore: remove unused dependency --- package.json | 1 - .../WalletAdapterIdentityDriver.ts | 27 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index b3f7ecfe9..43327eb4d 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "@metaplex-foundation/mpl-candy-machine": "^4.2.0", "@metaplex-foundation/mpl-token-metadata": "^2.1.1", "@solana/spl-token": "^0.2.0", - "@solana/wallet-adapter-base": "^0.9.3", "@solana/web3.js": "^1.37.0", "abort-controller": "^3.0.0", "bignumber.js": "^9.0.2", diff --git a/src/plugins/walletAdapterIdentity/WalletAdapterIdentityDriver.ts b/src/plugins/walletAdapterIdentity/WalletAdapterIdentityDriver.ts index 178ad82f7..6c66e182f 100644 --- a/src/plugins/walletAdapterIdentity/WalletAdapterIdentityDriver.ts +++ b/src/plugins/walletAdapterIdentity/WalletAdapterIdentityDriver.ts @@ -3,23 +3,30 @@ import { PublicKey, Transaction, TransactionSignature, + SendOptions, } from '@solana/web3.js'; -import { - MessageSignerWalletAdapterProps, - SignerWalletAdapterProps, - SendTransactionOptions, - WalletAdapter as BaseWalletAdapter, -} from '@solana/wallet-adapter-base'; -import { IdentityDriver } from '@/types'; +import { IdentityDriver, KeypairSigner } from '@/types'; import { Metaplex } from '@/Metaplex'; import { OperationNotSupportedByWalletAdapterError, UninitializedWalletAdapterError, } from '@/errors'; -export type WalletAdapter = BaseWalletAdapter & - Partial & - Partial; +type SendTransactionOptions = SendOptions & { + signers?: KeypairSigner[]; +}; + +export type WalletAdapter = { + publicKey: PublicKey | null; + sendTransaction: ( + transaction: Transaction, + connection: Connection, + options?: SendTransactionOptions + ) => Promise; + signMessage?: (message: Uint8Array) => Promise; + signTransaction?: (transaction: Transaction) => Promise; + signAllTransactions?: (transaction: Transaction[]) => Promise; +}; export class WalletAdapterIdentityDriver extends IdentityDriver { public readonly walletAdapter: WalletAdapter; From e5ae16a5a8b82cdfdb41e2251c3b68336ae16f40 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Fri, 27 May 2022 21:35:29 +0100 Subject: [PATCH 2/2] Refactor Storage Module (#117) * feat: implement storage module plugin Revert "feat: implement storage module plugin" This reverts commit b328fb400bd572d5be4143d1c3bf4671853f7ba5. feat: implement storage module plugin * chore: update mock storage driver * chore: update aws storage driver * chore: remove metaplex from driver type * chore: update the bundlr plugin * fix: build errors * chore: small refactoring * chore: simplify bundlr storage driver * feat: add upload events to storage client * chore: implement upload triggers on bundlr plugin * chore: use class for storage client implementation * chore: use classes for mock and aws drivers * chore: use class for bundlr driver * chore: rename XAware to HasX * chore: remove before/after upload events * chore: remove plan upload metadata operation * fix: build * fix: tests * feat: add more Amount methods * chore: test Amount type and methods * chore: sunset SolAmount class * chore: update isBundlrStorageDriver method * chore: simplify MetaplexFile type * fix: refactoring error * chore: remove old export * chore: create custom error for currency mismatch * fix: wrong import * chore: remove duplicate type def for StorageClient * chore: remove unused argument --- README.md | 2 +- src/Metaplex.ts | 16 -- src/errors/SdkError.ts | 42 +++- src/plugins/awsStorage/AwsStorageDriver.ts | 20 +- src/plugins/awsStorage/plugin.ts | 4 +- .../bundlrStorage/BundlrStorageDriver.ts | 229 ++++++++---------- src/plugins/bundlrStorage/index.ts | 1 - ...loadMetadataUsingBundlrOperationHandler.ts | 81 ------- src/plugins/bundlrStorage/plugin.ts | 10 +- .../candyMachineModule/createCandyMachine.ts | 4 +- src/plugins/corePlugins/plugin.ts | 8 +- src/plugins/index.ts | 1 + src/plugins/mockStorage/MockStorageDriver.ts | 33 +-- src/plugins/mockStorage/plugin.ts | 2 +- src/plugins/nftModule/NftClient.ts | 10 - src/plugins/nftModule/index.ts | 1 - src/plugins/nftModule/planUploadMetadata.ts | 101 -------- src/plugins/nftModule/plugin.ts | 8 - src/plugins/nftModule/uploadMetadata.ts | 56 ++++- src/plugins/storageModule/MetaplexFile.ts | 95 ++++++++ src/plugins/storageModule/StorageClient.ts | 79 ++++++ src/plugins/storageModule/StorageDriver.ts | 9 + src/plugins/storageModule/index.ts | 4 + src/plugins/storageModule/plugin.ts | 16 ++ src/types/Amount.ts | 160 ++++++++++++ src/types/Driver.ts | 6 + .../{MetaplexAware.ts => HasMetaplex.ts} | 4 +- src/types/MetaplexFile.ts | 91 ------- src/types/StorageDriver.ts | 38 --- src/types/index.ts | 5 +- src/utils/Plan.ts | 1 + src/utils/SolAmount.ts | 146 ----------- src/utils/index.ts | 1 - src/utils/types.ts | 2 + .../awsStorage/AwsStorageDriver.test.ts | 4 +- test/plugins/nftModule/createNft.test.ts | 8 +- test/plugins/nftModule/updateNft.test.ts | 6 +- test/types/Amount.test.ts | 146 +++++++++++ test/utils/SolAmount.test.ts | 127 ---------- 39 files changed, 751 insertions(+), 826 deletions(-) delete mode 100644 src/plugins/bundlrStorage/planUploadMetadataUsingBundlrOperationHandler.ts delete mode 100644 src/plugins/nftModule/planUploadMetadata.ts create mode 100644 src/plugins/storageModule/MetaplexFile.ts create mode 100644 src/plugins/storageModule/StorageClient.ts create mode 100644 src/plugins/storageModule/StorageDriver.ts create mode 100644 src/plugins/storageModule/index.ts create mode 100644 src/plugins/storageModule/plugin.ts create mode 100644 src/types/Amount.ts rename src/types/{MetaplexAware.ts => HasMetaplex.ts} (60%) delete mode 100644 src/types/MetaplexFile.ts delete mode 100644 src/types/StorageDriver.ts delete mode 100644 src/utils/SolAmount.ts create mode 100644 test/types/Amount.test.ts delete mode 100644 test/utils/SolAmount.test.ts diff --git a/README.md b/README.md index 5f6500ac2..af9d7588d 100644 --- a/README.md +++ b/README.md @@ -446,7 +446,7 @@ You may access the current storage driver using `metaplex.storage()` which will ```ts class StorageDriver { - getPrice(...files: MetaplexFile[]): Promise; + getPrice(...files: MetaplexFile[]): Promise; upload(file: MetaplexFile): Promise; uploadAll(files: MetaplexFile[]): Promise; uploadJson(json: T): Promise; diff --git a/src/Metaplex.ts b/src/Metaplex.ts index 85e674669..6c4517745 100644 --- a/src/Metaplex.ts +++ b/src/Metaplex.ts @@ -4,13 +4,11 @@ import { Cluster, resolveClusterFromConnection, IdentityDriver, - StorageDriver, RpcDriver, ProgramDriver, OperationDriver, } from '@/types'; import { GuestIdentityDriver } from '@/plugins/guestIdentity'; -import { BundlrStorageDriver } from '@/plugins/bundlrStorage'; import { CoreRpcDriver } from '@/plugins/coreRpcDriver'; import { CoreProgramDriver } from '@/plugins/coreProgramDriver'; import { CoreOperationDriver } from '@/plugins/coreOperationDriver'; @@ -30,9 +28,6 @@ export class Metaplex { /** Encapsulates the identity of the users interacting with the SDK. */ protected identityDriver: IdentityDriver; - /** Encapsulates where assets should be uploaded. */ - protected storageDriver: StorageDriver; - /** Encapsulates how to read and write on-chain. */ protected rpcDriver: RpcDriver; @@ -46,7 +41,6 @@ export class Metaplex { this.connection = connection; this.cluster = options.cluster ?? resolveClusterFromConnection(connection); this.identityDriver = new GuestIdentityDriver(this); - this.storageDriver = new BundlrStorageDriver(this); this.rpcDriver = new CoreRpcDriver(this); this.programDriver = new CoreProgramDriver(this); this.operationDriver = new CoreOperationDriver(this); @@ -73,16 +67,6 @@ export class Metaplex { return this; } - storage() { - return this.storageDriver; - } - - setStorageDriver(storage: StorageDriver) { - this.storageDriver = storage; - - return this; - } - rpc() { return this.rpcDriver; } diff --git a/src/errors/SdkError.ts b/src/errors/SdkError.ts index 1632c2e76..c49da0e35 100644 --- a/src/errors/SdkError.ts +++ b/src/errors/SdkError.ts @@ -1,5 +1,5 @@ import { PublicKey } from '@solana/web3.js'; -import { Cluster, Program } from '@/types'; +import { Cluster, Program, Currency } from '@/types'; import { MetaplexError, MetaplexErrorInputWithoutSource, @@ -29,6 +29,46 @@ export class OperationHandlerMissingError extends SdkError { } } +export class DriverNotProvidedError extends SdkError { + constructor(driver: string, cause?: Error) { + super({ + cause, + key: 'driver_not_provided', + title: 'Driver Not Provided', + problem: `The SDK tried to access the driver [${driver}] but was not provided.`, + solution: + 'Make sure the driver is registered by using the "setDriver(myDriver)" method.', + }); + } +} + +export class CurrencyMismatchError extends SdkError { + left: Currency; + right: Currency; + operation?: string; + constructor( + left: Currency, + right: Currency, + operation?: string, + cause?: Error + ) { + const wrappedOperation = operation ? ` [${operation}]` : ''; + super({ + cause, + key: 'currency_mismatch', + title: 'Currency Mismatch', + problem: + `The SDK tried to execute an operation${wrappedOperation} on two different currencies: ` + + `${left.symbol} and ${right.symbol}.`, + solution: + 'Provide both amounts in the same currency to perform this operation.', + }); + this.left = left; + this.right = right; + this.operation = operation; + } +} + export class InvalidJsonVariableError extends SdkError { constructor(cause?: Error) { super({ diff --git a/src/plugins/awsStorage/AwsStorageDriver.ts b/src/plugins/awsStorage/AwsStorageDriver.ts index 8bcef2564..0814bd28b 100644 --- a/src/plugins/awsStorage/AwsStorageDriver.ts +++ b/src/plugins/awsStorage/AwsStorageDriver.ts @@ -1,27 +1,25 @@ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; -import { Metaplex } from '@/Metaplex'; -import { StorageDriver, MetaplexFile } from '@/types'; -import { SolAmount } from '@/utils'; +import { lamports, Amount } from '@/types'; +import { MetaplexFile, StorageDriver } from '../storageModule'; -export class AwsStorageDriver extends StorageDriver { +export class AwsStorageDriver implements StorageDriver { protected client: S3Client; protected bucketName: string; - constructor(metaplex: Metaplex, client: S3Client, bucketName: string) { - super(metaplex); + constructor(client: S3Client, bucketName: string) { this.client = client; this.bucketName = bucketName; } - public async getPrice(..._files: MetaplexFile[]): Promise { - return SolAmount.zero(); + async getUploadPrice(_bytes: number): Promise { + return lamports(0); } - public async upload(file: MetaplexFile): Promise { + async upload(file: MetaplexFile): Promise { const command = new PutObjectCommand({ Bucket: this.bucketName, Key: file.uniqueName, - Body: file.toBuffer(), + Body: file.buffer, }); try { @@ -34,7 +32,7 @@ export class AwsStorageDriver extends StorageDriver { } } - protected async getUrl(key: string) { + async getUrl(key: string) { const region = await this.client.config.region(); const encodedKey = encodeURIComponent(key); diff --git a/src/plugins/awsStorage/plugin.ts b/src/plugins/awsStorage/plugin.ts index 818a879f1..c3fe89410 100644 --- a/src/plugins/awsStorage/plugin.ts +++ b/src/plugins/awsStorage/plugin.ts @@ -8,8 +8,6 @@ export const awsStorage = ( bucketName: string ): MetaplexPlugin => ({ install(metaplex: Metaplex) { - metaplex.setStorageDriver( - new AwsStorageDriver(metaplex, client, bucketName) - ); + metaplex.storage().setDriver(new AwsStorageDriver(client, bucketName)); }, }); diff --git a/src/plugins/bundlrStorage/BundlrStorageDriver.ts b/src/plugins/bundlrStorage/BundlrStorageDriver.ts index 4a396b234..0380e71be 100644 --- a/src/plugins/bundlrStorage/BundlrStorageDriver.ts +++ b/src/plugins/bundlrStorage/BundlrStorageDriver.ts @@ -1,9 +1,9 @@ import type { default as NodeBundlr, WebBundlr } from '@bundlr-network/client'; import * as BundlrPackage from '@bundlr-network/client'; import BigNumber from 'bignumber.js'; +import BN from 'bn.js'; import { Metaplex } from '@/Metaplex'; -import { StorageDriver, MetaplexFile } from '@/types'; -import { SolAmount } from '@/utils'; +import { Amount, lamports } from '@/types'; import { KeypairIdentityDriver } from '../keypairIdentity'; import { AssetUploadFailedError, @@ -11,190 +11,140 @@ import { FailedToConnectToBundlrAddressError, FailedToInitializeBundlrError, } from '@/errors'; +import { + getBytesFromMetaplexFiles, + MetaplexFile, + MetaplexFileTag, + StorageDriver, +} from '../storageModule'; -export interface BundlrOptions { +export type BundlrOptions = { address?: string; timeout?: number; providerUrl?: string; priceMultiplier?: number; withdrawAfterUploading?: boolean; -} +}; -export class BundlrStorageDriver extends StorageDriver { - protected bundlr: WebBundlr | NodeBundlr | null = null; - protected options: BundlrOptions; - protected _withdrawAfterUploading: boolean; +export class BundlrStorageDriver implements StorageDriver { + protected _metaplex: Metaplex; + protected _bundlr: WebBundlr | NodeBundlr | null = null; + protected _options: BundlrOptions; constructor(metaplex: Metaplex, options: BundlrOptions = {}) { - super(metaplex); - this.options = { + this._metaplex = metaplex; + this._options = { providerUrl: metaplex.connection.rpcEndpoint, ...options, }; - this._withdrawAfterUploading = options.withdrawAfterUploading ?? true; - } - - public async getBalance(): Promise { - const bundlr = await this.getBundlr(); - const balance = await bundlr.getLoadedBalance(); - - return SolAmount.fromLamports(balance); } - public async getPrice(...files: MetaplexFile[]): Promise { - const price = await this.getMultipliedPrice(this.getBytes(files)); + async getUploadPrice(bytes: number): Promise { + const bundlr = await this.bundlr(); + const price = await bundlr.getPrice(bytes); - return SolAmount.fromLamports(price); + return bigNumberToAmount( + price.multipliedBy(this._options.priceMultiplier ?? 1.5) + ); } - public async upload(file: MetaplexFile): Promise { + async upload(file: MetaplexFile): Promise { const [uri] = await this.uploadAll([file]); return uri; } - public async uploadAll(files: MetaplexFile[]): Promise { - await this.fund(files); - const promises = files.map((file) => this.uploadFile(file)); - - const uris = await Promise.all(promises); + async uploadAll(files: MetaplexFile[]): Promise { + const bundlr = await this.bundlr(); + const amount = await this.getUploadPrice( + getBytesFromMetaplexFiles(...files) + ); + await this.fund(amount); - if (this.shouldWithdrawAfterUploading()) { - await this.withdrawAll(); - } + const promises = files.map(async (file) => { + const { status, data } = await bundlr.uploader.upload( + file.buffer, + getMetaplexFileTagsWithContentType(file) + ); - return uris; - } + if (status >= 300) { + throw new AssetUploadFailedError(status); + } - public async fundingNeeded( - filesOrBytes: MetaplexFile[] | number, - skipBalanceCheck = false - ): Promise { - const price = await this.getMultipliedPrice(this.getBytes(filesOrBytes)); + return `https://arweave.net/${data.id}`; + }); - if (skipBalanceCheck) { - return price; - } + return await Promise.all(promises); + } - const bundlr = await this.getBundlr(); + async getBalance(): Promise { + const bundlr = await this.bundlr(); const balance = await bundlr.getLoadedBalance(); - return price.isGreaterThan(balance) - ? price.minus(balance) - : new BigNumber(0); + return bigNumberToAmount(balance); } - public async needsFunding( - filesOrBytes: MetaplexFile[] | number, - skipBalanceCheck = false - ): Promise { - const fundingNeeded = await this.fundingNeeded( - filesOrBytes, - skipBalanceCheck - ); + async fund(amount: Amount, skipBalanceCheck = false): Promise { + const bundlr = await this.bundlr(); + let toFund = amountToBigNumber(amount); - return fundingNeeded.isGreaterThan(0); - } + if (!skipBalanceCheck) { + const balance = await bundlr.getLoadedBalance(); - public async fund( - filesOrBytes: MetaplexFile[] | number, - skipBalanceCheck = false - ): Promise { - const bundlr = await this.getBundlr(); - const fundingNeeded = await this.fundingNeeded( - filesOrBytes, - skipBalanceCheck - ); + toFund = toFund.isGreaterThan(balance) + ? toFund.minus(balance) + : new BigNumber(0); + } - if (!fundingNeeded.isGreaterThan(0)) { + if (toFund.isLessThanOrEqualTo(0)) { return; } // TODO: Catch errors and wrap in BundlrErrors. - await bundlr.fund(fundingNeeded); - } - - protected getBytes(filesOrBytes: MetaplexFile[] | number): number { - if (typeof filesOrBytes === 'number') { - return filesOrBytes; - } - - return filesOrBytes.reduce((total, file) => total + file.getBytes(), 0); - } - - protected async getMultipliedPrice(bytes: number): Promise { - const bundlr = await this.getBundlr(); - const price = await bundlr.getPrice(bytes); - - return price - .multipliedBy(this.options.priceMultiplier ?? 1.5) - .decimalPlaces(0); - } - - protected async uploadFile(file: MetaplexFile): Promise { - const bundlr = await this.getBundlr(); - const { status, data } = await bundlr.uploader.upload( - file.toBuffer(), - file.getTagsWithContentType() - ); - - if (status >= 300) { - throw new AssetUploadFailedError(status); - } - - return `https://arweave.net/${data.id}`; + await bundlr.fund(toFund); } - public async withdrawAll(): Promise { + async withdrawAll(): Promise { // TODO(loris): Replace with "withdrawAll" when available on Bundlr. - const balance = await this.getBalance(); - const minimumBalance = SolAmount.fromLamports(5000); + const bundlr = await this.bundlr(); + const balance = await bundlr.getLoadedBalance(); + const minimumBalance = new BigNumber(5000); if (balance.isLessThan(minimumBalance)) { return; } - const balanceToWithdraw = balance.minus(minimumBalance).getLamports(); - await this.withdraw(balanceToWithdraw); + const balanceToWithdraw = balance.minus(minimumBalance); + await this.withdraw(bigNumberToAmount(balanceToWithdraw)); } - public async withdraw(lamports: BigNumber | number): Promise { - const bundlr = await this.getBundlr(); + async withdraw(amount: Amount): Promise { + const bundlr = await this.bundlr(); - const { status } = await bundlr.withdrawBalance(new BigNumber(lamports)); + const { status } = await bundlr.withdrawBalance(amountToBigNumber(amount)); if (status >= 300) { throw new BundlrWithdrawError(status); } } - public shouldWithdrawAfterUploading(): boolean { - return this._withdrawAfterUploading; - } - - public withdrawAfterUploading() { - this._withdrawAfterUploading = true; - - return this; - } - - public dontWithdrawAfterUploading() { - this._withdrawAfterUploading = false; + async bundlr(): Promise { + if (this._bundlr) { + return this._bundlr; + } - return this; + return (this._bundlr = await this.initBundlr()); } - public async getBundlr(): Promise { - if (this.bundlr) return this.bundlr; - + async initBundlr(): Promise { const currency = 'solana'; - const address = this.options?.address ?? 'https://node1.bundlr.network'; + const address = this._options?.address ?? 'https://node1.bundlr.network'; const options = { - timeout: this.options.timeout, - providerUrl: this.options.providerUrl, + timeout: this._options.timeout, + providerUrl: this._options.providerUrl, }; - const identity = this.metaplex.identity(); + const identity = this._metaplex.identity(); const bundlr = identity instanceof KeypairIdentityDriver ? new BundlrPackage.default( @@ -221,8 +171,35 @@ export class BundlrStorageDriver extends StorageDriver { } } - this.bundlr = bundlr; - return bundlr; } } + +export const isBundlrStorageDriver = ( + storageDriver: StorageDriver +): storageDriver is BundlrStorageDriver => { + return ( + 'bundlr' in storageDriver && + 'getBalance' in storageDriver && + 'fund' in storageDriver && + 'withdrawAll' in storageDriver + ); +}; + +const bigNumberToAmount = (bigNumber: BigNumber): Amount => { + return lamports(new BN(bigNumber.decimalPlaces(0).toString())); +}; + +const amountToBigNumber = (amount: Amount): BigNumber => { + return new BigNumber(amount.basisPoints.toString()); +}; + +const getMetaplexFileTagsWithContentType = ( + file: MetaplexFile +): MetaplexFileTag[] => { + if (!file.contentType) { + return file.tags; + } + + return [{ name: 'Content-Type', value: file.contentType }, ...file.tags]; +}; diff --git a/src/plugins/bundlrStorage/index.ts b/src/plugins/bundlrStorage/index.ts index 6ab8f1ffc..edbb7d38a 100644 --- a/src/plugins/bundlrStorage/index.ts +++ b/src/plugins/bundlrStorage/index.ts @@ -1,3 +1,2 @@ export * from './BundlrStorageDriver'; -export * from './planUploadMetadataUsingBundlrOperationHandler'; export * from './plugin'; diff --git a/src/plugins/bundlrStorage/planUploadMetadataUsingBundlrOperationHandler.ts b/src/plugins/bundlrStorage/planUploadMetadataUsingBundlrOperationHandler.ts deleted file mode 100644 index 488657963..000000000 --- a/src/plugins/bundlrStorage/planUploadMetadataUsingBundlrOperationHandler.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Metaplex } from '@/Metaplex'; -import { MetaplexFile, OperationHandler } from '@/types'; -import { Plan, DisposableScope } from '@/utils'; -import { BundlrStorageDriver } from './BundlrStorageDriver'; -import { UploadMetadataOutput } from '../nftModule/uploadMetadata'; -import { - getAssetsFromJsonMetadata, - PlanUploadMetadataOperation, - planUploadMetadataOperationHandler, - replaceAssetsWithUris, -} from '../nftModule/planUploadMetadata'; - -export const planUploadMetadataUsingBundlrOperationHandler: OperationHandler = - { - handle: async ( - operation: PlanUploadMetadataOperation, - metaplex: Metaplex, - scope: DisposableScope - ): Promise> => { - const metadata = operation.input; - const plan = await planUploadMetadataOperationHandler.handle( - operation, - metaplex, - scope - ); - const storage = metaplex.storage(); - - if (!(storage instanceof BundlrStorageDriver)) { - return plan; - } - - const assets = getAssetsFromJsonMetadata(metadata); - const mockUri = 'x'.repeat(100); - const mockUris = assets.map(() => mockUri); - const mockedMetadata = replaceAssetsWithUris(metadata, mockUris); - const files: MetaplexFile[] = [ - ...assets, - MetaplexFile.fromJson(mockedMetadata), - ]; - let originalWithdrawAfterUploading = - storage.shouldWithdrawAfterUploading(); - - return plan - .prependStep({ - name: 'Fund Bundlr wallet', - handler: async () => { - // In this step, we ensure the wallet has enough funds to pay for all the required - // uploads. We also disable withdrawing after each upload and keep track of its - // initial state. This prevents having to fund many times within this plan. - - originalWithdrawAfterUploading = - storage.shouldWithdrawAfterUploading(); - storage.dontWithdrawAfterUploading(); - - const needsFunding = await storage.needsFunding(files); - - if (!needsFunding) { - return; - } - - await storage.fund(files); - }, - }) - .addStep({ - name: 'Withdraw funds from the Bundlr wallet', - handler: async (output: UploadMetadataOutput) => { - // Since we've not withdrawn after every upload, we now need to - // withdraw any remaining funds. After doing so, we must not - // forget to restore the original withdrawAfterUploading. - - await storage.withdrawAll(); - - originalWithdrawAfterUploading - ? storage.withdrawAfterUploading() - : storage.dontWithdrawAfterUploading(); - - return output; - }, - }); - }, - }; diff --git a/src/plugins/bundlrStorage/plugin.ts b/src/plugins/bundlrStorage/plugin.ts index 47027169e..ece9b1cd6 100644 --- a/src/plugins/bundlrStorage/plugin.ts +++ b/src/plugins/bundlrStorage/plugin.ts @@ -1,17 +1,9 @@ import { Metaplex } from '@/Metaplex'; import { MetaplexPlugin } from '@/types'; import { BundlrOptions, BundlrStorageDriver } from './BundlrStorageDriver'; -import { planUploadMetadataOperation } from '../nftModule/planUploadMetadata'; -import { planUploadMetadataUsingBundlrOperationHandler } from './planUploadMetadataUsingBundlrOperationHandler'; export const bundlrStorage = (options: BundlrOptions = {}): MetaplexPlugin => ({ install(metaplex: Metaplex) { - metaplex.setStorageDriver(new BundlrStorageDriver(metaplex, options)); - metaplex - .operations() - .register( - planUploadMetadataOperation, - planUploadMetadataUsingBundlrOperationHandler - ); + metaplex.storage().setDriver(new BundlrStorageDriver(metaplex, options)); }, }); diff --git a/src/plugins/candyMachineModule/createCandyMachine.ts b/src/plugins/candyMachineModule/createCandyMachine.ts index eed2dc315..2a3f39e17 100644 --- a/src/plugins/candyMachineModule/createCandyMachine.ts +++ b/src/plugins/candyMachineModule/createCandyMachine.ts @@ -15,7 +15,7 @@ import { useOperation, Signer, OperationHandler, - MetaplexAware, + HasMetaplex, } from '@/types'; import { TransactionBuilder } from '@/utils'; import { @@ -101,7 +101,7 @@ export const createCandyMachineOperationHandler: OperationHandler ({ install(metaplex: Metaplex) { + metaplex.use(storageModule()); metaplex.use(corePrograms()); metaplex.use(nftModule()); metaplex.use(candyMachineModule()); diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 16f0bb1ad..02fd9bb63 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -10,4 +10,5 @@ export * from './guestIdentity'; export * from './keypairIdentity'; export * from './mockStorage'; export * from './nftModule'; +export * from './storageModule'; export * from './walletAdapterIdentity'; diff --git a/src/plugins/mockStorage/MockStorageDriver.ts b/src/plugins/mockStorage/MockStorageDriver.ts index f58ce289a..727ab45d0 100644 --- a/src/plugins/mockStorage/MockStorageDriver.ts +++ b/src/plugins/mockStorage/MockStorageDriver.ts @@ -1,24 +1,22 @@ import BN from 'bn.js'; -import { Metaplex } from '@/Metaplex'; -import { MetaplexFile, StorageDriver } from '@/types'; -import { SolAmount } from '@/utils'; +import { Amount, lamports } from '@/types'; import { AssetNotFoundError } from '@/errors'; +import { MetaplexFile, StorageDriver } from '../storageModule'; const DEFAULT_BASE_URL = 'https://mockstorage.example.com/'; const DEFAULT_COST_PER_BYTE = new BN(1); -export interface MockStorageOptions { +export type MockStorageOptions = { baseUrl?: string; costPerByte?: BN | number; -} +}; -export class MockStorageDriver extends StorageDriver { +export class MockStorageDriver implements StorageDriver { private cache: Record = {}; public readonly baseUrl: string; public readonly costPerByte: BN; - constructor(metaplex: Metaplex, options?: MockStorageOptions) { - super(metaplex); + constructor(options?: MockStorageOptions) { this.baseUrl = options?.baseUrl ?? DEFAULT_BASE_URL; this.costPerByte = options?.costPerByte != null @@ -26,23 +24,18 @@ export class MockStorageDriver extends StorageDriver { : DEFAULT_COST_PER_BYTE; } - public async getPrice(...files: MetaplexFile[]): Promise { - const bytes = files.reduce( - (total, file) => total + file.toBuffer().byteLength, - 0 - ); - - return SolAmount.fromLamports(bytes).multipliedBy(this.costPerByte); + async getUploadPrice(bytes: number): Promise { + return lamports(this.costPerByte.muln(bytes)); } - public async upload(file: MetaplexFile): Promise { + async upload(file: MetaplexFile): Promise { const uri = `${this.baseUrl}${file.uniqueName}`; this.cache[uri] = file; return uri; } - public async download(uri: string): Promise { + async download(uri: string): Promise { const file = this.cache[uri]; if (!file) { @@ -51,10 +44,4 @@ export class MockStorageDriver extends StorageDriver { return file; } - - public async downloadJson(uri: string): Promise { - const file = await this.download(uri); - - return JSON.parse(file.toString()); - } } diff --git a/src/plugins/mockStorage/plugin.ts b/src/plugins/mockStorage/plugin.ts index c7720d2e3..90d86f28b 100644 --- a/src/plugins/mockStorage/plugin.ts +++ b/src/plugins/mockStorage/plugin.ts @@ -4,6 +4,6 @@ import { MockStorageDriver, MockStorageOptions } from './MockStorageDriver'; export const mockStorage = (options?: MockStorageOptions): MetaplexPlugin => ({ install(metaplex: Metaplex) { - metaplex.setStorageDriver(new MockStorageDriver(metaplex, options)); + metaplex.storage().setDriver(new MockStorageDriver(options)); }, }); diff --git a/src/plugins/nftModule/NftClient.ts b/src/plugins/nftModule/NftClient.ts index c0b54b78e..00c458a42 100644 --- a/src/plugins/nftModule/NftClient.ts +++ b/src/plugins/nftModule/NftClient.ts @@ -1,6 +1,5 @@ import { PublicKey } from '@solana/web3.js'; import { ModuleClient } from '@/types'; -import { Plan } from '@/utils'; import { Nft } from './Nft'; import { findNftByMintOperation } from './findNftByMint'; import { findNftsByMintListOperation } from './findNftsByMintList'; @@ -12,7 +11,6 @@ import { uploadMetadataOperation, UploadMetadataOutput, } from './uploadMetadata'; -import { planUploadMetadataOperation } from './planUploadMetadata'; import { CreateNftInput, createNftOperation, @@ -64,14 +62,6 @@ export class NftClient extends ModuleClient { return this.metaplex.operations().execute(uploadMetadataOperation(input)); } - planUploadMetadata( - input: UploadMetadataInput - ): Promise> { - return this.metaplex - .operations() - .execute(planUploadMetadataOperation(input)); - } - async create(input: CreateNftInput): Promise<{ nft: Nft } & CreateNftOutput> { const operation = createNftOperation(input); const createNftOutput = await this.metaplex.operations().execute(operation); diff --git a/src/plugins/nftModule/index.ts b/src/plugins/nftModule/index.ts index fe69b1e48..49742d61d 100644 --- a/src/plugins/nftModule/index.ts +++ b/src/plugins/nftModule/index.ts @@ -7,7 +7,6 @@ export * from './findNftsByOwner'; export * from './JsonMetadata'; export * from './Nft'; export * from './NftClient'; -export * from './planUploadMetadata'; export * from './plugin'; export * from './printNewEdition'; export * from './updateNft'; diff --git a/src/plugins/nftModule/planUploadMetadata.ts b/src/plugins/nftModule/planUploadMetadata.ts deleted file mode 100644 index 677b804f7..000000000 --- a/src/plugins/nftModule/planUploadMetadata.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Metaplex } from '@/Metaplex'; -import { - useOperation, - Operation, - OperationHandler, - MetaplexFile, -} from '@/types'; -import { Plan, walk } from '@/utils'; -import cloneDeep from 'lodash.clonedeep'; -import { JsonMetadata } from './JsonMetadata'; -import { UploadMetadataInput, UploadMetadataOutput } from './uploadMetadata'; - -const Key = 'PlanUploadMetadataOperation' as const; -export const planUploadMetadataOperation = - useOperation(Key); -export type PlanUploadMetadataOperation = Operation< - typeof Key, - UploadMetadataInput, - Plan ->; - -export const planUploadMetadataOperationHandler: OperationHandler = - { - handle: async ( - operation: PlanUploadMetadataOperation, - metaplex: Metaplex - ): Promise> => { - const metadata = operation.input; - const files = getAssetsFromJsonMetadata(metadata); - - if (files.length <= 0) { - return Plan.make().addStep({ - name: 'Upload the metadata', - handler: () => uploadMetadata(metaplex, metadata as JsonMetadata), - }); - } - - return Plan.make() - .addStep({ - name: 'Upload assets', - handler: () => uploadAssets(metaplex, metadata), - }) - .addStep({ - name: 'Upload the metadata', - handler: (input) => uploadMetadata(metaplex, input), - }); - }, - }; - -const uploadAssets = async ( - metaplex: Metaplex, - input: UploadMetadataInput -): Promise => { - const files = getAssetsFromJsonMetadata(input); - const uris = await metaplex.storage().uploadAll(files); - - return replaceAssetsWithUris(input, uris); -}; - -const uploadMetadata = async ( - metaplex: Metaplex, - metadata: JsonMetadata -): Promise => { - const uri = await metaplex.storage().uploadJson(metadata); - - return { metadata, uri }; -}; - -export const getAssetsFromJsonMetadata = ( - input: UploadMetadataInput -): MetaplexFile[] => { - const files: MetaplexFile[] = []; - - walk(input, (next, value) => { - if (value instanceof MetaplexFile) { - files.push(value); - } else { - next(value); - } - }); - - return files; -}; - -export const replaceAssetsWithUris = ( - input: UploadMetadataInput, - replacements: string[] -): JsonMetadata => { - const clone = cloneDeep(input); - let index = 0; - - walk(clone, (next, value, key, parent) => { - if (value instanceof MetaplexFile && index < replacements.length) { - parent[key] = replacements[index++]; - } - - next(value); - }); - - return clone as JsonMetadata; -}; diff --git a/src/plugins/nftModule/plugin.ts b/src/plugins/nftModule/plugin.ts index 075165b81..80b86312e 100644 --- a/src/plugins/nftModule/plugin.ts +++ b/src/plugins/nftModule/plugin.ts @@ -26,10 +26,6 @@ import { printNewEditionOperation, printNewEditionOperationHandler, } from './printNewEdition'; -import { - planUploadMetadataOperation, - planUploadMetadataOperationHandler, -} from './planUploadMetadata'; import { updateNftOperation, updateNftOperationHandler } from './updateNft'; import { uploadMetadataOperation, @@ -58,10 +54,6 @@ export const nftModule = (): MetaplexPlugin => ({ findNftsByOwnerOnChainOperationHandler ); op.register(printNewEditionOperation, printNewEditionOperationHandler); - op.register( - planUploadMetadataOperation, - planUploadMetadataOperationHandler - ); op.register(updateNftOperation, updateNftOperationHandler); op.register(uploadMetadataOperation, uploadMetadataOperationHandler); diff --git a/src/plugins/nftModule/uploadMetadata.ts b/src/plugins/nftModule/uploadMetadata.ts index 79526e05b..b194f6b6e 100644 --- a/src/plugins/nftModule/uploadMetadata.ts +++ b/src/plugins/nftModule/uploadMetadata.ts @@ -1,12 +1,9 @@ +import cloneDeep from 'lodash.clonedeep'; import { Metaplex } from '@/Metaplex'; -import { - MetaplexFile, - Operation, - OperationHandler, - useOperation, -} from '@/types'; +import { Operation, OperationHandler, useOperation } from '@/types'; +import { walk } from '@/utils'; import { JsonMetadata } from './JsonMetadata'; -import { planUploadMetadataOperation } from './planUploadMetadata'; +import { isMetaplexFile, MetaplexFile } from '../storageModule'; const Key = 'UploadMetadataOperation' as const; export const uploadMetadataOperation = @@ -21,6 +18,7 @@ export type UploadMetadataInput = JsonMetadata; export interface UploadMetadataOutput { metadata: JsonMetadata; + assetUris: string[]; uri: string; } @@ -30,10 +28,46 @@ export const uploadMetadataOperationHandler: OperationHandler => { - const plan = await metaplex - .operations() - .execute(planUploadMetadataOperation(operation.input)); + const rawMetadata = operation.input; + const files = getAssetsFromJsonMetadata(rawMetadata); + const assetUris = await metaplex.storage().uploadAll(files); + const metadata = replaceAssetsWithUris(rawMetadata, assetUris); + const uri = await metaplex.storage().uploadJson(metadata); - return plan.execute(); + return { uri, metadata, assetUris }; }, }; + +export const getAssetsFromJsonMetadata = ( + input: UploadMetadataInput +): MetaplexFile[] => { + const files: MetaplexFile[] = []; + + walk(input, (next, value) => { + if (isMetaplexFile(value)) { + files.push(value); + } else { + next(value); + } + }); + + return files; +}; + +export const replaceAssetsWithUris = ( + input: UploadMetadataInput, + replacements: string[] +): JsonMetadata => { + const clone = cloneDeep(input); + let index = 0; + + walk(clone, (next, value, key, parent) => { + if (isMetaplexFile(value) && index < replacements.length) { + parent[key] = replacements[index++]; + } + + next(value); + }); + + return clone as JsonMetadata; +}; diff --git a/src/plugins/storageModule/MetaplexFile.ts b/src/plugins/storageModule/MetaplexFile.ts new file mode 100644 index 000000000..10e882ef8 --- /dev/null +++ b/src/plugins/storageModule/MetaplexFile.ts @@ -0,0 +1,95 @@ +import { Buffer } from 'buffer'; +import { getContentType, getExtension, randomStr } from '@/utils'; +import { InvalidJsonVariableError } from '@/errors'; + +export type MetaplexFile = Readonly<{ + buffer: Buffer; + fileName: string; + displayName: string; + uniqueName: string; + contentType: string | null; + extension: string | null; + tags: MetaplexFileTag[]; +}>; + +export type MetaplexFileContent = string | Buffer | Uint8Array | ArrayBuffer; + +export type MetaplexFileTag = { name: string; value: string }; + +export type MetaplexFileOptions = { + displayName?: string; + uniqueName?: string; + contentType?: string; + extension?: string; + tags?: { name: string; value: string }[]; +}; + +export const useMetaplexFile = ( + content: MetaplexFileContent, + fileName: string, + options: MetaplexFileOptions = {} +): MetaplexFile => ({ + buffer: parseMetaplexFileContent(content), + fileName: fileName, + displayName: options.displayName ?? fileName, + uniqueName: options.uniqueName ?? randomStr(), + contentType: options.contentType ?? getContentType(fileName), + extension: options.extension ?? getExtension(fileName), + tags: options.tags ?? [], +}); + +export const useMetaplexFileFromBrowser = async ( + file: File, + options: MetaplexFileOptions = {} +): Promise => { + const buffer = await file.arrayBuffer(); + + return useMetaplexFile(buffer, file.name, options); +}; + +export const useMetaplexFileFromJson = ( + json: T, + fileName: string = 'inline.json', + options: MetaplexFileOptions = {} +): MetaplexFile => { + let jsonString; + + try { + jsonString = JSON.stringify(json); + } catch (error) { + throw new InvalidJsonVariableError(error as Error); + } + + return useMetaplexFile(jsonString, fileName, options); +}; + +export const parseMetaplexFileContent = ( + content: MetaplexFileContent +): Buffer => { + if (content instanceof ArrayBuffer) { + return Buffer.from(new Uint8Array(content)); + } + + return Buffer.from(content); +}; + +export const getBytesFromMetaplexFiles = (...files: MetaplexFile[]): number => + files.reduce((acc, file) => acc + file.buffer.byteLength, 0); + +export const getBrowserFileFromMetaplexFile = (file: MetaplexFile): File => + new File([file.buffer as BlobPart], file.fileName); + +export const isMetaplexFile = ( + metaplexFile: any +): metaplexFile is MetaplexFile => { + return ( + typeof metaplexFile === 'object' && + 'buffer' in metaplexFile && + 'fileName' in metaplexFile && + 'displayName' in metaplexFile && + 'uniqueName' in metaplexFile && + 'contentType' in metaplexFile && + 'extension' in metaplexFile && + 'tags' in metaplexFile + ); +}; diff --git a/src/plugins/storageModule/StorageClient.ts b/src/plugins/storageModule/StorageClient.ts new file mode 100644 index 000000000..122a34c38 --- /dev/null +++ b/src/plugins/storageModule/StorageClient.ts @@ -0,0 +1,79 @@ +import { DriverNotProvidedError, InvalidJsonStringError } from '@/errors'; +import { HasDriver, Amount } from '@/types'; +import { + getBytesFromMetaplexFiles, + MetaplexFile, + useMetaplexFile, + useMetaplexFileFromJson, +} from './MetaplexFile'; +import { StorageDriver } from './StorageDriver'; + +export class StorageClient implements HasDriver { + private _driver: StorageDriver | null = null; + + driver(): StorageDriver { + if (!this._driver) { + throw new DriverNotProvidedError('StorageDriver'); + } + + return this._driver; + } + + setDriver(newDriver: StorageDriver): void { + this._driver = newDriver; + } + + getUploadPriceForBytes(bytes: number): Promise { + return this.driver().getUploadPrice(bytes); + } + + getUploadPriceForFile(file: MetaplexFile): Promise { + return this.getUploadPriceForFiles([file]); + } + + getUploadPriceForFiles(files: MetaplexFile[]): Promise { + return this.getUploadPriceForBytes(getBytesFromMetaplexFiles(...files)); + } + + upload(file: MetaplexFile): Promise { + return this.driver().upload(file); + } + + uploadAll(files: MetaplexFile[]): Promise { + const driver = this.driver(); + + return driver.uploadAll + ? driver.uploadAll(files) + : Promise.all(files.map((file) => this.driver().upload(file))); + } + + uploadJson(json: T): Promise { + return this.upload(useMetaplexFileFromJson(json)); + } + + async download(uri: string, options?: RequestInit): Promise { + const driver = this.driver(); + + if (driver.download) { + return driver.download(uri, options); + } + + const response = await fetch(uri, options); + const buffer = await response.arrayBuffer(); + + return useMetaplexFile(buffer, uri); + } + + async downloadJson( + uri: string, + options?: RequestInit + ): Promise { + const file = await this.download(uri, options); + + try { + return JSON.parse(file.buffer.toString()); + } catch (error) { + throw new InvalidJsonStringError(error as Error); + } + } +} diff --git a/src/plugins/storageModule/StorageDriver.ts b/src/plugins/storageModule/StorageDriver.ts new file mode 100644 index 000000000..469483fe8 --- /dev/null +++ b/src/plugins/storageModule/StorageDriver.ts @@ -0,0 +1,9 @@ +import { Amount } from '@/types'; +import { MetaplexFile } from './MetaplexFile'; + +export type StorageDriver = { + getUploadPrice: (bytes: number) => Promise; + upload: (file: MetaplexFile) => Promise; + uploadAll?: (files: MetaplexFile[]) => Promise; + download?: (uri: string, options?: RequestInit) => Promise; +}; diff --git a/src/plugins/storageModule/index.ts b/src/plugins/storageModule/index.ts new file mode 100644 index 000000000..0aea3066e --- /dev/null +++ b/src/plugins/storageModule/index.ts @@ -0,0 +1,4 @@ +export * from './MetaplexFile'; +export * from './plugin'; +export * from './StorageClient'; +export * from './StorageDriver'; diff --git a/src/plugins/storageModule/plugin.ts b/src/plugins/storageModule/plugin.ts new file mode 100644 index 000000000..11a4bb38f --- /dev/null +++ b/src/plugins/storageModule/plugin.ts @@ -0,0 +1,16 @@ +import type { Metaplex } from '@/Metaplex'; +import { MetaplexPlugin } from '@/types'; +import { StorageClient } from './StorageClient'; + +export const storageModule = (): MetaplexPlugin => ({ + install(metaplex: Metaplex) { + const storageClient = new StorageClient(); + metaplex.storage = () => storageClient; + }, +}); + +declare module '../../Metaplex' { + interface Metaplex { + storage(): StorageClient; + } +} diff --git a/src/types/Amount.ts b/src/types/Amount.ts new file mode 100644 index 000000000..38363e87e --- /dev/null +++ b/src/types/Amount.ts @@ -0,0 +1,160 @@ +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; +import BN from 'bn.js'; +import { Opaque } from '@/utils'; +import { CurrencyMismatchError } from '@/errors'; + +export type Amount = { + basisPoints: BasisPoints; + currency: Currency; +}; + +export type BasisPoints = Opaque; + +export type Currency = { + symbol: string; + decimals: number; + namespace?: 'spl-token'; +}; + +export const SOL = { + symbol: 'SOL', + decimals: 9, +}; + +export const USD = { + symbol: 'USD', + decimals: 2, +}; + +export const amount = ( + basisPoints: number | BN, + currency: Currency +): Amount => { + return { + basisPoints: toBasisPoints(basisPoints), + currency, + }; +}; + +export const lamports = (lamports: number | BN): Amount => { + return amount(lamports, SOL); +}; + +export const sol = (sol: number): Amount => { + return lamports(sol * LAMPORTS_PER_SOL); +}; + +export const usd = (usd: number): Amount => { + return amount(usd * 100, USD); +}; + +export const toBasisPoints = (value: number | BN): BasisPoints => { + return new BN(value) as BasisPoints; +}; + +export const isSol = (currencyOrAmount: Currency | Amount): boolean => { + return sameCurrencies(currencyOrAmount, SOL); +}; + +export const sameCurrencies = ( + left: Currency | Amount, + right: Currency | Amount +) => { + if ('currency' in left) { + left = left.currency; + } + + if ('currency' in right) { + right = right.currency; + } + + return ( + left.symbol === right.symbol && + left.decimals === right.decimals && + left.namespace === right.namespace + ); +}; + +export const assertSameCurrencies = ( + left: Currency | Amount, + right: Currency | Amount, + operation?: string +) => { + if ('currency' in left) { + left = left.currency; + } + + if ('currency' in right) { + right = right.currency; + } + + if (!sameCurrencies(left, right)) { + throw new CurrencyMismatchError(left, right, operation); + } +}; + +export const addAmounts = (left: Amount, right: Amount): Amount => { + assertSameCurrencies(left, right, 'add'); + + return amount(left.basisPoints.add(right.basisPoints), left.currency); +}; + +export const subtractAmounts = (left: Amount, right: Amount): Amount => { + assertSameCurrencies(left, right, 'subtract'); + + return amount(left.basisPoints.sub(right.basisPoints), left.currency); +}; + +export const multiplyAmount = (left: Amount, multiplier: number): Amount => { + return amount(left.basisPoints.muln(multiplier), left.currency); +}; + +export const divideAmount = (left: Amount, divisor: number): Amount => { + return amount(left.basisPoints.divn(divisor), left.currency); +}; + +export const compareAmounts = (left: Amount, right: Amount): -1 | 0 | 1 => { + assertSameCurrencies(left, right, 'compare'); + + return left.basisPoints.cmp(right.basisPoints); +}; + +export const isEqualToAmount = (left: Amount, right: Amount): boolean => + compareAmounts(left, right) === 0; + +export const isLessThanAmount = (left: Amount, right: Amount): boolean => + compareAmounts(left, right) < 0; + +export const isLessThanOrEqualToAmount = ( + left: Amount, + right: Amount +): boolean => compareAmounts(left, right) <= 0; + +export const isGreaterThanAmount = (left: Amount, right: Amount): boolean => + compareAmounts(left, right) > 0; + +export const isGreaterThanOrEqualToAmount = ( + left: Amount, + right: Amount +): boolean => compareAmounts(left, right) >= 0; + +export const isZeroAmount = (value: Amount): boolean => + compareAmounts(value, amount(0, value.currency)) === 0; + +export const isPositiveAmount = (value: Amount): boolean => + compareAmounts(value, amount(0, value.currency)) >= 0; + +export const isNegativeAmount = (value: Amount): boolean => + compareAmounts(value, amount(0, value.currency)) < 0; + +export const formatAmount = (value: Amount): string => { + const power = new BN(10).pow(new BN(value.currency.decimals)); + const basisPoints = value.basisPoints as unknown as BN & { + divmod: (other: BN) => { div: BN; mod: BN }; + }; + + const { div, mod } = basisPoints.divmod(power); + const units = `${div.toString()}.${mod.abs().toString()}`; + + return `${value.currency.symbol} ${units}`; +}; diff --git a/src/types/Driver.ts b/src/types/Driver.ts index d5adb48c3..4fb2b7fa1 100644 --- a/src/types/Driver.ts +++ b/src/types/Driver.ts @@ -1,5 +1,11 @@ import { Metaplex } from '@/Metaplex'; +export type HasDriver = { + driver: () => T; + setDriver: (newDriver: T) => void; +}; + +/** @deprecated */ export abstract class Driver { protected readonly metaplex: Metaplex; diff --git a/src/types/MetaplexAware.ts b/src/types/HasMetaplex.ts similarity index 60% rename from src/types/MetaplexAware.ts rename to src/types/HasMetaplex.ts index 2f7034bd9..300ab9518 100644 --- a/src/types/MetaplexAware.ts +++ b/src/types/HasMetaplex.ts @@ -1,5 +1,5 @@ import { Metaplex } from '@/Metaplex'; -export type MetaplexAware = { +export type HasMetaplex = Readonly<{ metaplex: Metaplex; -}; +}>; diff --git a/src/types/MetaplexFile.ts b/src/types/MetaplexFile.ts deleted file mode 100644 index 59555582f..000000000 --- a/src/types/MetaplexFile.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Buffer } from 'buffer'; -import { getContentType, getExtension, randomStr } from '@/utils'; -import { InvalidJsonVariableError } from '@/errors'; - -export interface MetaplexFileOptions { - displayName?: string; - uniqueName?: string; - contentType?: string; - extension?: string; - tags?: { name: string; value: string }[]; -} - -export class MetaplexFile { - public readonly buffer: Buffer; - public readonly fileName: string; - public readonly displayName: string; - public readonly uniqueName: string; - public readonly contentType: string | null; - public readonly extension: string | null; - public readonly tags: { name: string; value: string }[]; - - constructor( - content: string | Buffer | Uint8Array | ArrayBuffer, - fileName: string, - options: MetaplexFileOptions = {} - ) { - this.buffer = MetaplexFile.parseContent(content); - this.fileName = fileName; - this.displayName = options.displayName ?? fileName; - this.uniqueName = options.uniqueName ?? randomStr(); - this.contentType = options.contentType ?? getContentType(fileName); - this.extension = options.extension ?? getExtension(fileName); - this.tags = options.tags ?? []; - } - - static async fromFile(file: File, options: MetaplexFileOptions = {}) { - const buffer = await file.arrayBuffer(); - - return new this(buffer, file.name, options); - } - - static fromJson( - json: T, - fileName: string = 'inline.json', - options: MetaplexFileOptions = {} - ) { - let jsonString; - - try { - jsonString = JSON.stringify(json); - } catch (error) { - throw new InvalidJsonVariableError(error as Error); - } - - return new this(jsonString, fileName, options); - } - - protected static parseContent( - content: string | Buffer | Uint8Array | ArrayBuffer - ) { - if (content instanceof ArrayBuffer) { - return Buffer.from(new Uint8Array(content)); - } - - return Buffer.from(content); - } - - getTagsWithContentType() { - if (!this.contentType) { - return this.tags; - } - - return [{ name: 'Content-Type', value: this.contentType }, ...this.tags]; - } - - getBytes(): number { - return this.buffer.byteLength; - } - - toBuffer(): Buffer { - return this.buffer; - } - - toString(): string { - return this.buffer.toString(); - } - - toGlobalFile(): File { - return new File([this.buffer as BlobPart], this.fileName); - } -} diff --git a/src/types/StorageDriver.ts b/src/types/StorageDriver.ts deleted file mode 100644 index 4fef84460..000000000 --- a/src/types/StorageDriver.ts +++ /dev/null @@ -1,38 +0,0 @@ -import fetch from 'cross-fetch'; -import { SolAmount } from '@/utils'; -import { Driver } from './Driver'; -import { MetaplexFile } from './MetaplexFile'; - -export abstract class StorageDriver extends Driver { - public abstract getPrice(...files: MetaplexFile[]): Promise; - public abstract upload(file: MetaplexFile): Promise; - - public async uploadAll(files: MetaplexFile[]): Promise { - const promises = files.map((file) => this.upload(file)); - - return Promise.all(promises); - } - - public async uploadJson(json: T): Promise { - return this.upload(MetaplexFile.fromJson(json)); - } - - public async download( - uri: string, - options?: RequestInit - ): Promise { - const response = await fetch(uri, options); - const buffer = await response.arrayBuffer(); - - return new MetaplexFile(buffer, uri); - } - - public async downloadJson( - uri: string, - options?: RequestInit - ): Promise { - const response = await fetch(uri, options); - - return await response.json(); - } -} diff --git a/src/types/index.ts b/src/types/index.ts index c018721fc..54e15886c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,10 +1,10 @@ +export * from './Amount'; export * from './BaseAccount'; export * from './Cluster'; export * from './DateTimeString'; export * from './Driver'; export * from './IdentityDriver'; -export * from './MetaplexAware'; -export * from './MetaplexFile'; +export * from './HasMetaplex'; export * from './MetaplexPlugin'; export * from './Model'; export * from './ModuleClient'; @@ -16,4 +16,3 @@ export * from './ProgramDriver'; export * from './PublicKeyString'; export * from './RpcDriver'; export * from './Signer'; -export * from './StorageDriver'; diff --git a/src/utils/Plan.ts b/src/utils/Plan.ts index 2d7d17ed7..222fbbdc1 100644 --- a/src/utils/Plan.ts +++ b/src/utils/Plan.ts @@ -30,6 +30,7 @@ export type InputStep = Pick & type InputPlan = Pick, 'promise'> & Partial>; +/** @deprecated */ export class Plan { public readonly promise: (state: I, plan: Plan) => Promise; public readonly steps: Step[]; diff --git a/src/utils/SolAmount.ts b/src/utils/SolAmount.ts deleted file mode 100644 index a1e2a4b18..000000000 --- a/src/utils/SolAmount.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { LAMPORTS_PER_SOL } from '@solana/web3.js'; -import BN from 'bn.js'; -import BigNumber from 'bignumber.js'; - -export type SolAmountInput = - | number - | string - | Uint8Array - | Buffer - | BN - | BigNumber - | SolAmount; - -export const parseBigNumber = ( - input: number | string | Uint8Array | Buffer | BN | BigNumber -): BigNumber => { - if (input instanceof Uint8Array || input instanceof BN) { - const bn = new BN(input); - return new BigNumber(bn.toString()); - } - - return new BigNumber(input); -}; - -export class SolAmount { - protected readonly lamports: BigNumber; - - protected constructor(lamports: BigNumber) { - this.lamports = lamports.decimalPlaces(0); - } - - static fromLamports(lamports: SolAmountInput) { - if (lamports instanceof SolAmount) { - return new this(lamports.getLamports()); - } - - return new this(parseBigNumber(lamports)); - } - - static fromSol(sol: SolAmountInput) { - if (sol instanceof SolAmount) { - return new this(sol.getLamports()); - } - - const lamports = parseBigNumber(sol).multipliedBy(LAMPORTS_PER_SOL); - - return new this(lamports); - } - - static zero() { - return this.fromLamports(0); - } - - plus(other: SolAmountInput): SolAmount { - return this.execute(other, (a, b) => a.getLamports().plus(b.getLamports())); - } - - minus(other: SolAmountInput): SolAmount { - return this.execute(other, (a, b) => - a.getLamports().minus(b.getLamports()) - ); - } - - multipliedBy(other: SolAmountInput): SolAmount { - return this.execute(other, (a, b) => - a.getLamports().multipliedBy(b.getSol()) - ); - } - - dividedBy(other: SolAmountInput): SolAmount { - return this.execute(other, (a, b) => a.getLamports().dividedBy(b.getSol())); - } - - modulo(other: SolAmountInput): SolAmount { - return this.execute(other, (a, b) => - a.getLamports().modulo(b.getLamports()) - ); - } - - isEqualTo(other: SolAmountInput): boolean { - return this.lamports.isEqualTo(SolAmount.fromSol(other).getLamports()); - } - - isLessThan(other: SolAmountInput): boolean { - return this.lamports.isLessThan(SolAmount.fromSol(other).getLamports()); - } - - isLessThanOrEqualTo(other: SolAmountInput): boolean { - return this.lamports.isLessThanOrEqualTo( - SolAmount.fromSol(other).getLamports() - ); - } - - isGreaterThan(other: SolAmountInput): boolean { - return this.lamports.isGreaterThan(SolAmount.fromSol(other).getLamports()); - } - - isGreaterThanOrEqualTo(other: SolAmountInput): boolean { - return this.lamports.isGreaterThanOrEqualTo( - SolAmount.fromSol(other).getLamports() - ); - } - - isNegative(): boolean { - return this.lamports.isNegative(); - } - - isPositive(): boolean { - return this.lamports.isPositive(); - } - - isZero(): boolean { - return this.lamports.isZero(); - } - - getLamports(): BigNumber { - return this.lamports; - } - - getSol(): BigNumber { - return this.lamports.dividedBy(LAMPORTS_PER_SOL); - } - - toLamports(): string { - return this.lamports.toString(); - } - - toSol(decimals?: number, roundingMode?: BigNumber.RoundingMode): string { - if (!decimals) { - return this.getSol().toFormat(); - } - - return this.getSol().toFormat(decimals, roundingMode); - } - - toString(): string { - return this.toLamports(); - } - - protected execute( - other: SolAmountInput, - operation: (a: SolAmount, b: SolAmount) => BigNumber - ): SolAmount { - return SolAmount.fromLamports(operation(this, SolAmount.fromSol(other))); - } -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 0c0fff416..5b179b9bb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,7 +6,6 @@ export * from './GpaBuilder'; export * from './log'; export * from './Plan'; export * from './Postpone'; -export * from './SolAmount'; export * from './TransactionBuilder'; export * from './types'; export * from './useDisposable'; diff --git a/src/utils/types.ts b/src/utils/types.ts index 813172e54..5fc1f4298 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -3,3 +3,5 @@ export type Optional = Omit< K > & Partial>; + +export type Opaque = T & { __opaque__: K }; diff --git a/test/plugins/awsStorage/AwsStorageDriver.test.ts b/test/plugins/awsStorage/AwsStorageDriver.test.ts index 541b9b74a..d59992e74 100644 --- a/test/plugins/awsStorage/AwsStorageDriver.test.ts +++ b/test/plugins/awsStorage/AwsStorageDriver.test.ts @@ -2,7 +2,7 @@ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import test, { Test } from 'tape'; import sinon from 'sinon'; import { killStuckProcess, metaplex } from '../../helpers'; -import { awsStorage, MetaplexFile } from '@/index'; +import { awsStorage, useMetaplexFile } from '@/index'; killStuckProcess(); @@ -26,7 +26,7 @@ test('it can upload assets to a S3 bucket', async (t: Test) => { mx.use(awsStorage(awsClient, 'some-bucket')); // When we upload some content to AWS S3. - const file = new MetaplexFile('some-image', 'some-image.jpg', { + const file = useMetaplexFile('some-image', 'some-image.jpg', { uniqueName: 'some-key', }); const uri = await mx.storage().upload(file); diff --git a/test/plugins/nftModule/createNft.test.ts b/test/plugins/nftModule/createNft.test.ts index 92589c2a2..5f7ae552a 100644 --- a/test/plugins/nftModule/createNft.test.ts +++ b/test/plugins/nftModule/createNft.test.ts @@ -2,7 +2,7 @@ import test, { Test } from 'tape'; import spok, { Specifications } from 'spok'; import { Keypair } from '@solana/web3.js'; import { UseMethod } from '@metaplex-foundation/mpl-token-metadata'; -import { JsonMetadata, MetaplexFile, Nft } from '@/index'; +import { JsonMetadata, useMetaplexFile, Nft } from '@/index'; import { metaplex, spokSamePubkey, @@ -18,7 +18,7 @@ test('it can create an NFT with minimum configuration', async (t: Test) => { const mx = await metaplex(); // And we uploaded an image. - const imageFile = new MetaplexFile('some_image', 'some-image.jpg'); + const imageFile = useMetaplexFile('some_image', 'some-image.jpg'); const imageUri = await mx.storage().upload(imageFile); // And we uploaded some metadata containing this image. @@ -73,7 +73,7 @@ test('it can create an NFT with maximum configuration', async (t: Test) => { const { uri, metadata } = await mx.nfts().uploadMetadata({ name: 'JSON NFT name', description: 'JSON NFT description', - image: new MetaplexFile('some_image', 'some-image.jpg'), + image: useMetaplexFile('some_image', 'some-image.jpg'), }); // And a various keypairs for different access. @@ -171,7 +171,7 @@ test('it fill missing on-chain data from the JSON metadata', async (t: Test) => name: 'JSON NFT name', symbol: 'MYNFT', description: 'JSON NFT description', - image: new MetaplexFile('some_image', 'some-image.jpg'), + image: useMetaplexFile('some_image', 'some-image.jpg'), seller_fee_basis_points: 456, properties: { creators: [ diff --git a/test/plugins/nftModule/updateNft.test.ts b/test/plugins/nftModule/updateNft.test.ts index 79f64a67e..73b010978 100644 --- a/test/plugins/nftModule/updateNft.test.ts +++ b/test/plugins/nftModule/updateNft.test.ts @@ -1,6 +1,6 @@ import test, { Test } from 'tape'; import spok, { Specifications } from 'spok'; -import { Nft, MetaplexFile } from '@/index'; +import { Nft, useMetaplexFile } from '@/index'; import { metaplex, createNft, killStuckProcess } from '../../helpers'; killStuckProcess(); @@ -15,7 +15,7 @@ test('it can update the on-chain data of an nft', async (t: Test) => { { name: 'JSON NFT name', description: 'JSON NFT description', - image: new MetaplexFile('some image', 'some-image.jpg'), + image: useMetaplexFile('some image', 'some-image.jpg'), }, { name: 'On-chain NFT name', @@ -29,7 +29,7 @@ test('it can update the on-chain data of an nft', async (t: Test) => { .uploadMetadata({ name: 'Updated JSON NFT name', description: 'Updated JSON NFT description', - image: new MetaplexFile('updated image', 'updated-image.jpg'), + image: useMetaplexFile('updated image', 'updated-image.jpg'), }); // When we update the NFT with new on-chain data. diff --git a/test/types/Amount.test.ts b/test/types/Amount.test.ts new file mode 100644 index 000000000..a378d233e --- /dev/null +++ b/test/types/Amount.test.ts @@ -0,0 +1,146 @@ +import test, { Test } from 'tape'; +import { + Amount, + amount, + formatAmount, + addAmounts, + subtractAmounts, + multiplyAmount, + divideAmount, + isEqualToAmount, + isLessThanAmount, + isLessThanOrEqualToAmount, + isGreaterThanAmount, + isGreaterThanOrEqualToAmount, + isZeroAmount, + isPositiveAmount, + isNegativeAmount, + usd, + sol, + lamports, + USD, + SOL, + CurrencyMismatchError, +} from '@/index'; + +test('Amount: it can create amounts from any currencies', (t: Test) => { + const usdAmount = amount(1500, { symbol: 'USD', decimals: 2 }); + const gbpAmount = amount(4200, { symbol: 'GBP', decimals: 2 }); + + t.equal(usdAmount.basisPoints.toNumber(), 1500); + t.equal(usdAmount.currency.symbol, 'USD'); + t.equal(gbpAmount.basisPoints.toNumber(), 4200); + t.equal(gbpAmount.currency.symbol, 'GBP'); + t.end(); +}); + +test('Amount: it can be formatted', (t: Test) => { + const usdAmount = amount(1536, { symbol: 'USD', decimals: 2 }); + const gbpAmount = amount(4210, { symbol: 'GBP', decimals: 2 }); + const solAmount = amount(2_500_000_000, { symbol: 'SOL', decimals: 9 }); + + t.equal(formatAmount(usdAmount), 'USD 15.36'); + t.equal(formatAmount(gbpAmount), 'GBP 42.10'); + t.equal(formatAmount(solAmount), 'SOL 2.500000000'); + t.end(); +}); + +test('Amount: it has helpers for certain currencies', (t: Test) => { + amountEquals(t, usd(15.36), 'USD 15.36'); + amountEquals(t, usd(15.36), 'USD 15.36'); + amountEquals(t, amount(1536, USD), 'USD 15.36'); + amountEquals(t, sol(2.5), 'SOL 2.500000000'); + amountEquals(t, lamports(2_500_000_000), 'SOL 2.500000000'); + amountEquals(t, amount(2_500_000_000, SOL), 'SOL 2.500000000'); + t.end(); +}); + +test('Amount: it can add and subtract amounts together', (t: Test) => { + const a = sol(1.5); + const b = lamports(4200000000); // 4.2 SOL + + amountEquals(t, addAmounts(a, b), 'SOL 5.700000000'); + amountEquals(t, addAmounts(b, a), 'SOL 5.700000000'); + amountEquals(t, addAmounts(a, sol(1)), 'SOL 2.500000000'); + + amountEquals(t, subtractAmounts(a, b), 'SOL -2.700000000'); + amountEquals(t, subtractAmounts(b, a), 'SOL 2.700000000'); + amountEquals(t, subtractAmounts(a, sol(1)), 'SOL 0.500000000'); + t.end(); +}); + +test('Amount: it fail to operate on amounts of different currencies', (t: Test) => { + try { + addAmounts(sol(1), usd(1)); + t.fail(); + } catch (error) { + t.true(error instanceof CurrencyMismatchError); + const customError = error as CurrencyMismatchError; + t.equal(customError.left, SOL); + t.equal(customError.right, USD); + t.equal(customError.operation, 'add'); + t.end(); + } +}); + +test('Amount: it can multiply and divide amounts', (t: Test) => { + amountEquals(t, multiplyAmount(sol(1.5), 3), 'SOL 4.500000000'); + amountEquals(t, multiplyAmount(sol(1.5), 3.78), 'SOL 5.659262581'); + amountEquals(t, multiplyAmount(sol(1.5), -1), 'SOL -1.500000000'); + + amountEquals(t, divideAmount(sol(1.5), 3), 'SOL 0.500000000'); + amountEquals(t, divideAmount(sol(1.5), 9), 'SOL 0.166666666'); + amountEquals(t, divideAmount(sol(1.5), -1), 'SOL -1.500000000'); + t.end(); +}); + +test('Amount: it can compare amounts together', (t: Test) => { + const a = sol(1.5); + const b = lamports(4200000000); // 4.2 SOL + + t.false(isEqualToAmount(a, b)); + t.true(isEqualToAmount(a, sol(1.5))); + + t.true(isLessThanAmount(a, b)); + t.false(isLessThanAmount(b, a)); + t.false(isLessThanAmount(a, sol(1.5))); + t.true(isLessThanOrEqualToAmount(a, b)); + t.true(isLessThanOrEqualToAmount(a, sol(1.5))); + + t.false(isGreaterThanAmount(a, b)); + t.true(isGreaterThanAmount(b, a)); + t.false(isGreaterThanAmount(a, sol(1.5))); + t.false(isGreaterThanOrEqualToAmount(a, b)); + t.true(isGreaterThanOrEqualToAmount(a, sol(1.5))); + + t.true(isPositiveAmount(a)); + t.false(isNegativeAmount(a)); + t.false(isZeroAmount(a)); + + t.true(isPositiveAmount(sol(0))); + t.false(isNegativeAmount(sol(0))); + t.true(isZeroAmount(sol(0))); + + t.false(isPositiveAmount(sol(-1))); + t.true(isNegativeAmount(sol(-1))); + t.false(isZeroAmount(sol(-1))); + + t.end(); +}); + +test('it returns a new instance when running operations', (t: Test) => { + const a = sol(1.5); + const b = lamports(4200000000); // 4.2 SOL + + t.notEqual(a, addAmounts(a, b)); + t.notEqual(b, addAmounts(a, b)); + t.notEqual(a, subtractAmounts(a, b)); + t.notEqual(b, subtractAmounts(a, b)); + t.notEqual(a, multiplyAmount(a, 3)); + t.notEqual(a, divideAmount(a, 3)); + t.end(); +}); + +const amountEquals = (t: Test, amount: Amount, expected: string) => { + t.equal(formatAmount(amount), expected); +}; diff --git a/test/utils/SolAmount.test.ts b/test/utils/SolAmount.test.ts deleted file mode 100644 index 7e613e040..000000000 --- a/test/utils/SolAmount.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import BigNumber from 'bignumber.js'; -import test, { Test } from 'tape'; -import { SolAmount } from '@/index'; - -test('it can create a SolAmount from lamports', (t: Test) => { - t.equal(SolAmount.fromLamports(0).getLamports().toNumber(), 0); - t.equal(SolAmount.fromLamports(1).getLamports().toNumber(), 1); - t.equal(SolAmount.fromLamports(42).getLamports().toNumber(), 42); - t.end(); -}); - -test('it can create a SolAmount from SOLs', (t: Test) => { - t.equal(SolAmount.fromSol(0).getLamports().toNumber(), 0); - t.equal(SolAmount.fromSol(1).getLamports().toNumber(), 1000000000); - t.equal(SolAmount.fromSol(1.5).getLamports().toNumber(), 1500000000); - t.equal(SolAmount.fromSol(42).getLamports().toNumber(), 42000000000); - t.end(); -}); - -test('it can return the lamports and SOLs as a BigNumber', (t: Test) => { - t.equal(SolAmount.fromSol(1.5).getLamports().toNumber(), 1500000000); - t.equal(SolAmount.fromSol(1.5).getSol().toNumber(), 1.5); - t.end(); -}); - -test('it can return the lamports and SOLs as a formatted strings', (t: Test) => { - t.equal(SolAmount.fromSol(1.5).toLamports(), '1500000000'); - t.equal(SolAmount.fromSol(1.5).toSol(), '1.5'); - t.equal(SolAmount.fromSol(1.5).toSol(2), '1.50'); - t.equal(SolAmount.fromSol(1.5).toSol(9), '1.500000000'); - t.equal(SolAmount.fromSol(1.499).toSol(2), '1.50'); - t.equal(SolAmount.fromSol(1.499).toSol(2, BigNumber.ROUND_FLOOR), '1.49'); - t.end(); -}); - -test('it returns the lamports when parsing as a string', (t: Test) => { - t.equal(SolAmount.fromSol(0).toString(), '0'); - t.equal(SolAmount.fromSol(1).toString(), '1000000000'); - t.equal(SolAmount.fromSol(1.5).toString(), '1500000000'); - t.equal(SolAmount.fromLamports(42).toString(), '42'); - t.end(); -}); - -test('it can add and subtract SolAmounts together', (t: Test) => { - const a = SolAmount.fromSol(1.5); - const b = SolAmount.fromLamports(4200000000); // 4.2 SOL - - t.equal(a.plus(b).toSol(), '5.7'); - t.equal(b.plus(a).toSol(), '5.7'); - t.equal(a.plus(1).toSol(), '2.5'); // Scalar assumes SOL. - - t.equal(a.minus(b).toSol(), '-2.7'); - t.equal(b.minus(a).toSol(), '2.7'); - t.equal(a.minus(1).toSol(), '0.5'); // Scalar assumes SOL. - t.end(); -}); - -test('it can multiply and divide SolAmounts together', (t: Test) => { - const a = SolAmount.fromSol(1.5); - const b = SolAmount.fromLamports(4200000000); // 4.2 SOL - - t.equal(a.multipliedBy(b).toSol(), '6.3'); - t.equal(b.multipliedBy(a).toSol(), '6.3'); - t.equal(a.multipliedBy(5).toSol(), '7.5'); - - t.equal(a.dividedBy(b).toSol(3), '0.357'); - t.equal(b.dividedBy(a).toSol(), '2.8'); - t.equal(a.dividedBy(5).toSol(), '0.3'); - t.end(); -}); - -test('it can find the modulo of a SolAmount', (t: Test) => { - t.equal(SolAmount.fromSol(42).modulo(10).toSol(), '2'); - t.equal(SolAmount.fromSol(54).modulo(7).toSol(), '5'); - t.end(); -}); - -test('it can compare SolAmounts together', (t: Test) => { - const a = SolAmount.fromSol(1.5); - const b = SolAmount.fromLamports(4200000000); // 4.2 SOL - - t.true(a.isLessThan(b)); - t.false(b.isLessThan(a)); - t.false(a.isLessThan(1.5)); - - t.true(a.isLessThanOrEqualTo(b)); - t.true(a.isLessThanOrEqualTo(1.5)); - - t.false(a.isGreaterThan(b)); - t.true(b.isGreaterThan(a)); - t.false(a.isGreaterThan(1.5)); - - t.false(a.isGreaterThanOrEqualTo(b)); - t.true(a.isGreaterThanOrEqualTo(1.5)); - - t.false(a.isEqualTo(b)); - t.true(a.isEqualTo(1.5)); - - t.true(a.isPositive()); - t.false(a.isNegative()); - t.false(a.isZero()); - - t.true(SolAmount.fromSol(0).isPositive()); - t.false(SolAmount.fromSol(0).isNegative()); - t.true(SolAmount.fromSol(0).isZero()); - - t.false(SolAmount.fromSol(-1).isPositive()); - t.true(SolAmount.fromSol(-1).isNegative()); - t.false(SolAmount.fromSol(-1).isZero()); - - t.end(); -}); - -test('it returns a new instance when running operations', (t: Test) => { - const a = SolAmount.fromSol(1.5); - const b = SolAmount.fromLamports(4200000000); // 4.2 SOL - - t.notEqual(a, a.plus(b)); - t.notEqual(b, a.plus(b)); - t.notEqual(a, a.minus(b)); - t.notEqual(b, a.minus(b)); - t.notEqual(a, a.multipliedBy(b)); - t.notEqual(b, a.multipliedBy(b)); - t.notEqual(a, a.dividedBy(b)); - t.notEqual(b, a.dividedBy(b)); - t.end(); -});