diff --git a/.github/workflows/cli-pull-request.yml b/.github/workflows/cli-pull-request.yml new file mode 100644 index 0000000..ba03215 --- /dev/null +++ b/.github/workflows/cli-pull-request.yml @@ -0,0 +1,34 @@ +name: Pull Request (CLI) + +on: + pull_request: + paths: + - js/packages/cli/* + push: + branches: + - master + paths: + - js/packages/cli/* + +jobs: + unit_tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + + - uses: actions/setup-node@v2 + with: + node-version: "14" + + - uses: actions/cache@v2 + with: + path: "**/node_modules" + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + + - name: Install modules + run: yarn install + working-directory: js/packages/cli + + - name: Run Tests + run: yarn test + working-directory: js/packages/cli diff --git a/.gitignore b/.gitignore index 34d55fb..98381aa 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ hfuzz_workspace **/*.so **/.DS_Store .cache +js/packages/web/.env diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 172217c..10020f7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,7 +14,7 @@ This is the bedrock contract of the entire ecosystem. All that you need to inter Furthermore, if your mint has one token in its supply, you can give it an additional decoration PDA, of type MasterEdition. This PDA denotes the mint as a special type of object that can mint other mints - which we call Editions (as opposed to MasterEditions because they can't print other mints themselves). This makes this mint like the "master records" that record studios used to use to make new copies of records back in the day. The MasterEdition PDA will take away minting and freezing authority from you in the process and will contain information about total supply, maximum possible supply, etc. -The existence of Metadata and its sister PDA MasterEdition makes a very powerful combination for a mint that enables the entire rest of the Metaplex contract stack. Now you can create: +The existence of Metadata and its sister PDA MasterEdition makes a very powerful combination for a mint that enables the entire rest of the Metaplex contract stack. Now you can create: - Normal mints that just have names (Metadata but no MasterEdition) - One of a kind NFTs (Metadata + MasterEdition with `max_supply` of 0) @@ -35,7 +35,7 @@ When there are outstanding shares, you cannot, as the vault owner, **Combine** t ### Auction -The Auction Contract represents an auction primitive, and it knows nothing about NFTs, or Metadata, or anything else in the Metaplex ecosystem. All it cares about is that it has a resource address, it has auction mechanics, and it is using those auction mechanics to auction off that resource. It currently supports English Auctions and Open Edition Auctions (no winners but bids are tracked.) Its only purpose is to track who won what place in an auction and to collect money for those wins. When you place bids, or cancel them, you are interacting with this contract. However, when you redeem bids, you are not interacting with this contract, but Metaplex, because while it can provide proof that you did indeed win 4th place, it has no opinion on how the resource being auctioned off is divvied up between 1st, 2nd, 3rd, and 4th place winners, for example. +The Auction Contract represents an auction primitive, and it knows nothing about NFTs, or Metadata, or anything else in the Metaplex ecosystem. All it cares about is that it has a resource address, it has auction mechanics, and it is using those auction mechanics to auction off that resource. It currently supports English Auctions and Open Edition Auctions (no winners but bids are tracked.) Its only purpose is to track who won what place in an auction and to collect money for those wins. When you place bids, or cancel them, you are interacting with this contract. However, when you redeem bids, you are not interacting with this contract, but Metaplex, because while it can provide proof that you did indeed win 4th place, it has no opinion on how the resource being auctioned off is divvied up between 1st, 2nd, 3rd, and 4th place winners, for example. This contract will be expanded in the future to include other auction types, and better guarantees between that the auctioneer claiming the bid actually has provided the prize by having the winner sign a PDA saying that they received the prize. Right now this primitive contract should *not* be used in isolation, but in companionship with another contract (like Metaplex in our case) that makes such guarantees that prizes are delivered if prizes are won. @@ -48,7 +48,7 @@ This is the granddaddy contract of them all. The primary product of the Metaplex - Full Rights Transfers (Giving away token + metadata ownership) - Single Token Transfers (Giving away a token but not metadata ownership) -It orchestrates disbursements of those contents to winners of an auction. An AuctionManager requires both a Vault and an Auction to run, and it requires that the Auction's resource key be set to the Vault. +It orchestrates disbursements of those contents to winners of an auction. An AuctionManager requires both a Vault and an Auction to run, and it requires that the Auction's resource key be set to the Vault. Due to each type of NFT transfer above requiring slightly different nuanced handling and checking, Metaplex handles knowing about those things, and making the different CPI calls to the Token Metadata contract to make those things happen as required during the redemption phase. It also has full authority over all the objects like Vault and Auction, and handles all royalties payments by collecting funds from the auction into its own central escrow account and then disbursing to artists. @@ -98,11 +98,11 @@ Get ready and grab some aspirin. Here we go! ### Overview -The Token Metadata contract can be used for storing generic metadata about any given mint, whether NFT or not. Metadata allows storage of name, symbol, and URI to an external resource. Additionally, the Metadata allows for the tracking of creators, primary sales, and seller fees. Once the mint has been created, the mint authority can use the SPL Metadata program to create metadata as described in this document. +The Token Metadata contract can be used for storing generic metadata about any given mint, whether NFT or not. Metadata allows storage of name, symbol, and URI to an external resource. Additionally, the Metadata allows for the tracking of creators, primary sales, and seller fees. Once the mint has been created, the mint authority can use the SPL Metadata program to create metadata as described in this document. Minting an NFT requires creating a new SPL Mint with the supply of one and decimals zero as described [https://spl.solana.com/token#example-create-a-non-fungible-token](https://spl.solana.com/token#example-create-a-non-fungible-token) -Below is the Rust representation of the structs that are stored on-chain. +Below is the Rust representation of the structs that are stored on-chain. ```rust @@ -200,7 +200,7 @@ Master Edition accounts are PDA addresses of `['metaplex', metaplex_program_id, An edition represents a copy of an NFT, and is created from a Master Edition. Each print has an edition number associated with it. Normally, prints can be created during Open Edition or Limited Edition auction, but they could also be created by the creator manually. -Editions are created by presenting the Master Edition token, along with a new mint that lacks a Metadata account and a token account containing one token from that mint to the `mint_new_edition_from_master_edition_via_token` endpoint. This endpoint will create both an immutable Metadata based on the parent Metadata and a special Edition struct based on the parent Master Edition struct. +Editions are created by presenting the Master Edition token, along with a new mint that lacks a Metadata account and a token account containing one token from that mint to the `mint_new_edition_from_master_edition_via_token` endpoint. This endpoint will create both an immutable Metadata based on the parent Metadata and a special Edition struct based on the parent Master Edition struct. The Edition has the same PDA as a Master Edition to force collision and prevent a user from having a mint with both, `['metaplex', metaplex_program_id, mint_id, 'edition']`. @@ -208,7 +208,7 @@ The Edition has the same PDA as a Master Edition to force collision and prevent ### Decoration as PDA Extensions -The whole idea of the Token Metadata program is to be a decorator to a Token Mint. Each struct acts as further decoration. The Metadata struct gives a mint a name and a symbol and points to some external URI that can be anything. The Master Edition gives it printing capabilities. The Edition labels it as a child of something. +The whole idea of the Token Metadata program is to be a decorator to a Token Mint. Each struct acts as further decoration. The Metadata struct gives a mint a name and a symbol and points to some external URI that can be anything. The Master Edition gives it printing capabilities. The Edition labels it as a child of something. This is important to internalize, because it means you as a Rust developer can take it a step further. There is nothing stopping you from building a new contract on top of ours that makes it's own PDAs and and extending this still further. Why not build a CookingRecipes PDA, that has seed `['your-app', your_program_id, mint_id, 'recipes']`? You can require that a Metadata PDA from our contract exists to make a PDA in your program, and then you can further decorate mints on top of our decorations. The idea is to compose mints with further information than they ever had before, and then build clients that can consume that information in new and interesting ways. @@ -245,7 +245,7 @@ The URI resource is compatible with [ERC-1155 JSON Schema](https://github.com/et }, "seller_fee_basis_points": { "type": "number", - + }, "properties": { "type": "object", @@ -376,7 +376,7 @@ Safety Deposit Boxes always have PDA addresses of type `['vault', vault_key, min ### External Price Account -The External Price Account is meant to be used as an external oracle. It is provided to a Vault on initialization and doesn't need to be owned or controlled by the vault authority (though it can be.) It can provide data on the `price_per_share` of fractional shares, whether or not the vault authority is currently allowed to **Combine** the vault and reclaim the contents, and what the `price_mint` of the vault is. +The External Price Account is meant to be used as an external oracle. It is provided to a Vault on initialization and doesn't need to be owned or controlled by the vault authority (though it can be.) It can provide data on the `price_per_share` of fractional shares, whether or not the vault authority is currently allowed to **Combine** the vault and reclaim the contents, and what the `price_mint` of the vault is. ExternalPriceAccounts do not have PDA addresses. @@ -456,6 +456,8 @@ pub struct AuctionDataExtended { pub tick_size: Option, /// gap_tick_size_percentage - two decimal points pub gap_tick_size_percentage: Option, + /// auction name + pub name: Option<[u8; 32]>, } /// Define valid auction state transitions. @@ -543,11 +545,11 @@ AuctionData accounts always have PDA addresses of `['auction', auction_program_i ### Bid State -Bid State is technically not a top level struct, but an embedded one within AuctionData. I thought it was good to give it a section anyway because it's a complex little beast. It's actually an enum that holds a bid vector and a maximum size denoting how many of those bids are actually "valid winners" vs just placeholders. +Bid State is technically not a top level struct, but an embedded one within AuctionData. I thought it was good to give it a section anyway because it's a complex little beast. It's actually an enum that holds a bid vector and a maximum size denoting how many of those bids are actually "valid winners" vs just placeholders. It's reversed, which is to say that the number one winner is always at the end of the vec. It's also always bigger generally than the number of winners so that if a bid is cancelled, we have some people who got bumped out of top spots that can be moved back into them without having to cancel and replace their bids. When a bid is placed, it is inserted in the proper position based on it's amount and then the lowest bidder is bumped off the 0th position of the vec if the vec is at max size, so the vec remains sorted at all times. -In the case of open edition, the max is always zero, ie there are never any winners, and we are just accepting bids and creating BidderMetadata tickets and BidderPots to accept payment for (probably) fixed price Participation NFTs. +In the case of open edition, the max is always zero, ie there are never any winners, and we are just accepting bids and creating BidderMetadata tickets and BidderPots to accept payment for (probably) fixed price Participation NFTs. We would prefer that OpenEdition enum have no bid vector and no max, but unfortunately borsh-js does not support enums with different internal data structures, so all data structures in an enum must be identical (even if unused.) Keep that in mind when designing your own end to end borsh implementations! @@ -567,7 +569,7 @@ BidderPot always has a PDA of `['auction', auction_program_id, auction_id, bidde If you've read this far, you now get to witness my personal shame. So as it turns out, if you build a complex enough program with enough structs flying around, there is some kind of weird interaction in the Metaplex contract that causes it to blow out with an access violation if you add more than a certain number of keys to one particular struct (AuctionData), and *only* during the redemption endpoint calls. We were unable to discern why this was across 3 days of debugging. We had a theory it was due to some issue with borsh but it is not 100% certain, as we're not experts with that library's internals. -Instead, our work-around was to introduce AuctionDataExtended to add new fields that we needed to AuctionData without breaking this hidden bug that seems to exist. What is odd about the whole thing is adding fields to *other* structs doesn't cause any issues. In the future I'd love to have someone who knows way more than me about these subjects weigh in and tell me what I did wrong here to resolve this split-brain problem! We also don't have reverse lookup capability (Resource key on AuctionData) because of this bug - adding it would cause the blow out I mentioned. +Instead, our work-around was to introduce AuctionDataExtended to add new fields that we needed to AuctionData without breaking this hidden bug that seems to exist. What is odd about the whole thing is adding fields to *other* structs doesn't cause any issues. In the future I'd love to have someone who knows way more than me about these subjects weigh in and tell me what I did wrong here to resolve this split-brain problem! We also don't have reverse lookup capability (Resource key on AuctionData) because of this bug - adding it would cause the blow out I mentioned. Another note here is `gap_tick_size_percentage` as of the time of this writing has not been implemented yet, it is just a dummy field. @@ -860,7 +862,7 @@ The instruction set for metaplex can be found here: [https://github.com/metaplex ### AuctionManager -This is the top level struct of the entire contract and serves as a container for "all the things." When you make auctions on Metaplex, you are actually really making these ultimately. An AuctionManager has a single authority (you, the auctioneer), a store, which is the storefront struct, an Auction from the auction contract, and a Vault from the vault contract. It also has a token account called `accept_payment` that serves as a central clearing escrow for all tokens that it will collect in the future from the winning bidders and all payments for fixed price participation nfts from all non-winners in the auction. +This is the top level struct of the entire contract and serves as a container for "all the things." When you make auctions on Metaplex, you are actually really making these ultimately. An AuctionManager has a single authority (you, the auctioneer), a store, which is the storefront struct, an Auction from the auction contract, and a Vault from the vault contract. It also has a token account called `accept_payment` that serves as a central clearing escrow for all tokens that it will collect in the future from the winning bidders and all payments for fixed price participation nfts from all non-winners in the auction. It contains embedded within it a separate `state` and `settings` struct. It is seeded with the `settings` on initialization by the caller, while the `state` is derived from `settings` on initialization. AuctionManager goes through several states: @@ -872,7 +874,7 @@ It contains embedded within it a separate `state` and `settings` struct. It is s **Disbursing**: The underlying Auction is over and now the AuctionManager is in the business of disbursing royalties to the auctioneer and creators, prizes and participation NFTs to the winners, and possibly participation NFTs to the non-winners. -**Finished:** All funds and prizes disbursed. +**Finished:** All funds and prizes disbursed. This state is not currently in use as switching to it requires an iteration over prizes to review all items for claimed-ness and this costs CPU that is too precious during the redemption call OR adding new endpoint that is not guaranteed to be called. We will revisit it later to bring it back during a refactoring, for now it is considered a NOOP state. @@ -882,7 +884,7 @@ AuctionManagers always have PDAs of seed `['metaplex', metaplex_program_id, auct AuctionManagerSettings is an embedded struct inside AuctionManager but is deserving of it's own section. This struct is actually provided by the user in the `init_auction_manager` call to parameterize the AuctionManager with who is winning what and whether or not there is a participation NFT. It is fairly straightforward - for each entry in the WinningConfig vec, it stands for a given winning place in the Auction. The 0th entry is the WinningConfig for the 1st place winner. A WinningConfig has many WinningConfigItems. For each WinningConfigItem in the 0th WinningConfig, it is a mapping to a Vault SafetyDepositBox that the 1st place winner gets items from. You can therefore configure quite arbitrary Auctions this way. -This setup is actually quite redundant and will likely change in the future to a setup where a WinningConfigItem is the top level structure and it simply declares which winners will receive it, because if you wish for multiple winners to receive prints from the same Master Edition, the WinningConfigItem must right now be duplicated across each WinningConfig. +This setup is actually quite redundant and will likely change in the future to a setup where a WinningConfigItem is the top level structure and it simply declares which winners will receive it, because if you wish for multiple winners to receive prints from the same Master Edition, the WinningConfigItem must right now be duplicated across each WinningConfig. The Participation Config is optional, but has enums describing how it will behave for winners and for non-winners, whether or not it has a price associated with it, and what safety deposit box contains its printing tokens. @@ -902,7 +904,7 @@ BidRedemptionTickets always have PDAs of `['metaplex', auction_id, bidder_metada ### PayoutTicket -For each creator, for each metadata(WinningConfigItem), for each winning place(WinningConfig) in an Auction, a PayoutTicket is created to record the sliver of income generated for that creator. There is also one made for the Auctioneer for every such case. And yes, it really is that specific. This means that a given creator may have quite a few PayoutTickets for a single AuctionManager, but each one represents a slightly different royalty payout. +For each creator, for each metadata(WinningConfigItem), for each winning place(WinningConfig) in an Auction, a PayoutTicket is created to record the sliver of income generated for that creator. There is also one made for the Auctioneer for every such case. And yes, it really is that specific. This means that a given creator may have quite a few PayoutTickets for a single AuctionManager, but each one represents a slightly different royalty payout. For instance, 1st place may have three items with 3 unique metadata won while 2nd place may have 4 metadata from 4 items, every item with a single unique creator. The split of funds in the 1st place is going to be 3 ways, while in 2nd place would be 4 ways. Even if 1st and 2nd place bids are the same, we want two records to reflect the royalties paid from 1st and 2nd place, because they would be different numbers in this case, and we want to preserve history. @@ -958,9 +960,9 @@ Note that owning the token itself is the *only* requirement for using the `updat Metadata come locked and stocked with arrays of creators, each with their own `share` and all guaranteed to sum to 100. The Metadata itself has a `seller_fee_basis_points` field that represents the share creators get out of the proceeds in any secondary sale and a `primary_sale_happened` boolean that distinguishes to the world whether or not this particular Metadata has experienced it's first sale or not. With all of this, Metaplex is able to do complete Royalty calculations after an Auction is over. It was mentioned above that on initialization, the Metaplex contract snapshots for each Metadata being sold the `primary_sale_happened` just in case the boolean is flipped during the auction so that royalties are calculated as-of initiation - this is important to note. -At the end of the auction, anybody (permissionless) can cycle through each winning bid in the contract and ask the Metaplex contract to use its authority to call the Auction contract and pump the winning bid monies into the `accept_payment` escrow account via `claim_bid`. Once all winning bids have been settled into here, royalties are eligible to be paid out. We'll cover payouts of fixed price Participation NFTs separately. +At the end of the auction, anybody (permissionless) can cycle through each winning bid in the contract and ask the Metaplex contract to use its authority to call the Auction contract and pump the winning bid monies into the `accept_payment` escrow account via `claim_bid`. Once all winning bids have been settled into here, royalties are eligible to be paid out. We'll cover payouts of fixed price Participation NFTs separately. -Now, anybody (permissionless) can cycle through each creator PLUS the auctioneer on each item in each winning bid and call `empty_payment_account` with an Associated Token Account that is owned by that creator or auctioneer and that action will calculate, using the creator's share or auctioneer's share of that item's metadata, and the fractional percentage of that item of the overall winning basket, to payout the creator or auctioneer from the escrow. +Now, anybody (permissionless) can cycle through each creator PLUS the auctioneer on each item in each winning bid and call `empty_payment_account` with an Associated Token Account that is owned by that creator or auctioneer and that action will calculate, using the creator's share or auctioneer's share of that item's metadata, and the fractional percentage of that item of the overall winning basket, to payout the creator or auctioneer from the escrow. Our front end implementation immediately calls the `update_primary_sale_happened` endpoint on token metadata for any token once redeemed for users so that if they re-sell, the `primary_sale_happened` boolean is taken into account in the `empty_payment_account` logic and only the basis points given in `seller_fee_basis_points` goes to the creators instead of the whole pie. The remaining part of the pie goes to the auctioneer doing the reselling. @@ -972,9 +974,9 @@ Note because our front end implementation chooses to use SOL instead of a generi ### Validation -Just because you provide a vault to an AuctionManager and an AuctionManagerSettings declaring this vault is filled with wonderful prizes *does not* believe that Metaplex will believe you. For every safety deposit box indexed in a WinningConfigItem, there must be a call to `validate_safety_deposit_box` after initiation where the safety deposit box is provided for inspection to the Metaplex contract so that it can verify that there are enough tokens, and of the right type, to pay off all winners in the auction. +Just because you provide a vault to an AuctionManager and an AuctionManagerSettings declaring this vault is filled with wonderful prizes *does not* believe that Metaplex will believe you. For every safety deposit box indexed in a WinningConfigItem, there must be a call to `validate_safety_deposit_box` after initiation where the safety deposit box is provided for inspection to the Metaplex contract so that it can verify that there are enough tokens, and of the right type, to pay off all winners in the auction. -Given how irritating this process is, we may in the future merge token-vault with metaplex, or simply copy over the parts of it that are relevant, leaving token-vault out for those interested in experimenting with fractionalization. +Given how irritating this process is, we may in the future merge token-vault with metaplex, or simply copy over the parts of it that are relevant, leaving token-vault out for those interested in experimenting with fractionalization. ### Unwon Items diff --git a/js/README.md b/js/README.md index 24cf240..efa1136 100644 --- a/js/README.md +++ b/js/README.md @@ -1,6 +1,6 @@ ## Setup -Be sure to be running Node v12.16.2 and yarn version 1.22.10. +Be sure to be running Node v14.17.6 and yarn version 1.22.10. `yarn bootstrap` diff --git a/js/package.json b/js/package.json index b8eba9a..930d743 100644 --- a/js/package.json +++ b/js/package.json @@ -8,7 +8,7 @@ "keywords": [], "license": "Apache-2.0", "engines": { - "node": ">=6.0.0" + "node": "~14.17" }, "scripts": { "bootstrap": "lerna link && lerna bootstrap", @@ -29,6 +29,9 @@ "eslint --cache --fix --max-warnings=0" ] }, + "resolutions": { + "@types/react": "^17.0.16" + }, "husky": { "hooks": { "pre-commit": "lint-staged" @@ -59,11 +62,11 @@ "@typescript-eslint/eslint-plugin": "^4.6.0", "@typescript-eslint/parser": "^4.6.0", "eslint-plugin-react": "^7.25.1", - "eslint": "^6.6.0", + "eslint": "^7.11.0", "eslint-config-prettier": "^6.15.0", "gh-pages": "^3.1.0", "husky": "^4.3.0", - "jest": "24.9.0", + "jest": "26.6.0", "jest-config": "24.9.0", "lerna": "3.22.1", "lint-staged": "^10.5.0", diff --git a/js/packages/cli/.nvmrc b/js/packages/cli/.nvmrc new file mode 100644 index 0000000..62df50f --- /dev/null +++ b/js/packages/cli/.nvmrc @@ -0,0 +1 @@ +14.17.0 diff --git a/js/packages/cli/README.md b/js/packages/cli/README.md index a419665..d91c3c8 100644 --- a/js/packages/cli/README.md +++ b/js/packages/cli/README.md @@ -91,10 +91,10 @@ metaplex create_candy_machine -k ~/.config/solana/id.json -p 1 ts-node cli create_candy_machine -k ~/.config/solana/id.json -p 3 ``` -4. Set the start date for your candy machine. +4. Set the start date and update the price of your candy machine. ``` -metaplex set_start_date -k ~/.config/solana/id.json -d "20 Apr 2021 04:20:00 GMT" -ts-node cli set_start_date -k ~/.config/solana/id.json -d "20 Apr 2021 04:20:00 GMT" +metaplex update_candy_machine -k ~/.config/solana/id.json -d "20 Apr 2021 04:20:00 GMT" -p 0.1 +ts-node cli update_candy_machine -k ~/.config/solana/id.json -d "20 Apr 2021 04:20:00 GMT" -p 0.1 ``` 5. Test mint a token (provided it's after the start date) diff --git a/js/packages/cli/package.json b/js/packages/cli/package.json index ce4f576..b9217fa 100644 --- a/js/packages/cli/package.json +++ b/js/packages/cli/package.json @@ -1,37 +1,64 @@ { "name": "@metaplex/cli", - "version": "0.0.1", + "version": "0.0.2", "main": "./build/cli.js", "license": "MIT", "bin": { - "metaplex": "./build/cli.js" + "metaplex": "./build/candy-machine-cli.js" }, "scripts": { "build": "tsc -p ./src", "watch": "tsc -w -p ./src", "package:linux": "pkg . --no-bytecode --targets node14-linux-x64 --output bin/linux/metaplex", "package:linuxb": "pkg . --targets node14-linux-x64 --output bin/linux/metaplex", - "package:macos": "pkg . --no-bytecode --targets node14-macos-x64 --output bin/macos/metaplex", + "package:win": "pkg . --targets node14-win-x64 --output bin/win/metaplex", + "package:macos": "pkg . --targets node14-macos-x64 --output bin/macos/metaplex", "format": "prettier --loglevel warn --write \"**/*.{ts,js,json,yaml}\"", "format:check": "prettier --loglevel warn --check \"**/*.{ts,js,json,yaml}\"", "lint": "eslint \"src/**/*.ts\" --fix", - "lint:check": "eslint \"src/**/*.ts\"" + "lint:check": "eslint \"src/**/*.ts\"", + "test": "jest" }, "pkg": { - "scripts": "./build/**/*.js" + "scripts": "./build/**/*.{js|json}" + }, + "babel": { + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ], + "@babel/preset-typescript" + ] + }, + "jest": { + "testPathIgnorePatterns": [ + "/build/", + "/node_modules/" + ] }, "dependencies": { - "@project-serum/anchor": "^0.13.2", + "@project-serum/anchor": "^0.14.0", "@solana/spl-token": "^0.1.8", "arweave": "^1.10.16", "bn.js": "^5.2.0", "borsh": "^0.4.0", "commander": "^8.1.0", "form-data": "^4.0.0", + "ipfs-http-client": "^52.0.3", + "jsonschema": "^1.4.0", "loglevel": "^1.7.1", "node-fetch": "^2.6.1" }, "devDependencies": { + "@babel/preset-env": "^7.15.6", + "@babel/preset-typescript": "^7.15.0", + "@types/jest": "^27.0.1", + "jest": "^27.2.0", "pkg": "^5.3.1", "typescript": "^4.3.5" } diff --git a/js/packages/cli/src/candy-machine-cli.ts b/js/packages/cli/src/candy-machine-cli.ts new file mode 100755 index 0000000..9a7cf6d --- /dev/null +++ b/js/packages/cli/src/candy-machine-cli.ts @@ -0,0 +1,653 @@ +#!/usr/bin/env ts-node +import * as fs from 'fs'; +import * as path from 'path'; +import { program } from 'commander'; +import * as anchor from '@project-serum/anchor'; +import BN from 'bn.js'; +import fetch from 'node-fetch'; + +import { fromUTF8Array, parseDate, parsePrice } from './helpers/various'; +import { Token, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; +import { + CACHE_PATH, + CONFIG_ARRAY_START, + CONFIG_LINE_SIZE, + EXTENSION_JSON, + EXTENSION_PNG, +} from './helpers/constants'; +import { + getCandyMachineAddress, + loadCandyProgram, + loadWalletKey, +} from './helpers/accounts'; +import { Config } from './types'; +import { upload } from './commands/upload'; +import { verifyTokenMetadata } from './commands/verifyTokenMetadata'; +import { loadCache, saveCache } from './helpers/cache'; +import { mint } from './commands/mint'; +import { signMetadata } from './commands/sign'; +import { signAllMetadataFromCandyMachine } from './commands/signAll'; +import log from 'loglevel'; + +program.version('0.0.2'); + +if (!fs.existsSync(CACHE_PATH)) { + fs.mkdirSync(CACHE_PATH); +} + +log.setLevel(log.levels.INFO); + +programCommand('upload') + .argument( + '', + 'Directory containing images named from 0-n', + val => { + return fs.readdirSync(`${val}`).map(file => path.join(val, file)); + }, + ) + .option('-n, --number ', 'Number of images to upload') + .option( + '-s, --storage ', + 'Database to use for storage (arweave, ipfs)', + 'arweave', + ) + .option( + '--ipfs-infura-project-id', + 'Infura IPFS project id (required if using IPFS)', + ) + .option( + '--ipfs-infura-secret', + 'Infura IPFS scret key (required if using IPFS)', + ) + .option('--no-retain-authority', 'Do not retain authority to update metadata') + .action(async (files: string[], options, cmd) => { + const { + number, + keypair, + env, + cacheName, + storage, + ipfsInfuraProjectId, + ipfsInfuraSecret, + retainAuthority, + } = cmd.opts(); + + if (storage === 'ipfs' && (!ipfsInfuraProjectId || !ipfsInfuraSecret)) { + throw new Error( + 'IPFS selected as storage option but Infura project id or secret key were not provided.', + ); + } + if (!(storage === 'arweave' || storage === 'ipfs')) { + throw new Error("Storage option must either be 'arweave' or 'ipfs'."); + } + const ipfsCredentials = { + projectId: ipfsInfuraProjectId, + secretKey: ipfsInfuraSecret, + }; + + const pngFileCount = files.filter(it => { + return it.endsWith(EXTENSION_PNG); + }).length; + const jsonFileCount = files.filter(it => { + return it.endsWith(EXTENSION_JSON); + }).length; + + const parsedNumber = parseInt(number); + const elemCount = parsedNumber ? parsedNumber : pngFileCount; + + if (pngFileCount !== jsonFileCount) { + throw new Error( + `number of png files (${pngFileCount}) is different than the number of json files (${jsonFileCount})`, + ); + } + + if (elemCount < pngFileCount) { + throw new Error( + `max number (${elemCount})cannot be smaller than the number of elements in the source folder (${pngFileCount})`, + ); + } + + log.info(`Beginning the upload for ${elemCount} (png+json) pairs`); + + const startMs = Date.now(); + log.info('started at: ' + startMs.toString()); + let warn = false; + for (;;) { + const successful = await upload( + files, + cacheName, + env, + keypair, + elemCount, + storage, + retainAuthority, + ipfsCredentials, + ); + + if (successful) { + warn = false; + break; + } else { + warn = true; + log.warn('upload was not successful, rerunning'); + } + } + const endMs = Date.now(); + const timeTaken = new Date(endMs - startMs).toISOString().substr(11, 8); + log.info( + `ended at: ${new Date(endMs).toISOString()}. time taken: ${timeTaken}`, + ); + if (warn) { + log.info('not all images have been uplaoded, rerun this step.'); + } + }); + +programCommand('verify_token_metadata') + .argument( + '', + 'Directory containing images and metadata files named from 0-n', + val => { + return fs + .readdirSync(`${val}`) + .map(file => path.join(process.cwd(), val, file)); + }, + ) + .option('-n, --number ', 'Number of images to upload') + .action((files: string[], options, cmd) => { + const { number } = cmd.opts(); + + const startMs = Date.now(); + log.info('started at: ' + startMs.toString()); + verifyTokenMetadata({ files, uploadElementsCount: number }); + + const endMs = Date.now(); + const timeTaken = new Date(endMs - startMs).toISOString().substr(11, 8); + log.info( + `ended at: ${new Date(endMs).toString()}. time taken: ${timeTaken}`, + ); + }); + +programCommand('verify').action(async (directory, cmd) => { + const { env, keypair, cacheName } = cmd.opts(); + + const cacheContent = loadCache(cacheName, env); + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadCandyProgram(walletKeyPair, env); + + const configAddress = new PublicKey(cacheContent.program.config); + const config = await anchorProgram.provider.connection.getAccountInfo( + configAddress, + ); + let allGood = true; + + const keys = Object.keys(cacheContent.items); + for (let i = 0; i < keys.length; i++) { + log.debug('Looking at key ', i); + const key = keys[i]; + const thisSlice = config.data.slice( + CONFIG_ARRAY_START + 4 + CONFIG_LINE_SIZE * i, + CONFIG_ARRAY_START + 4 + CONFIG_LINE_SIZE * (i + 1), + ); + const name = fromUTF8Array([...thisSlice.slice(4, 36)]); + const uri = fromUTF8Array([...thisSlice.slice(40, 240)]); + const cacheItem = cacheContent.items[key]; + if (!name.match(cacheItem.name) || !uri.match(cacheItem.link)) { + //leaving here for debugging reasons, but it's pretty useless. if the first upload fails - all others are wrong + // log.info( + // `Name (${name}) or uri (${uri}) didnt match cache values of (${cacheItem.name})` + + // `and (${cacheItem.link}). marking to rerun for image`, + // key, + // ); + cacheItem.onChain = false; + allGood = false; + } else { + const json = await fetch(cacheItem.link); + if (json.status == 200 || json.status == 204 || json.status == 202) { + const body = await json.text(); + const parsed = JSON.parse(body); + if (parsed.image) { + const check = await fetch(parsed.image); + if ( + check.status == 200 || + check.status == 204 || + check.status == 202 + ) { + const text = await check.text(); + if (!text.match(/Not found/i)) { + if (text.length == 0) { + log.debug( + 'Name', + name, + 'with', + uri, + 'has zero length, failing', + ); + cacheItem.onChain = false; + allGood = false; + } else { + log.debug('Name', name, 'with', uri, 'checked out'); + } + } else { + log.debug( + 'Name', + name, + 'with', + uri, + 'never got uploaded to arweave, failing', + ); + cacheItem.onChain = false; + allGood = false; + } + } else { + log.debug( + 'Name', + name, + 'with', + uri, + 'returned non-200 from uploader', + check.status, + ); + cacheItem.onChain = false; + allGood = false; + } + } else { + log.debug('Name', name, 'with', uri, 'lacked image in json, failing'); + cacheItem.onChain = false; + allGood = false; + } + } else { + log.debug('Name', name, 'with', uri, 'returned no json from link'); + cacheItem.onChain = false; + allGood = false; + } + } + } + + if (!allGood) { + saveCache(cacheName, env, cacheContent); + + throw new Error( + `not all NFTs checked out. check out logs above for details`, + ); + } + + const configData = (await anchorProgram.account.config.fetch( + configAddress, + )) as Config; + + const lineCount = new BN(config.data.slice(247, 247 + 4), undefined, 'le'); + + log.info( + `uploaded (${lineCount.toNumber()}) out of (${ + configData.data.maxNumberOfLines + })`, + ); + if (configData.data.maxNumberOfLines > lineCount.toNumber()) { + throw new Error( + `predefined number of NFTs (${ + configData.data.maxNumberOfLines + }) is smaller than the uploaded one (${lineCount.toNumber()})`, + ); + } else { + log.info('ready to deploy!'); + } + + saveCache(cacheName, env, cacheContent); +}); + +programCommand('verify_price') + .option('-p, --price ') + .option('--cache-path ') + .action(async (directory, cmd) => { + const { keypair, env, price, cacheName, cachePath } = cmd.opts(); + const lamports = parsePrice(price); + + if (isNaN(lamports)) { + return log.error(`verify_price requires a --price to be set`); + } + + log.info(`Expected price is: ${lamports}`); + + const cacheContent = loadCache(cacheName, env, cachePath); + + if (!cacheContent) { + return log.error( + `No cache found, can't continue. Make sure you are in the correct directory where the assets are located or use the --cache-path option.`, + ); + } + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadCandyProgram(walletKeyPair, env); + + const candyAddress = new PublicKey(cacheContent.candyMachineAddress); + + const machine = await anchorProgram.account.candyMachine.fetch( + candyAddress, + ); + + //@ts-ignore + const candyMachineLamports = machine.data.price.toNumber(); + + log.info(`Candymachine price is: ${candyMachineLamports}`); + + if (lamports != candyMachineLamports) { + throw new Error(`Expected price and CandyMachine's price do not match!`); + } + + log.info(`Good to go!`); + }); + +programCommand('show') + .option('--cache-path ') + .action(async (directory, cmd) => { + const { keypair, env, cacheName, cachePath } = cmd.opts(); + + const cacheContent = loadCache(cacheName, env, cachePath); + + if (!cacheContent) { + return log.error( + `No cache found, can't continue. Make sure you are in the correct directory where the assets are located or use the --cache-path option.`, + ); + } + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadCandyProgram(walletKeyPair, env); + + const [candyMachine] = await getCandyMachineAddress( + new PublicKey(cacheContent.program.config), + cacheContent.program.uuid, + ); + + try { + const machine = await anchorProgram.account.candyMachine.fetch( + candyMachine, + ); + log.info('...Candy Machine...'); + //@ts-ignore + log.info('authority: ', machine.authority.toBase58()); + //@ts-ignore + log.info('wallet: ', machine.wallet.toBase58()); + //@ts-ignore + log.info('tokenMint: ', machine.tokenMint.toBase58()); + //@ts-ignore + log.info('config: ', machine.config.toBase58()); + //@ts-ignore + log.info('uuid: ', machine.data.uuid); + //@ts-ignore + log.info('price: ', machine.data.price.toNumber()); + //@ts-ignore + log.info('itemsAvailable: ', machine.data.itemsAvailable.toNumber()); + log.info( + 'goLiveDate: ', + //@ts-ignore + machine.data.goLiveDate + ? //@ts-ignore + new Date(machine.data.goLiveDate * 1000) + : 'N/A', + ); + } catch (e) { + console.log('No machine found'); + } + + const config = await anchorProgram.account.config.fetch( + cacheContent.program.config, + ); + log.info('...Config...'); + //@ts-ignore + log.info('authority: ', config.authority); + //@ts-ignore + log.info('symbol: ', config.data.symbol); + //@ts-ignore + log.info('sellerFeeBasisPoints: ', config.data.sellerFeeBasisPoints); + //@ts-ignore + log.info('creators: '); + //@ts-ignore + config.data.creators.map(c => + log.info(c.address.toBase58(), 'at', c.share, '%'), + ), + //@ts-ignore + log.info('maxSupply: ', config.data.maxSupply.toNumber()); + //@ts-ignore + log.info('retainAuthority: ', config.data.retainAuthority); + //@ts-ignore + log.info('maxNumberOfLines: ', config.data.maxNumberOfLines); + }); + +programCommand('create_candy_machine') + .option( + '-p, --price ', + 'Price denominated in SOL or spl-token override', + '1', + ) + .option( + '-t, --spl-token ', + 'SPL token used to price NFT mint. To use SOL leave this empty.', + ) + .option( + '-a, --spl-token-account ', + 'SPL token account that receives mint payments. Only required if spl-token is specified.', + ) + .option( + '-s, --sol-treasury-account ', + 'SOL account that receives mint payments.', + ) + .action(async (directory, cmd) => { + const { + keypair, + env, + price, + cacheName, + splToken, + splTokenAccount, + solTreasuryAccount, + } = cmd.opts(); + + let parsedPrice = parsePrice(price); + const cacheContent = loadCache(cacheName, env); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadCandyProgram(walletKeyPair, env); + + let wallet = walletKeyPair.publicKey; + const remainingAccounts = []; + if (splToken || splTokenAccount) { + if (solTreasuryAccount) { + throw new Error( + 'If spl-token-account or spl-token is set then sol-treasury-account cannot be set', + ); + } + if (!splToken) { + throw new Error( + 'If spl-token-account is set, spl-token must also be set', + ); + } + const splTokenKey = new PublicKey(splToken); + const splTokenAccountKey = new PublicKey(splTokenAccount); + if (!splTokenAccount) { + throw new Error( + 'If spl-token is set, spl-token-account must also be set', + ); + } + + const token = new Token( + anchorProgram.provider.connection, + splTokenKey, + TOKEN_PROGRAM_ID, + walletKeyPair, + ); + + const mintInfo = await token.getMintInfo(); + if (!mintInfo.isInitialized) { + throw new Error(`The specified spl-token is not initialized`); + } + const tokenAccount = await token.getAccountInfo(splTokenAccountKey); + if (!tokenAccount.isInitialized) { + throw new Error(`The specified spl-token-account is not initialized`); + } + if (!tokenAccount.mint.equals(splTokenKey)) { + throw new Error( + `The spl-token-account's mint (${tokenAccount.mint.toString()}) does not match specified spl-token ${splTokenKey.toString()}`, + ); + } + + wallet = splTokenAccountKey; + parsedPrice = parsePrice(price, 10 ** mintInfo.decimals); + remainingAccounts.push({ + pubkey: splTokenKey, + isWritable: false, + isSigner: false, + }); + } + + if (solTreasuryAccount) { + wallet = new PublicKey(solTreasuryAccount); + } + + const config = new PublicKey(cacheContent.program.config); + const [candyMachine, bump] = await getCandyMachineAddress( + config, + cacheContent.program.uuid, + ); + await anchorProgram.rpc.initializeCandyMachine( + bump, + { + uuid: cacheContent.program.uuid, + price: new anchor.BN(parsedPrice), + itemsAvailable: new anchor.BN(Object.keys(cacheContent.items).length), + goLiveDate: null, + }, + { + accounts: { + candyMachine, + wallet, + config: config, + authority: walletKeyPair.publicKey, + payer: walletKeyPair.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + signers: [], + remainingAccounts, + }, + ); + cacheContent.candyMachineAddress = candyMachine.toBase58(); + saveCache(cacheName, env, cacheContent); + log.info( + `create_candy_machine finished. candy machine pubkey: ${candyMachine.toBase58()}`, + ); + }); + +programCommand('update_candy_machine') + .option( + '-d, --date ', + 'timestamp - eg "04 Dec 1995 00:12:00 GMT" or "now"', + ) + .option('-p, --price ', 'SOL price') + .action(async (directory, cmd) => { + const { keypair, env, date, price, cacheName } = cmd.opts(); + const cacheContent = loadCache(cacheName, env); + + const secondsSinceEpoch = date ? parseDate(date) : null; + const lamports = price ? parsePrice(price) : null; + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadCandyProgram(walletKeyPair, env); + + const candyMachine = new PublicKey(cacheContent.candyMachineAddress); + const tx = await anchorProgram.rpc.updateCandyMachine( + lamports ? new anchor.BN(lamports) : null, + secondsSinceEpoch ? new anchor.BN(secondsSinceEpoch) : null, + { + accounts: { + candyMachine, + authority: walletKeyPair.publicKey, + }, + }, + ); + + cacheContent.startDate = secondsSinceEpoch; + saveCache(cacheName, env, cacheContent); + if (date) + log.info( + ` - updated startDate timestamp: ${secondsSinceEpoch} (${date})`, + ); + if (lamports) + log.info(` - updated price: ${lamports} lamports (${price} SOL)`); + log.info('updated_candy_machine finished', tx); + }); + +programCommand('mint_one_token').action(async (directory, cmd) => { + const { keypair, env, cacheName } = cmd.opts(); + + const cacheContent = loadCache(cacheName, env); + const configAddress = new PublicKey(cacheContent.program.config); + const tx = await mint(keypair, env, configAddress); + + log.info('mint_one_token finished', tx); +}); + +programCommand('sign') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .option('-m, --metadata ', 'base58 metadata account id') + .action(async (directory, cmd) => { + const { keypair, env, metadata } = cmd.opts(); + + await signMetadata(metadata, keypair, env); + }); + +programCommand('sign_all') + .option('-b, --batch-size ', 'Batch size', '10') + .option('-d, --daemon', 'Run signing continuously', false) + .action(async (directory, cmd) => { + const { keypair, env, cacheName, batchSize, daemon } = cmd.opts(); + const cacheContent = loadCache(cacheName, env); + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadCandyProgram(walletKeyPair, env); + const candyAddress = cacheContent.candyMachineAddress; + + const batchSizeParsed = parseInt(batchSize); + if (!parseInt(batchSize)) { + throw new Error('Batch size needs to be an integer!'); + } + + log.debug('Creator pubkey: ', walletKeyPair.publicKey.toBase58()); + log.debug('Environment: ', env); + log.debug('Candy machine address: ', candyAddress); + log.debug('Batch Size: ', batchSizeParsed); + await signAllMetadataFromCandyMachine( + anchorProgram.provider.connection, + walletKeyPair, + candyAddress, + batchSizeParsed, + daemon, + ); + }); + +function programCommand(name: string) { + return program + .command(name) + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-l, --log-level ', 'log level', setLogLevel) + .option('-c, --cache-name ', 'Cache file name', 'temp'); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function setLogLevel(value, prev) { + if (value === undefined || value === null) { + return; + } + log.info('setting the log value to: ' + value); + log.setLevel(value); +} + +program.parse(process.argv); diff --git a/js/packages/cli/src/cli.ts b/js/packages/cli/src/cli.ts deleted file mode 100755 index 1e25cab..0000000 --- a/js/packages/cli/src/cli.ts +++ /dev/null @@ -1,391 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { program } from 'commander'; -import * as anchor from '@project-serum/anchor'; -import BN from 'bn.js'; - -import { fromUTF8Array, parsePrice } from './helpers/various'; -import { Token, TOKEN_PROGRAM_ID } from '@solana/spl-token'; -import { PublicKey } from '@solana/web3.js'; -import { CACHE_PATH, CONFIG_ARRAY_START, CONFIG_LINE_SIZE, EXTENSION_JSON, EXTENSION_PNG, } from './helpers/constants'; -import { getCandyMachineAddress, loadAnchorProgram, loadWalletKey, } from './helpers/accounts'; -import { Config } from './types'; -import { upload } from './commands/upload'; -import { loadCache, saveCache } from './helpers/cache'; -import { mint } from "./commands/mint"; -import { signMetadata } from "./commands/sign"; -import { signAllMetadataFromCandyMachine } from "./commands/signAll"; -import log from 'loglevel'; - -program.version('0.0.1'); - -if (!fs.existsSync(CACHE_PATH)) { - fs.mkdirSync(CACHE_PATH); -} - -log.setLevel(log.levels.INFO); - -programCommand('upload') - .argument( - '', - 'Directory containing images named from 0-n', - val => { - return fs.readdirSync(`${val}`).map(file => path.join(val, file)); - }, - ) - .option('-n, --number ', 'Number of images to upload') - .action(async (files: string[], options, cmd) => { - const {number, keypair, env, cacheName} = cmd.opts(); - - const pngFileCount = files.filter(it => { - return it.endsWith(EXTENSION_PNG); - }).length; - const jsonFileCount = files.filter(it => { - return it.endsWith(EXTENSION_JSON); - }).length; - - const parsedNumber = parseInt(number); - const elemCount = parsedNumber ? parsedNumber : pngFileCount; - - if (pngFileCount !== jsonFileCount) { - throw new Error(`number of png files (${pngFileCount}) is different than the number of json files (${jsonFileCount})`); - } - - if (elemCount < pngFileCount) { - throw new Error(`max number (${elemCount})cannot be smaller than the number of elements in the source folder (${pngFileCount})`); - } - - log.info(`Beginning the upload for ${elemCount} (png+json) pairs`) - - const startMs = Date.now(); - log.info("started at: " + startMs.toString()) - let warn = false; - for (; ;) { - const successful = await upload(files, cacheName, env, keypair, elemCount); - if (successful) { - warn = false; - break; - } else { - warn = true; - log.warn("upload was not successful, rerunning"); - } - } - const endMs = Date.now(); - const timeTaken = new Date(endMs - startMs).toISOString().substr(11, 8); - log.info(`ended at: ${new Date(endMs).toString()}. time taken: ${timeTaken}`) - if (warn) { log.info("not all images have been uplaoded, rerun this step.") } - }); - -programCommand('verify') - .action(async (directory, cmd) => { - const { env, keypair, cacheName } = cmd.opts(); - - const cacheContent = loadCache(cacheName, env); - const walletKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(walletKeyPair, env); - - const configAddress = new PublicKey(cacheContent.program.config); - const config = await anchorProgram.provider.connection.getAccountInfo( - configAddress, - ); - let allGood = true; - - const keys = Object.keys(cacheContent.items); - for (let i = 0; i < keys.length; i++) { - log.debug('Looking at key ', i); - const key = keys[i]; - const thisSlice = config.data.slice( - CONFIG_ARRAY_START + 4 + CONFIG_LINE_SIZE * i, - CONFIG_ARRAY_START + 4 + CONFIG_LINE_SIZE * (i + 1), - ); - const name = fromUTF8Array([...thisSlice.slice(4, 36)]); - const uri = fromUTF8Array([...thisSlice.slice(40, 240)]); - const cacheItem = cacheContent.items[key]; - if (!name.match(cacheItem.name) || !uri.match(cacheItem.link)) { - //leaving here for debugging reasons, but it's pretty useless. if the first upload fails - all others are wrong - // log.info( - // `Name (${name}) or uri (${uri}) didnt match cache values of (${cacheItem.name})` + - // `and (${cacheItem.link}). marking to rerun for image`, - // key, - // ); - cacheItem.onChain = false; - allGood = false; - } else { - log.debug('Name', name, 'with', uri, 'checked out'); - } - } - - if (!allGood) { - saveCache(cacheName, env, cacheContent); - - throw new Error( - `not all NFTs checked out. check out logs above for details`, - ); - } - - const configData = (await anchorProgram.account.config.fetch( - configAddress, - )) as Config; - - const lineCount = new BN(config.data.slice(247, 247 + 4), undefined, 'le'); - - log.info( - `uploaded (${lineCount.toNumber()}) out of (${configData.data.maxNumberOfLines - })`, - ); - if (configData.data.maxNumberOfLines > lineCount.toNumber()) { - throw new Error( - `predefined number of NFTs (${configData.data.maxNumberOfLines - }) is smaller than the uploaded one (${lineCount.toNumber()})`, - ); - } else { - log.info('ready to deploy!'); - } - - saveCache(cacheName, env, cacheContent); - }); - -programCommand('verify_price') - .option('-p, --price ') - .option('--cache-path ') - .action(async (directory, cmd) => { - const { keypair, env, price, cacheName, cachePath } = cmd.opts(); - const lamports = parsePrice(price); - - if (isNaN(lamports)) { - return log.error(`verify_price requires a --price to be set`); - } - - log.info(`Expected price is: ${lamports}`); - - const cacheContent = loadCache(cacheName, env, cachePath); - - if (!cacheContent) { - return log.error( - `No cache found, can't continue. Make sure you are in the correct directory where the assets are located or use the --cache-path option.`, - ); - } - - const walletKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(walletKeyPair, env); - - const [candyMachine] = await getCandyMachineAddress( - new PublicKey(cacheContent.program.config), - cacheContent.program.uuid, - ); - - const machine = await anchorProgram.account.candyMachine.fetch( - candyMachine, - ); - - //@ts-ignore - const candyMachineLamports = machine.data.price.toNumber(); - - log.info(`Candymachine price is: ${candyMachineLamports}`); - - if (lamports != candyMachineLamports) { - throw new Error(`Expected price and CandyMachine's price do not match!`); - } - - log.info(`Good to go!`); - }); - -programCommand('create_candy_machine') - .option('-p, --price ', 'Price denominated in SOL or spl-token override', '1') - .option('-t, --spl-token ', 'SPL token used to price NFT mint. To use SOL leave this empty.') - .option('-t, --spl-token-account ', 'SPL token account that receives mint payments. Only required if spl-token is specified.') - .action(async (directory, cmd) => { - const { keypair, env, price, cacheName, splToken, splTokenAccount } = cmd.opts(); - - let parsedPrice = parsePrice(price); - const cacheContent = loadCache(cacheName, env); - - const walletKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(walletKeyPair, env); - - let wallet = walletKeyPair.publicKey; - const remainingAccounts = []; - if (splToken || splTokenAccount) { - if (!splToken) { - throw new Error("If spl-token-account is set, spl-token must also be set") - } - const splTokenKey = new PublicKey(splToken); - const splTokenAccountKey = new PublicKey(splTokenAccount); - if (!splTokenAccount) { - throw new Error("If spl-token is set, spl-token-account must also be set") - } - - const token = new Token( - anchorProgram.provider.connection, - splTokenKey, - TOKEN_PROGRAM_ID, - walletKeyPair - ); - - const mintInfo = await token.getMintInfo(); - if (!mintInfo.isInitialized) { - throw new Error(`The specified spl-token is not initialized`); - } - const tokenAccount = await token.getAccountInfo(splTokenAccountKey); - if (!tokenAccount.isInitialized) { - throw new Error(`The specified spl-token-account is not initialized`); - } - if (!tokenAccount.mint.equals(splTokenKey)) { - throw new Error(`The spl-token-account's mint (${tokenAccount.mint.toString()}) does not match specified spl-token ${splTokenKey.toString()}`); - } - - wallet = splTokenAccountKey; - parsedPrice = parsePrice(price, 10 ** mintInfo.decimals); - remainingAccounts.push({ pubkey: splTokenKey, isWritable: false, isSigner: false }); - } - - const config = new PublicKey(cacheContent.program.config); - const [candyMachine, bump] = await getCandyMachineAddress( - config, - cacheContent.program.uuid, - ); - await anchorProgram.rpc.initializeCandyMachine( - bump, - { - uuid: cacheContent.program.uuid, - price: new anchor.BN(parsedPrice), - itemsAvailable: new anchor.BN(Object.keys(cacheContent.items).length), - goLiveDate: null, - }, - { - accounts: { - candyMachine, - wallet, - config: config, - authority: walletKeyPair.publicKey, - payer: walletKeyPair.publicKey, - systemProgram: anchor.web3.SystemProgram.programId, - rent: anchor.web3.SYSVAR_RENT_PUBKEY, - }, - signers: [], - remainingAccounts, - }, - ); - saveCache(cacheName, env, cacheContent); - log.info(`create_candy_machine finished. candy machine pubkey: ${candyMachine.toBase58()}`); - }); - -programCommand('update_candy_machine') - .option('-d, --date ', 'timestamp - eg "04 Dec 1995 00:12:00 GMT"') - .option('-p, --price ', 'SOL price') - .action(async (directory, cmd) => { - const { keypair, env, date, price, cacheName } = cmd.opts(); - const cacheContent = loadCache(cacheName, env); - - const secondsSinceEpoch = date ? Date.parse(date) / 1000 : null; - const lamports = price ? parsePrice(price) : null; - - const walletKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(walletKeyPair, env); - - const [candyMachine] = await getCandyMachineAddress( - new PublicKey(cacheContent.program.config), - cacheContent.program.uuid, - ); - const tx = await anchorProgram.rpc.updateCandyMachine( - lamports ? new anchor.BN(lamports) : null, - secondsSinceEpoch ? new anchor.BN(secondsSinceEpoch) : null, - { - accounts: { - candyMachine, - authority: walletKeyPair.publicKey, - }, - }, - ); - - if (date) log.info(` - updated startDate timestamp: ${secondsSinceEpoch} (${date})`) - if (lamports) log.info(` - updated price: ${lamports} lamports (${price} SOL)`) - log.info('updated_candy_machine Done', tx); - }); - -programCommand('mint_one_token') - .option('-t, --spl-token-account ', 'SPL token account to payfrom') - .action(async (directory, cmd) => { - const {keypair, env, cacheName, splTokenAccount} = cmd.opts(); - - const cacheContent = loadCache(cacheName, env); - const configAddress = new PublicKey(cacheContent.program.config); - const splTokenAccountKey = splTokenAccount ? new PublicKey(splTokenAccount) : undefined; - const tx = await mint(keypair, env, configAddress, splTokenAccountKey); - - log.info('Done', tx); - }); - -programCommand('sign') - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .option('-m, --metadata ', 'base58 metadata account id') - .action(async (directory, cmd) => { - const { keypair, env, metadata } = cmd.opts(); - - await signMetadata( - metadata, - keypair, - env - ); - }); - -function programCommand(name: string) { - return program - .command(name) - .option( - '-e, --env ', - 'Solana cluster env name', - 'devnet', //mainnet-beta, testnet, devnet - ) - .option( - '-k, --keypair ', - `Solana wallet location`, - '--keypair not provided', - ) - .option('-l, --log-level ', 'log level', setLogLevel) - .option('-c, --cache-name ', 'Cache file name', 'temp'); -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function setLogLevel(value, prev) { - if (value === undefined || value === null) { - return - } - log.info("setting the log value to: " + value); - log.setLevel(value); -} - -programCommand("sign_candy_machine_metadata") - .option('-cndy, --candy-address ', 'Candy machine address', '') - .option('-b, --batch-size ', 'Batch size', '10') - .action(async (directory, cmd) => { - let { keypair, env, cacheName, candyAddress, batchSize } = cmd.opts(); - if (!keypair || keypair == '') { - log.info("Keypair required!"); - return; - } - if (!candyAddress || candyAddress == '') { - log.info("Candy machine address required! Using from saved list.") - const cacheContent = loadCache(cacheName, env); - const config = new PublicKey(cacheContent.program.config); - const [candyMachine, bump] = await getCandyMachineAddress( - config, - cacheContent.program.uuid, - ); - candyAddress = candyMachine.toBase58(); - } - let batchSizeParsed = parseInt(batchSize) - if (!parseInt(batchSize)) { - log.info("Batch size needs to be an integer!") - return; - } - const walletKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(walletKeyPair, env); - log.info("Creator pubkey: ", walletKeyPair.publicKey.toBase58()) - log.info("Environment: ", env) - log.info("Candy machine address: ", candyAddress) - log.info("Batch Size: ", batchSizeParsed) - await signAllMetadataFromCandyMachine(anchorProgram.provider.connection, walletKeyPair, candyAddress, batchSizeParsed) - }); - -program.parse(process.argv); diff --git a/js/packages/cli/src/commands/mint.ts b/js/packages/cli/src/commands/mint.ts index 711936d..3fc8f74 100644 --- a/js/packages/cli/src/commands/mint.ts +++ b/js/packages/cli/src/commands/mint.ts @@ -1,23 +1,31 @@ -import { Keypair, PublicKey, SystemProgram } from "@solana/web3.js"; +import { Keypair, PublicKey, SystemProgram } from '@solana/web3.js'; import { getCandyMachineAddress, getMasterEdition, getMetadata, getTokenWallet, - loadAnchorProgram, + loadCandyProgram, loadWalletKey, - uuidFromConfigPubkey -} from "../helpers/accounts"; -import { TOKEN_METADATA_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../helpers/constants"; -import * as anchor from "@project-serum/anchor"; -import { MintLayout, Token } from "@solana/spl-token"; -import { createAssociatedTokenAccountInstruction } from "../helpers/instructions"; + uuidFromConfigPubkey, +} from '../helpers/accounts'; +import { + TOKEN_METADATA_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from '../helpers/constants'; +import * as anchor from '@project-serum/anchor'; +import { MintLayout, Token } from '@solana/spl-token'; +import { createAssociatedTokenAccountInstruction } from '../helpers/instructions'; +import { sendTransactionWithRetryWithKeypair } from '../helpers/transactions'; -export async function mint(keypair: string, env: string, configAddress: PublicKey, splTokenAccountKey?: PublicKey): Promise { +export async function mint( + keypair: string, + env: string, + configAddress: PublicKey, +): Promise { const mint = Keypair.generate(); const userKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(userKeyPair, env); + const anchorProgram = await loadCandyProgram(userKeyPair, env); const userTokenAccountAddress = await getTokenWallet( userKeyPair.publicKey, mint.publicKey, @@ -28,90 +36,120 @@ export async function mint(keypair: string, env: string, configAddress: PublicKe configAddress, uuid, ); - const candyMachine : any = await anchorProgram.account.candyMachine.fetch( + const candyMachine: any = await anchorProgram.account.candyMachine.fetch( candyMachineAddress, ); const remainingAccounts = []; - if (splTokenAccountKey) { - const candyMachineTokenMintKey = candyMachine.tokenMint; - if (!candyMachineTokenMintKey) { - throw new Error('Candy machine data does not have token mint configured. Can\'t use spl-token-account'); - } - const token = new Token( - anchorProgram.provider.connection, - candyMachine.tokenMint, + const signers = [mint, userKeyPair]; + const instructions = [ + anchor.web3.SystemProgram.createAccount({ + fromPubkey: userKeyPair.publicKey, + newAccountPubkey: mint.publicKey, + space: MintLayout.span, + lamports: + await anchorProgram.provider.connection.getMinimumBalanceForRentExemption( + MintLayout.span, + ), + programId: TOKEN_PROGRAM_ID, + }), + Token.createInitMintInstruction( TOKEN_PROGRAM_ID, - userKeyPair + mint.publicKey, + 0, + userKeyPair.publicKey, + userKeyPair.publicKey, + ), + createAssociatedTokenAccountInstruction( + userTokenAccountAddress, + userKeyPair.publicKey, + userKeyPair.publicKey, + mint.publicKey, + ), + Token.createMintToInstruction( + TOKEN_PROGRAM_ID, + mint.publicKey, + userTokenAccountAddress, + userKeyPair.publicKey, + [], + 1, + ), + ]; + + let tokenAccount; + if (candyMachine.tokenMint) { + const transferAuthority = anchor.web3.Keypair.generate(); + + tokenAccount = await getTokenWallet( + userKeyPair.publicKey, + candyMachine.tokenMint, ); - const tokenAccount = await token.getAccountInfo(splTokenAccountKey); - if (!candyMachine.tokenMint.equals(tokenAccount.mint)) { - throw new Error(`Specified spl-token-account's mint (${tokenAccount.mint.toString()}) does not match candy machine's token mint (${candyMachine.tokenMint.toString()})`); - } + remainingAccounts.push({ + pubkey: tokenAccount, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: userKeyPair.publicKey, + isWritable: false, + isSigner: true, + }); - if (!tokenAccount.owner.equals(userKeyPair.publicKey)) { - throw new Error(`Specified spl-token-account's owner (${tokenAccount.owner.toString()}) does not match user public key (${userKeyPair.publicKey})`); - } - - remainingAccounts.push({ pubkey: splTokenAccountKey, isWritable: true, isSigner: false }); - remainingAccounts.push({ pubkey: userKeyPair.publicKey, isWritable: false, isSigner: true }); - } - - const metadataAddress = await getMetadata(mint.publicKey); - const masterEdition = await getMasterEdition(mint.publicKey); - return await anchorProgram.rpc.mintNft({ - accounts: { - config: configAddress, - candyMachine: candyMachineAddress, - payer: userKeyPair.publicKey, - //@ts-ignore - wallet: candyMachine.wallet, - mint: mint.publicKey, - metadata: metadataAddress, - masterEdition, - mintAuthority: userKeyPair.publicKey, - updateAuthority: userKeyPair.publicKey, - tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID, - tokenProgram: TOKEN_PROGRAM_ID, - systemProgram: SystemProgram.programId, - rent: anchor.web3.SYSVAR_RENT_PUBKEY, - clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, - }, - signers: [mint, userKeyPair], - remainingAccounts, - instructions: [ - anchor.web3.SystemProgram.createAccount({ - fromPubkey: userKeyPair.publicKey, - newAccountPubkey: mint.publicKey, - space: MintLayout.span, - lamports: - await anchorProgram.provider.connection.getMinimumBalanceForRentExemption( - MintLayout.span, - ), - programId: TOKEN_PROGRAM_ID, - }), - Token.createInitMintInstruction( + instructions.push( + Token.createApproveInstruction( TOKEN_PROGRAM_ID, - mint.publicKey, - 0, - userKeyPair.publicKey, - userKeyPair.publicKey, - ), - createAssociatedTokenAccountInstruction( - userTokenAccountAddress, - userKeyPair.publicKey, - userKeyPair.publicKey, - mint.publicKey, - ), - Token.createMintToInstruction( - TOKEN_PROGRAM_ID, - mint.publicKey, - userTokenAccountAddress, + tokenAccount, + transferAuthority.publicKey, userKeyPair.publicKey, [], - 1, + candyMachine.data.price.toNumber(), ), - ], - }); + ); + } + const metadataAddress = await getMetadata(mint.publicKey); + const masterEdition = await getMasterEdition(mint.publicKey); + + instructions.push( + await anchorProgram.instruction.mintNft({ + accounts: { + config: configAddress, + candyMachine: candyMachineAddress, + payer: userKeyPair.publicKey, + //@ts-ignore + wallet: candyMachine.wallet, + mint: mint.publicKey, + metadata: metadataAddress, + masterEdition, + mintAuthority: userKeyPair.publicKey, + updateAuthority: userKeyPair.publicKey, + tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + remainingAccounts, + }), + ); + + if (tokenAccount) { + instructions.push( + Token.createRevokeInstruction( + TOKEN_PROGRAM_ID, + tokenAccount, + userKeyPair.publicKey, + [], + ), + ); + } + + return ( + await sendTransactionWithRetryWithKeypair( + anchorProgram.provider.connection, + userKeyPair, + instructions, + signers, + ) + ).txid; } diff --git a/js/packages/cli/src/commands/sign.ts b/js/packages/cli/src/commands/sign.ts index 68e6041..8c1c843 100644 --- a/js/packages/cli/src/commands/sign.ts +++ b/js/packages/cli/src/commands/sign.ts @@ -1,46 +1,35 @@ -import { Keypair, PublicKey, TransactionInstruction } from "@solana/web3.js"; -import { TOKEN_METADATA_PROGRAM_ID } from "../helpers/constants"; -import { sendTransactionWithRetryWithKeypair } from "../helpers/transactions"; -import { loadAnchorProgram, loadWalletKey } from "../helpers/accounts"; -import { Program } from "@project-serum/anchor"; +import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { TOKEN_METADATA_PROGRAM_ID } from '../helpers/constants'; +import { sendTransactionWithRetryWithKeypair } from '../helpers/transactions'; +import { loadCandyProgram, loadWalletKey } from '../helpers/accounts'; +import { Program } from '@project-serum/anchor'; +const METADATA_SIGNATURE = Buffer.from([7]); //now thats some voodoo magic. WTF metaplex? XD export async function signMetadata( metadata: string, keypair: string, - env: string + env: string, ) { const creatorKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(creatorKeyPair, env); - await signWithRetry(anchorProgram, creatorKeyPair, metadata); + const anchorProgram = await loadCandyProgram(creatorKeyPair, env); + await signWithRetry(anchorProgram, creatorKeyPair, new PublicKey(metadata)); } -export async function signAllUnapprovedMetadata( - keypair: string, - env: string +async function signWithRetry( + anchorProgram: Program, + creatorKeyPair: Keypair, + metadataAddress: PublicKey, ) { - const creatorKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(creatorKeyPair, env); - const metadataIds = await findAllUnapprovedMetadataIds(anchorProgram, creatorKeyPair); - - for(const id in metadataIds) { - await signWithRetry(anchorProgram, creatorKeyPair, id); - } -} - - -// @ts-ignore -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function findAllUnapprovedMetadataIds(anchorProgram: Program, creatorKeyPair: Keypair): Promise { - //TODO well I need some help with that... so... help? :D - throw new Error("Unsupported yet") -} - -async function signWithRetry(anchorProgram: Program, creatorKeyPair: Keypair, metadataAddress: string) { await sendTransactionWithRetryWithKeypair( anchorProgram.provider.connection, creatorKeyPair, - [signMetadataInstruction(new PublicKey(metadataAddress), creatorKeyPair.publicKey)], + [ + signMetadataInstruction( + new PublicKey(metadataAddress), + creatorKeyPair.publicKey, + ), + ], [], 'single', ); @@ -50,7 +39,7 @@ export function signMetadataInstruction( metadata: PublicKey, creator: PublicKey, ): TransactionInstruction { - const data = Buffer.from([7]); //now thats bloody magic. WTF metaplex? XD + const data = METADATA_SIGNATURE; const keys = [ { diff --git a/js/packages/cli/src/commands/signAll.ts b/js/packages/cli/src/commands/signAll.ts index 8524d82..5b3dc32 100644 --- a/js/packages/cli/src/commands/signAll.ts +++ b/js/packages/cli/src/commands/signAll.ts @@ -1,18 +1,26 @@ -import { Keypair, PublicKey, TransactionInstruction, Connection, AccountInfo } from '@solana/web3.js'; -import { sendTransactionWithRetryWithKeypair } from '../helpers/transactions'; -import * as borsh from "borsh" import { - MAX_NAME_LENGTH, - MAX_URI_LENGTH, - MAX_SYMBOL_LENGTH, + AccountInfo, + Connection, + Keypair, + PublicKey, + TransactionInstruction, +} from '@solana/web3.js'; +import { sendTransactionWithRetryWithKeypair } from '../helpers/transactions'; +import * as borsh from 'borsh'; +import { MAX_CREATOR_LEN, + MAX_NAME_LENGTH, + MAX_SYMBOL_LENGTH, + MAX_URI_LENGTH, TOKEN_METADATA_PROGRAM_ID, } from '../helpers/constants'; -import { - AccountAndPubkey, - Metadata, - METADATA_SCHEMA -} from '../types' +import { AccountAndPubkey, Metadata, METADATA_SCHEMA } from '../types'; +import { signMetadataInstruction } from './sign'; +import log from 'loglevel'; +import { sleep } from '../helpers/various'; + +const SIGNING_INTERVAL = 60 * 1000; //60s +let lastCount = 0; /* Get accounts by candy machine creator address Get only verified ones @@ -21,10 +29,108 @@ import { PS: Don't sign candy machine addresses that you do not know about. Signing verifies your participation. */ -async function decodeMetadata(buffer) { - const metadata = borsh.deserializeUnchecked(METADATA_SCHEMA, Metadata, buffer); - return metadata; -}; +export async function signAllMetadataFromCandyMachine( + connection: Connection, + wallet: Keypair, + candyMachineAddress: string, + batchSize: number, + daemon: boolean, +) { + if (daemon) { + // noinspection InfiniteLoopJS + for (;;) { + await findAndSignMetadata( + candyMachineAddress, + connection, + wallet, + batchSize, + ); + await sleep(SIGNING_INTERVAL); + } + } else { + await findAndSignMetadata( + candyMachineAddress, + connection, + wallet, + batchSize, + ); + } +} + +async function findAndSignMetadata( + candyMachineAddress: string, + connection: Connection, + wallet: Keypair, + batchSize: number, +) { + const metadataByCandyMachine = await getAccountsByCreatorAddress( + candyMachineAddress, + connection, + ); + if (lastCount === metadataByCandyMachine.length) { + log.debug(`Didn't find any new NFTs to sign - ${new Date()}`); + return; + } + lastCount = metadataByCandyMachine.length; + log.info( + `Found ${metadataByCandyMachine.length} nft's minted by candy machine ${candyMachineAddress}`, + ); + const candyVerifiedListToSign = await getCandyMachineVerifiedMetadata( + metadataByCandyMachine, + candyMachineAddress, + wallet.publicKey.toBase58(), + ); + log.info( + `Found ${ + candyVerifiedListToSign.length + } nft's to sign by ${wallet.publicKey.toBase58()}`, + ); + await sendSignMetadata( + connection, + wallet, + candyVerifiedListToSign, + batchSize, + ); +} + +async function getAccountsByCreatorAddress(creatorAddress, connection) { + const metadataAccounts = await getProgramAccounts( + connection, + TOKEN_METADATA_PROGRAM_ID.toBase58(), + { + filters: [ + { + memcmp: { + offset: + 1 + // key + 32 + // update auth + 32 + // mint + 4 + // name string length + MAX_NAME_LENGTH + // name + 4 + // uri string length + MAX_URI_LENGTH + // uri* + 4 + // symbol string length + MAX_SYMBOL_LENGTH + // symbol + 2 + // seller fee basis points + 1 + // whether or not there is a creators vec + 4 + // creators vec length + 0 * MAX_CREATOR_LEN, + bytes: creatorAddress, + }, + }, + ], + }, + ); + const decodedAccounts = []; + for (let i = 0; i < metadataAccounts.length; i++) { + const e = metadataAccounts[i]; + const decoded = await decodeMetadata(e.account.data); + const accountPubkey = e.pubkey; + const store = [decoded, accountPubkey]; + decodedAccounts.push(store); + } + return decodedAccounts; +} async function getProgramAccounts( connection: Connection, @@ -80,134 +186,70 @@ async function getProgramAccounts( return data; } -export async function signAllMetadataFromCandyMachine( - connection, - wallet, - candyMachineAddress, - batchSize - ){ - let metadataByCandyMachine = await getAccountsByCreatorAddress(candyMachineAddress, connection) - console.log(`Found ${metadataByCandyMachine.length} nft's minted by candy machine ${candyMachineAddress}`) - let candyVerifiedListToSign = await getCandyMachineVerifiedMetadata(metadataByCandyMachine, candyMachineAddress, wallet.publicKey.toBase58()) - console.log(`Found ${candyVerifiedListToSign.length} nft's to sign by ${wallet.publicKey.toBase58()}`) - await sendSignMetadata(connection, wallet, candyVerifiedListToSign, batchSize) +async function decodeMetadata(buffer) { + return borsh.deserializeUnchecked(METADATA_SCHEMA, Metadata, buffer); } -async function getAccountsByCreatorAddress(creatorAddress, connection) { - let metadataAccounts = await getProgramAccounts(connection, TOKEN_METADATA_PROGRAM_ID.toBase58(), { - filters: [ - { - memcmp: { - offset: - 1 + // key - 32 + // update auth - 32 + // mint - 4 + // name string length - MAX_NAME_LENGTH + // name - 4 + // uri string length - MAX_URI_LENGTH + // uri* - 4 + // symbol string length - MAX_SYMBOL_LENGTH + // symbol - 2 + // seller fee basis points - 1 + // whether or not there is a creators vec - 4 + // creators vec length - 0 * MAX_CREATOR_LEN, - bytes: creatorAddress, - }, - }, - ], - }) - let decodedAccounts = [] - for (let i = 0; i < metadataAccounts.length; i++) { - let e = metadataAccounts[i]; - let decoded = await decodeMetadata(e.account.data) - let accountPubkey = e.pubkey - let store = [decoded, accountPubkey] - decodedAccounts.push(store) - } - return decodedAccounts -} - -async function getCandyMachineVerifiedMetadata(metadataList, candyAddress, creatorAddress){ - let verifiedList = []; +async function getCandyMachineVerifiedMetadata( + metadataList, + candyAddress, + creatorAddress, +) { + const verifiedList = []; metadataList.forEach(meta => { let verifiedCandy = false; let verifiedCreator = true; meta[0].data.creators.forEach(creator => { - if (new PublicKey(creator.address).toBase58() == candyAddress && creator.verified === 1) { + if ( + new PublicKey(creator.address).toBase58() == candyAddress && + creator.verified === 1 + ) { verifiedCandy = true; } - if (new PublicKey(creator.address).toBase58() == creatorAddress && creator.verified === 0) { + if ( + new PublicKey(creator.address).toBase58() == creatorAddress && + creator.verified === 0 + ) { verifiedCreator = false; } }); - if(verifiedCandy && !verifiedCreator){ - verifiedList.push(meta) + if (verifiedCandy && !verifiedCreator) { + verifiedList.push(meta); } }); - return verifiedList + return verifiedList; } -async function sendSignMetadata( - connection, - wallet, - metadataList, - batchsize -) { +async function sendSignMetadata(connection, wallet, metadataList, batchsize) { let total = 0; - while(metadataList.length > 0){ - console.log("Signing metadata") + while (metadataList.length > 0) { + log.debug('Signing metadata '); let sliceAmount = batchsize; if (metadataList.length < batchsize) { sliceAmount = metadataList.length; } - var removed = metadataList.splice(0,sliceAmount); + const removed = metadataList.splice(0, sliceAmount); total += sliceAmount; - await delay(500) - await signMetadataBatch(removed, connection, wallet) - console.log(`Processed ${total} nfts`) + await delay(500); + await signMetadataBatch(removed, connection, wallet); + log.debug(`Processed ${total} nfts`); } - console.log("Finished signing metadata..") + log.info(`Finished signing metadata for ${total} NFTs`); } -async function signMetadataBatch(metadataList, connection, keypair){ - - const signers: Keypair[] = []; - const instructions: TransactionInstruction[] = []; - for (let i = 0; i < metadataList.length; i++) { - const meta = metadataList[i]; - await signMetadataSingle(meta[1], keypair.publicKey.toBase58(), instructions) - } - await sendTransactionWithRetryWithKeypair(connection, keypair, instructions, [], 'single') -} - -async function signMetadataSingle( - metadata, - creator, - instructions, -) { - const data = Buffer.from([7]); - const keys = [ - { - pubkey: new PublicKey(metadata), - isSigner: false, - isWritable: true, - }, - { - pubkey: new PublicKey(creator), - isSigner: true, - isWritable: false, - }, - ]; - instructions.push( - ({ - keys, - programId: TOKEN_METADATA_PROGRAM_ID.toBase58(), - data, - }), +async function signMetadataBatch(metadataList, connection, keypair) { + const instructions: TransactionInstruction[] = metadataList.map(meta => { + return signMetadataInstruction(new PublicKey(meta[1]), keypair.publicKey); + }); + await sendTransactionWithRetryWithKeypair( + connection, + keypair, + instructions, + [], + 'single', ); } function delay(ms: number) { - return new Promise( resolve => setTimeout(resolve, ms) ); + return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/js/packages/cli/src/commands/upload.ts b/js/packages/cli/src/commands/upload.ts index e6c60ea..d4f7234 100644 --- a/js/packages/cli/src/commands/upload.ts +++ b/js/packages/cli/src/commands/upload.ts @@ -1,17 +1,29 @@ -import { EXTENSION_PNG, ARWEAVE_PAYMENT_WALLET } from "../helpers/constants"; -import path from "path"; -import { createConfig, loadAnchorProgram, loadWalletKey } from "../helpers/accounts"; -import { PublicKey } from "@solana/web3.js"; -import fs from "fs"; -import BN from "bn.js"; -import * as anchor from "@project-serum/anchor"; -import { sendTransactionWithRetryWithKeypair } from "../helpers/transactions"; -import FormData from "form-data"; -import { loadCache, saveCache } from "../helpers/cache"; -import fetch from 'node-fetch'; -import log from "loglevel"; +import { EXTENSION_PNG } from '../helpers/constants'; +import path from 'path'; +import { + createConfig, + loadCandyProgram, + loadWalletKey, +} from '../helpers/accounts'; +import { PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import BN from 'bn.js'; +import { loadCache, saveCache } from '../helpers/cache'; +import log from 'loglevel'; +import { arweaveUpload } from '../helpers/upload/arweave'; +import { ipfsCreds, ipfsUpload } from '../helpers/upload/ipfs'; +import { chunks } from '../helpers/various'; -export async function upload(files: string[], cacheName: string, env: string, keypair: string, totalNFTs: number): Promise { +export async function upload( + files: string[], + cacheName: string, + env: string, + keypair: string, + totalNFTs: number, + storage: string, + retainAuthority: boolean, + ipfsCredentials: ipfsCreds, +): Promise { let uploadSuccessful = true; const savedContent = loadCache(cacheName, env); @@ -48,7 +60,7 @@ export async function upload(files: string[], cacheName: string, env: string, ke const SIZE = images.length; const walletKeyPair = loadWalletKey(keypair); - const anchorProgram = await loadAnchorProgram(walletKeyPair, env); + const anchorProgram = await loadCandyProgram(walletKeyPair, env); let config = cacheContent.program.config ? new PublicKey(cacheContent.program.config) @@ -64,8 +76,6 @@ export async function upload(files: string[], cacheName: string, env: string, ke log.info(`Processing file: ${i}`); } - const storageCost = 10; - let link = cacheContent?.items?.[index]?.link; if (!link || !cacheContent.program.uuid) { const manifestPath = image.replace(EXTENSION_PNG, '.json'); @@ -80,7 +90,7 @@ export async function upload(files: string[], cacheName: string, env: string, ke if (i === 0 && !cacheContent.program.uuid) { // initialize config - log.info(`initializing config`) + log.info(`initializing config`); try { const res = await createConfig(anchorProgram, walletKeyPair, { maxNumberOfLines: new BN(totalNFTs), @@ -88,7 +98,7 @@ export async function upload(files: string[], cacheName: string, env: string, ke sellerFeeBasisPoints: manifest.seller_fee_basis_points, isMutable: true, maxSupply: new BN(0), - retainAuthority: true, + retainAuthority: retainAuthority, creators: manifest.properties.creators.map(creator => { return { address: new PublicKey(creator.address), @@ -101,7 +111,9 @@ export async function upload(files: string[], cacheName: string, env: string, ke cacheContent.program.config = res.config.toBase58(); config = res.config; - log.info(`initialized config for a candy machine with publickey: ${res.config.toBase58()}`) + log.info( + `initialized config for a candy machine with publickey: ${res.config.toBase58()}`, + ); saveCache(cacheName, env, cacheContent); } catch (exx) { @@ -111,47 +123,31 @@ export async function upload(files: string[], cacheName: string, env: string, ke } if (!link) { - const instructions = [ - anchor.web3.SystemProgram.transfer({ - fromPubkey: walletKeyPair.publicKey, - toPubkey: ARWEAVE_PAYMENT_WALLET, - lamports: storageCost, - }), - ]; - - const tx = await sendTransactionWithRetryWithKeypair( - anchorProgram.provider.connection, - walletKeyPair, - instructions, - [], - 'single', - ); - log.debug('transaction for arweave payment:', tx); - - // data.append('tags', JSON.stringify(tags)); - // payment transaction - const data = new FormData(); - data.append('transaction', tx['txid']); - data.append('env', env); - data.append('file[]', fs.createReadStream(image), {filename: `image.png`, contentType: 'image/png'}); - data.append('file[]', manifestBuffer, 'metadata.json'); try { - const result = await uploadToArweave(data, manifest, index); - - const metadataFile = result.messages?.find( - m => m.filename === 'manifest.json', - ); - if (metadataFile?.transactionId) { - link = `https://arweave.net/${metadataFile.transactionId}`; - log.debug(`File uploaded: ${link}`); + if (storage === 'arweave') { + link = await arweaveUpload( + walletKeyPair, + anchorProgram, + env, + image, + manifestBuffer, + manifest, + index, + ); + } else if (storage === 'ipfs') { + link = await ipfsUpload(ipfsCredentials, image, manifestBuffer); } - cacheContent.items[index] = { - link, - name: manifest.name, - onChain: false, - }; - saveCache(cacheName, env, cacheContent); + if (link) { + log.debug('setting cache for ', index); + cacheContent.items[index] = { + link, + name: manifest.name, + onChain: false, + }; + cacheContent.authority = walletKeyPair.publicKey.toBase58(); + saveCache(cacheName, env, cacheContent); + } } catch (er) { uploadSuccessful = false; log.error(`Error uploading file ${index}`, er); @@ -160,7 +156,6 @@ export async function upload(files: string[], cacheName: string, env: string, ke } } - const keys = Object.keys(cacheContent.items); try { await Promise.all( @@ -179,7 +174,9 @@ export async function upload(files: string[], cacheName: string, env: string, ke const ind = keys[indexes[0]]; if (onChain.length != indexes.length) { - log.info(`Writing indices ${ind}-${keys[indexes[indexes.length - 1]]}`); + log.info( + `Writing indices ${ind}-${keys[indexes[indexes.length - 1]]}`, + ); try { await anchorProgram.rpc.addConfigLines( ind, @@ -203,7 +200,12 @@ export async function upload(files: string[], cacheName: string, env: string, ke }); saveCache(cacheName, env, cacheContent); } catch (e) { - log.error(`saving config line ${ind}-${keys[indexes[indexes.length - 1]]} failed`, e); + log.error( + `saving config line ${ind}-${ + keys[indexes[indexes.length - 1]] + } failed`, + e, + ); uploadSuccessful = false; } } @@ -219,23 +221,3 @@ export async function upload(files: string[], cacheName: string, env: string, ke console.log(`Done. Successful = ${uploadSuccessful}.`); return uploadSuccessful; } - -async function uploadToArweave(data: FormData, manifest, index) { - log.debug(`trying to upload ${index}.png: ${manifest.name}`) - return await ( - await fetch( - 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile4', - { - method: 'POST', - // @ts-ignore - body: data, - }, - ) - ).json(); -} - -function chunks(array, size) { - return Array.apply(0, new Array(Math.ceil(array.length / size))).map( - (_, index) => array.slice(index * size, (index + 1) * size), - ); -} diff --git a/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/invalidSchema/invalid-address.json b/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/invalidSchema/invalid-address.json new file mode 100644 index 0000000..6b5e0f7 --- /dev/null +++ b/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/invalidSchema/invalid-address.json @@ -0,0 +1,16 @@ +{ + "name": "Invalid shares", + "description": "", + "image": "0.png", + "external_url": "", + "seller_fee_basis_points": 0, + "properties": { + "files": [{ "uri": "0.png", "type": "image/png" }], + "creators": [ + { + "address": "111111111111111111111111111111", + "share": 100 + } + ] + } +} diff --git a/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/invalidSchema/invalid-shares.json b/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/invalidSchema/invalid-shares.json new file mode 100644 index 0000000..e773bf6 --- /dev/null +++ b/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/invalidSchema/invalid-shares.json @@ -0,0 +1,16 @@ +{ + "name": "Invalid shares", + "description": "", + "image": "0.png", + "external_url": "", + "seller_fee_basis_points": 0, + "properties": { + "files": [{ "uri": "0.png", "type": "image/png" }], + "creators": [ + { + "address": "111111111111111111111111111111111", + "share": 0 + } + ] + } +} diff --git a/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/mismatchedAssets/0.json b/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/mismatchedAssets/0.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/js/packages/cli/src/commands/verifyTokenMetadata/__fixtures__/mismatchedAssets/0.json @@ -0,0 +1 @@ +{} diff --git a/js/packages/cli/src/commands/verifyTokenMetadata/__tests__/__snapshots__/verifyTokenMetadata.ts.snap b/js/packages/cli/src/commands/verifyTokenMetadata/__tests__/__snapshots__/verifyTokenMetadata.ts.snap new file mode 100644 index 0000000..ab8ac3a --- /dev/null +++ b/js/packages/cli/src/commands/verifyTokenMetadata/__tests__/__snapshots__/verifyTokenMetadata.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`\`metaplex verify_token_metadata\` invalidates ../__fixtures__/invalidSchema/invalid-address.json 1`] = `"does not match pattern \\"[1-9A-HJ-NP-Za-km-z]{32,44}\\""`; + +exports[`\`metaplex verify_token_metadata\` invalidates ../__fixtures__/invalidSchema/invalid-shares.json 1`] = `"must be strictly greater than 0"`; diff --git a/js/packages/cli/src/commands/verifyTokenMetadata/__tests__/verifyTokenMetadata.ts b/js/packages/cli/src/commands/verifyTokenMetadata/__tests__/verifyTokenMetadata.ts new file mode 100644 index 0000000..f050150 --- /dev/null +++ b/js/packages/cli/src/commands/verifyTokenMetadata/__tests__/verifyTokenMetadata.ts @@ -0,0 +1,103 @@ +import fs from 'fs'; +import path from 'path'; +import log from 'loglevel'; +import { + verifyTokenMetadata, + verifyAggregateShare, + verifyImageURL, + verifyConsistentShares, + verifyCreatorCollation, +} from '../index'; + +const getFiles = rootDir => { + const assets = fs.readdirSync(rootDir).map(file => path.join(rootDir, file)); + return assets; +}; + +describe('`metaplex verify_token_metadata`', () => { + const spy = jest.spyOn(log, 'warn'); + beforeEach(() => { + spy.mockClear(); + }); + + it('catches mismatched assets', () => { + const mismatchedAssets = getFiles( + path.join(__dirname, '../__fixtures__/mismatchedAssets'), + ); + expect(() => + verifyTokenMetadata({ files: mismatchedAssets }), + ).toThrowErrorMatchingInlineSnapshot( + `"number of png files (0) is different than the number of json files (1)"`, + ); + }); + + const invalidSchemas = getFiles( + path.join(__dirname, '../__fixtures__/invalidSchema'), + ); + invalidSchemas.forEach(invalidSchema => { + it(`invalidates ${path.relative(__dirname, invalidSchema)}`, () => { + expect(() => + verifyTokenMetadata({ + files: [invalidSchema, invalidSchema.replace('.json', '.png')], + }), + ).toThrowErrorMatchingSnapshot(); + }); + }); + + it('throws on invalid share allocation', () => { + expect(() => + verifyAggregateShare( + [{ address: 'some-solana-address', share: 80 }], + 'placeholder-manifest-file', + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Creator share for placeholder-manifest-file does not add up to 100, got: 80."`, + ); + + expect(() => + verifyAggregateShare( + [ + { address: 'some-solana-address', share: 80 }, + { + address: 'some-other-solana-address', + share: 19.9, + }, + ], + + 'placeholder-manifest-file', + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Creator share for placeholder-manifest-file does not add up to 100, got: 99.9."`, + ); + }); + + it('warns when using different image URIs', () => { + verifyImageURL( + 'https://google.com?ext=png', + [{ uri: 'https://google.com?ext=png', type: 'image/png' }], + '0.json', + ); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('warns when there are inconsistent share allocations', () => { + const collatedCreators = new Map([ + ['some-solana-address', { shares: new Set([70]), tokenCount: 10 }], + ]); + verifyCreatorCollation( + [{ address: 'some-solana-address', share: 80 }], + collatedCreators, + '0.json', + ); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('warns when there are inconsistent creator allocations', () => { + const collatedCreators = new Map([ + ['some-solana-address', { shares: new Set([80]), tokenCount: 10 }], + ['some-other-solana-address', { shares: new Set([80]), tokenCount: 20 }], + ]); + verifyConsistentShares(collatedCreators); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/packages/cli/src/commands/verifyTokenMetadata/index.ts b/js/packages/cli/src/commands/verifyTokenMetadata/index.ts new file mode 100644 index 0000000..9cd554a --- /dev/null +++ b/js/packages/cli/src/commands/verifyTokenMetadata/index.ts @@ -0,0 +1,163 @@ +import path from 'path'; +import log from 'loglevel'; +import { validate } from 'jsonschema'; + +import { EXTENSION_JSON, EXTENSION_PNG } from '../../helpers/constants'; +import tokenMetadataJsonSchema from './token-metadata.schema.json'; + +type TokenMetadata = { + image: string; + properties: { + files: { uri: string; type: string }[]; + creators: { address: string; share: number }[]; + }; +}; + +export const verifyAssets = ({ files, uploadElementsCount }) => { + const pngFileCount = files.filter(it => { + return it.endsWith(EXTENSION_PNG); + }).length; + const jsonFileCount = files.filter(it => { + return it.endsWith(EXTENSION_JSON); + }).length; + + const parsedNumber = parseInt(uploadElementsCount, 10); + const elemCount = parsedNumber ?? pngFileCount; + + if (pngFileCount !== jsonFileCount) { + throw new Error( + `number of png files (${pngFileCount}) is different than the number of json files (${jsonFileCount})`, + ); + } + + if (elemCount < pngFileCount) { + throw new Error( + `max number (${elemCount}) cannot be smaller than the number of elements in the source folder (${pngFileCount})`, + ); + } + + log.info(`Verifying token metadata for ${pngFileCount} (png+json) pairs`); +}; + +export const verifyAggregateShare = ( + creators: TokenMetadata['properties']['creators'], + manifestFile, +) => { + const aggregateShare = creators + .map(creator => creator.share) + .reduce((memo, share) => { + return memo + share; + }, 0); + // Check that creator share adds up to 100 + if (aggregateShare !== 100) { + throw new Error( + `Creator share for ${manifestFile} does not add up to 100, got: ${aggregateShare}.`, + ); + } +}; + +type CollatedCreators = Map< + string, + { shares: Set; tokenCount: number } +>; +export const verifyCreatorCollation = ( + creators: TokenMetadata['properties']['creators'], + collatedCreators: CollatedCreators, + manifestFile: string, +) => { + for (const { address, share } of creators) { + if (collatedCreators.has(address)) { + const creator = collatedCreators.get(address); + creator.shares.add(share); + if (creator.shares.size > 1) { + log.warn( + `The creator share for ${address} in ${manifestFile} is different than the share declared for a previous token. This means at least one token is inconsistently configured, but we will continue. `, + ); + } + creator.tokenCount += 1; + } else { + collatedCreators.set(address, { + tokenCount: 1, + shares: new Set([share]), + }); + } + } +}; + +export const verifyImageURL = (image, files, manifestFile) => { + const expectedImagePath = `image${EXTENSION_PNG}`; + if (image !== expectedImagePath) { + // We _could_ match against this in the JSON schema validation, but it is totally valid to have arbitrary URLs to images here. + // The downside, though, is that those images will not get uploaded to Arweave since they're not on-disk. + log.warn(`We expected the \`image\` property in ${manifestFile} to be ${expectedImagePath}. +This will still work properly (assuming the URL is valid!), however, this image will not get uploaded to Arweave through the \`metaplex upload\` command. +If you want us to take care of getting this into Arweave, make sure to set \`image\`: "${expectedImagePath}" +The \`metaplex upload\` command will automatically substitute this URL with the Arweave URL location. + `); + } + const pngFiles = files.filter(file => file.type === 'image/png'); + if (pngFiles.length === 0 || !pngFiles.some(file => file.uri === image)) { + throw new Error( + `At least one entry with the \`image/png\` type in the \`properties.files\` array is expected to match the \`image\` property.`, + ); + } +}; + +export const verifyConsistentShares = (collatedCreators: CollatedCreators) => { + // We expect all creators to have been added to the same amount of tokens + const tokenCountSet = new Set(); + for (const [address, collation] of collatedCreators.entries()) { + tokenCountSet.add(collation.tokenCount); + if (tokenCountSet.size > 1) { + log.warn( + `We found that ${address} was added to more tokens than other creators.`, + ); + } + } +}; + +export const verifyMetadataManifests = ({ files }) => { + const manifestFiles = files.filter( + file => path.extname(file) === EXTENSION_JSON, + ); + + // Used to keep track of the share allocations for individual creators + // We will send a warning if we notice discrepancies across the entire collection. + const collatedCreators: CollatedCreators = new Map(); + + // Do manifest-specific stuff here + for (const manifestFile of manifestFiles) { + // Check the overall schema shape. This is a non-exhaustive check, but guarantees the bare minimum needed for the rest of the commands to succeed. + const tokenMetadata = require(manifestFile) as TokenMetadata; + validate(tokenMetadata, tokenMetadataJsonSchema, { throwError: true }); + + const { + properties: { creators }, + } = tokenMetadata; + verifyAggregateShare(creators, manifestFile); + + verifyCreatorCollation(creators, collatedCreators, manifestFile); + + // Check that the `image` and at least one of the files has a URI matching the index of this token. + const { + image, + properties: { files }, + } = tokenMetadata; + verifyImageURL(image, files, manifestFile); + } + + verifyConsistentShares(collatedCreators); +}; + +export const verifyTokenMetadata = ({ + files, + uploadElementsCount = null, +}): Boolean => { + // Will we need to deal with the cache? + + verifyAssets({ files, uploadElementsCount }); + + verifyMetadataManifests({ files }); + + return true; +}; diff --git a/js/packages/cli/src/commands/verifyTokenMetadata/token-metadata.schema.json b/js/packages/cli/src/commands/verifyTokenMetadata/token-metadata.schema.json new file mode 100644 index 0000000..2e66f18 --- /dev/null +++ b/js/packages/cli/src/commands/verifyTokenMetadata/token-metadata.schema.json @@ -0,0 +1,67 @@ +{ + "title": "Token Metadata", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Identifies the asset to which this token represents" + }, + "description": { + "type": "string", + "description": "Describes the asset to which this token represents" + }, + "image": { + "type": "string", + "description": "A URI pointing to a resource with mime type image/* representing the asset to which this token represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive." + }, + "external_url": { + "type": "string", + "description": "A URI pointing to an external resource that will take user outside of the platform." + }, + "seller_fee_basis_points": { + "type": "number", + "description": "Royalties percentage awarded to creators, represented as a 'basis point' (i.e., multiple the percentage by 100: 75% = 7500)", + "minimum": 0, + "maximum": 10000 + }, + "properties": { + "type": "object", + "description": "Arbitrary properties. Values may be strings, numbers, object or arrays.", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uri": { "type": "string" }, + "type": { + "type": "string", + "description": "The MIME type for this file" + } + } + } + }, + "creators": { + "type": "array", + "description": "Contains list of creators, each with Solana address and share of the NFT", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "A Solana address", + "pattern": "[1-9A-HJ-NP-Za-km-z]{32,44}" + }, + "share": { + "type": "number", + "description": "Percentage of royalties to send to this address, represented as a percentage (0-100). The sum of all shares must equal 100", + "exclusiveMinimum": 0, + "maximum": 100 + } + } + } + } + } + } + } +} diff --git a/js/packages/cli/src/fair-launch-cli.ts b/js/packages/cli/src/fair-launch-cli.ts new file mode 100755 index 0000000..5bfd179 --- /dev/null +++ b/js/packages/cli/src/fair-launch-cli.ts @@ -0,0 +1,2337 @@ +#!/usr/bin/env node +import * as fs from 'fs'; +import { program } from 'commander'; +import * as anchor from '@project-serum/anchor'; +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; +import { Token, MintLayout } from '@solana/spl-token'; +import { + CACHE_PATH, + FAIR_LAUNCH_PROGRAM_ID, + TOKEN_METADATA_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from './helpers/constants'; +import { + loadFairLaunchProgram, + loadWalletKey, + getTokenMint, + getFairLaunch, + getTreasury, + getFairLaunchTicket, + getAtaForMint, + getFairLaunchTicketSeqLookup, + getFairLaunchLotteryBitmap, + getMetadata, +} from './helpers/accounts'; +import { chunks, getMultipleAccounts, sleep } from './helpers/various'; +import { createAssociatedTokenAccountInstruction } from './helpers/instructions'; +import { sendTransactionWithRetryWithKeypair } from './helpers/transactions'; +program.version('0.0.1'); + +if (!fs.existsSync(CACHE_PATH)) { + fs.mkdirSync(CACHE_PATH); +} + +const FAIR_LAUNCH_LOTTERY_SIZE = + 8 + // discriminator + 32 + // fair launch + 1 + // bump + 8; // size of bitmask ones + +program + .command('new_fair_launch') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-u, --uuid ', 'uuid') + .option('-f, --fee ', 'fee', '2') + .option('-s, --price-range-start ', 'price range start', '1') + .option('-pe, --price-range-end ', 'price range end', '2') + .option( + '-arbp, --anti-rug-reserve-bp ', + 'optional anti-rug treasury reserve basis points (1-10000)', + ) + .option( + '-atc, --anti-rug-token-requirement ', + 'optional anti-rug token requirement when reserve opens - 100 means 100 tokens remaining out of total supply', + ) + .option( + '-sd, --self-destruct-date ', + 'optional date when funds from anti-rug setting will be returned - eg "04 Dec 1995 00:12:00 GMT"', + ) + .option( + '-pos, --phase-one-start-date ', + 'timestamp - eg "04 Dec 1995 00:12:00 GMT"', + ) + .option( + '-poe, --phase-one-end-date ', + 'timestamp - eg "04 Dec 1995 00:12:00 GMT"', + ) + .option( + '-pte, --phase-two-end-date ', + 'timestamp - eg "04 Dec 1995 00:12:00 GMT"', + ) + .option('-ld, --lottery-duration ', 'seconds eg 86400') + .option('-ts, --tick-size ', 'tick size', '0.1') + .option('-n, --number-of-tokens ', 'Number of tokens to sell') + .option( + '-mint, --treasury-mint ', + 'token mint to take as payment instead of sol', + ) + .action(async (_, cmd) => { + const { + keypair, + env, + priceRangeStart, + priceRangeEnd, + phaseOneStartDate, + phaseOneEndDate, + phaseTwoEndDate, + tickSize, + numberOfTokens, + fee, + uuid, + selfDestructDate, + antiRugTokenRequirement, + antiRugReserveBp, + lotteryDuration, + treasuryMint, + } = cmd.opts(); + + const antiRugTokenRequirementNumber = antiRugTokenRequirement + ? parseInt(antiRugTokenRequirement) + : null; + const antiRugReserveBpNumber = antiRugReserveBp + ? parseInt(antiRugReserveBp) + : null; + const selfDestructDateActual = selfDestructDate + ? Date.parse(selfDestructDate) / 1000 + : null; + + const antiRug = + antiRugTokenRequirementNumber && + antiRugReserveBpNumber && + selfDestructDateActual + ? { + reserveBp: antiRugReserveBpNumber, + tokenRequirement: new anchor.BN(antiRugTokenRequirementNumber), + selfDestructDate: new anchor.BN(selfDestructDateActual), + } + : null; + + const parsedNumber = parseInt(numberOfTokens); + let priceRangeStartNumber = parseFloat(priceRangeStart); + let priceRangeEndNumber = parseFloat(priceRangeEnd); + let tickSizeNumber = parseFloat(tickSize); + + let feeNumber = parseFloat(fee); + const realUuid = uuid.slice(0, 6); + const phaseOneStartDateActual = + (phaseOneStartDate ? Date.parse(phaseOneStartDate) : Date.now()) / 1000; + const phaseOneEndDateActual = + (phaseOneEndDate ? Date.parse(phaseOneEndDate) : Date.now() + 86400000) / + 1000; + const phaseTwoEndDateActual = + (phaseTwoEndDate + ? Date.parse(phaseTwoEndDate) + : Date.now() + 2 * 86400000) / 1000; + const lotteryDurationActual = lotteryDuration ? lotteryDuration : 86400; + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + if (!treasuryMint) { + priceRangeStartNumber = Math.ceil( + priceRangeStartNumber * LAMPORTS_PER_SOL, + ); + priceRangeEndNumber = Math.ceil(priceRangeEndNumber * LAMPORTS_PER_SOL); + tickSizeNumber = Math.ceil(tickSizeNumber * LAMPORTS_PER_SOL); + feeNumber = Math.ceil(feeNumber * LAMPORTS_PER_SOL); + } else { + const token = new Token( + anchorProgram.provider.connection, + //@ts-ignore + new anchor.web3.PublicKey(treasuryMint), + TOKEN_PROGRAM_ID, + walletKeyPair, + ); + + const mintInfo = await token.getMintInfo(); + + const mantissa = 10 ** mintInfo.decimals; + priceRangeStartNumber = Math.ceil(priceRangeStartNumber * mantissa); + priceRangeEndNumber = Math.ceil(priceRangeEndNumber * mantissa); + tickSizeNumber = Math.ceil(tickSizeNumber * mantissa); + feeNumber = Math.ceil(feeNumber * mantissa); + } + + const [tokenMint, tokenBump] = await getTokenMint( + walletKeyPair.publicKey, + realUuid, + ); + const [fairLaunch, fairLaunchBump] = await getFairLaunch(tokenMint); + const [treasury, treasuryBump] = await getTreasury(tokenMint); + + const remainingAccounts = !treasuryMint + ? [] + : [ + { + pubkey: new anchor.web3.PublicKey(treasuryMint), + isWritable: false, + isSigner: false, + }, + ]; + await anchorProgram.rpc.initializeFairLaunch( + fairLaunchBump, + treasuryBump, + tokenBump, + { + uuid: realUuid, + priceRangeStart: new anchor.BN(priceRangeStartNumber), + priceRangeEnd: new anchor.BN(priceRangeEndNumber), + phaseOneStart: new anchor.BN(phaseOneStartDateActual), + phaseOneEnd: new anchor.BN(phaseOneEndDateActual), + phaseTwoEnd: new anchor.BN(phaseTwoEndDateActual), + lotteryDuration: new anchor.BN(lotteryDurationActual), + tickSize: new anchor.BN(tickSizeNumber), + numberOfTokens: new anchor.BN(parsedNumber), + fee: new anchor.BN(feeNumber), + antiRugSetting: antiRug, + }, + { + accounts: { + fairLaunch, + tokenMint, + treasury, + authority: walletKeyPair.publicKey, + payer: walletKeyPair.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + remainingAccounts, + signers: [], + }, + ); + + console.log(`create fair launch Done: ${fairLaunch.toBase58()}`); + }); + +program + .command('update_fair_launch') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-u, --uuid ', 'uuid') + .option('-f, --fee ', 'price range end', '2') + .option('-s, --price-range-start ', 'price range start', '1') + .option('-pe, --price-range-end ', 'price range end', '2') + .option( + '-arbp, --anti-rug-reserve-bp ', + 'optional anti-rug treasury reserve basis points (1-10000)', + ) + .option( + '-atc, --anti-rug-token-requirement ', + 'optional anti-rug token requirement when reserve opens - 100 means 100 tokens remaining out of total supply', + ) + .option( + '-sd, --self-destruct-date ', + 'optional date when funds from anti-rug setting will be returned - eg "04 Dec 1995 00:12:00 GMT"', + ) + .option( + '-pos, --phase-one-start-date ', + 'timestamp - eg "04 Dec 1995 00:12:00 GMT"', + ) + .option( + '-poe, --phase-one-end-date ', + 'timestamp - eg "04 Dec 1995 00:12:00 GMT"', + ) + .option('-ld, --lottery-duration ', 'seconds eg 86400') + .option( + '-pte, --phase-two-end-date ', + 'timestamp - eg "04 Dec 1995 00:12:00 GMT"', + ) + .option('-ts, --tick-size ', 'tick size', '0.1') + .option('-n, --number-of-tokens ', 'Number of tokens to sell') + .option( + '-mint, --token-mint ', + 'token mint to take as payment instead of sol', + ) + .action(async (_, cmd) => { + const { + keypair, + env, + priceRangeStart, + priceRangeEnd, + phaseOneStartDate, + phaseOneEndDate, + phaseTwoEndDate, + tickSize, + numberOfTokens, + fee, + mint, + uuid, + selfDestructDate, + antiRugTokenRequirement, + antiRugReserveBp, + lotteryDuration, + } = cmd.opts(); + const antiRugTokenRequirementNumber = antiRugTokenRequirement + ? parseInt(antiRugTokenRequirement) + : null; + const antiRugReserveBpNumber = antiRugReserveBp + ? parseInt(antiRugReserveBp) + : null; + const selfDestructDateActual = selfDestructDate + ? Date.parse(selfDestructDate) / 1000 + : null; + + const antiRug = + antiRugTokenRequirementNumber && + antiRugReserveBpNumber && + selfDestructDateActual + ? { + reserveBp: antiRugReserveBpNumber, + tokenRequirement: new anchor.BN(antiRugTokenRequirementNumber), + selfDestructDate: new anchor.BN(selfDestructDateActual), + } + : null; + const parsedNumber = parseInt(numberOfTokens); + let priceRangeStartNumber = parseFloat(priceRangeStart); + let priceRangeEndNumber = parseFloat(priceRangeEnd); + let tickSizeNumber = parseFloat(tickSize); + let feeNumber = parseFloat(fee); + const realUuid = uuid.slice(0, 6); + const phaseOneStartDateActual = + (phaseOneStartDate ? Date.parse(phaseOneStartDate) : Date.now()) / 1000; + const phaseOneEndDateActual = + (phaseOneEndDate ? Date.parse(phaseOneEndDate) : Date.now() + 86400000) / + 1000; + const phaseTwoEndDateActual = + (phaseTwoEndDate + ? Date.parse(phaseTwoEndDate) + : Date.now() + 2 * 86400000) / 1000; + const lotteryDurationActual = lotteryDuration ? lotteryDuration : 86400; + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + if (!mint) { + priceRangeStartNumber = Math.ceil( + priceRangeStartNumber * LAMPORTS_PER_SOL, + ); + priceRangeEndNumber = Math.ceil(priceRangeEndNumber * LAMPORTS_PER_SOL); + tickSizeNumber = Math.ceil(tickSizeNumber * LAMPORTS_PER_SOL); + feeNumber = Math.ceil(feeNumber * LAMPORTS_PER_SOL); + } else { + const token = new Token( + anchorProgram.provider.connection, + //@ts-ignore + fairLaunchObj.treasuryMint, + TOKEN_PROGRAM_ID, + walletKeyPair, + ); + + const mintInfo = await token.getMintInfo(); + + const mantissa = 10 ** mintInfo.decimals; + priceRangeStartNumber = Math.ceil(priceRangeStartNumber * mantissa); + priceRangeEndNumber = Math.ceil(priceRangeEndNumber * mantissa); + tickSizeNumber = Math.ceil(tickSizeNumber * mantissa); + feeNumber = Math.ceil(feeNumber * mantissa); + } + + const tokenMint = ( + await getTokenMint(walletKeyPair.publicKey, realUuid) + )[0]; + const fairLaunch = (await getFairLaunch(tokenMint))[0]; + + await anchorProgram.rpc.updateFairLaunch( + { + uuid: realUuid, + priceRangeStart: new anchor.BN(priceRangeStartNumber), + priceRangeEnd: new anchor.BN(priceRangeEndNumber), + phaseOneStart: new anchor.BN(phaseOneStartDateActual), + phaseOneEnd: new anchor.BN(phaseOneEndDateActual), + phaseTwoEnd: new anchor.BN(phaseTwoEndDateActual), + lotteryDuration: new anchor.BN(lotteryDurationActual), + tickSize: new anchor.BN(tickSizeNumber), + numberOfTokens: new anchor.BN(parsedNumber), + fee: new anchor.BN(feeNumber), + antiRugSetting: antiRug, + }, + { + accounts: { + fairLaunch, + authority: walletKeyPair.publicKey, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }, + ); + + console.log(`Updated fair launch Done: ${fairLaunch.toBase58()}`); + }); + +program + .command('purchase_ticket') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .option('-a, --amount ', 'amount') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch, amount } = cmd.opts(); + let amountNumber = parseFloat(amount); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + const [fairLaunchTicket, bump] = await getFairLaunchTicket( + //@ts-ignore + fairLaunchObj.tokenMint, + walletKeyPair.publicKey, + ); + + const remainingAccounts = []; + const instructions = []; + const signers = []; + //@ts-ignore + const tokenAta = fairLaunchObj.treasuryMint + ? ( + await getAtaForMint( + //@ts-ignore + fairLaunchObj.treasuryMint, + walletKeyPair.publicKey, + ) + )[0] + : undefined; + + //@ts-ignore + if (!fairLaunchObj.treasuryMint) { + amountNumber = Math.ceil(amountNumber * LAMPORTS_PER_SOL); + } else { + const transferAuthority = anchor.web3.Keypair.generate(); + signers.push(transferAuthority); + const token = new Token( + anchorProgram.provider.connection, + //@ts-ignore + fairLaunchObj.treasuryMint, + TOKEN_PROGRAM_ID, + walletKeyPair, + ); + const mintInfo = await token.getMintInfo(); + amountNumber = Math.ceil(amountNumber * 10 ** mintInfo.decimals); + + instructions.push( + Token.createApproveInstruction( + TOKEN_PROGRAM_ID, + //@ts-ignore + tokenAta, + transferAuthority.publicKey, + walletKeyPair.publicKey, + [], + amountNumber * 10 ** mintInfo.decimals + + //@ts-ignore + fairLaunchObj.data.fee.toNumber(), + ), + ); + + remainingAccounts.push({ + //@ts-ignore + pubkey: fairLaunchObj.treasuryMint, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: tokenAta, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: transferAuthority.publicKey, + isWritable: false, + isSigner: true, + }); + remainingAccounts.push({ + pubkey: TOKEN_PROGRAM_ID, + isWritable: false, + isSigner: false, + }); + } + + instructions.push( + await anchorProgram.instruction.purchaseTicket( + bump, + new anchor.BN(amountNumber), + { + accounts: { + fairLaunchTicket, + fairLaunch, + //@ts-ignore + treasury: fairLaunchObj.treasury, + buyer: walletKeyPair.publicKey, + payer: walletKeyPair.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + remainingAccounts, + }, + ), + ); + + if (tokenAta) { + instructions.push( + Token.createRevokeInstruction( + TOKEN_PROGRAM_ID, + tokenAta, + walletKeyPair.publicKey, + [], + ), + ); + } + + await sendTransactionWithRetryWithKeypair( + anchorProgram.provider.connection, + walletKeyPair, + instructions, + signers, + 'max', + ); + console.log( + `create fair launch ticket Done: ${fairLaunchTicket.toBase58()}. Trying to create seq now...we may or may not get a validator with data on chain. Either way, your ticket is secure.`, + ); + + let fairLaunchTicketObj; + for (let i = 0; i < 10; i++) { + await sleep(5000); + try { + fairLaunchTicketObj = + await anchorProgram.account.fairLaunchTicket.fetch(fairLaunchTicket); + break; + } catch (e) { + console.log('Not found. Trying again...'); + } + } + + const [fairLaunchTicketSeqLookup, seqBump] = + await getFairLaunchTicketSeqLookup( + //@ts-ignore + fairLaunchObj.tokenMint, + //@ts-ignore + fairLaunchTicketObj.seq, + ); + + await anchorProgram.rpc.createTicketSeq(seqBump, { + accounts: { + fairLaunchTicketSeqLookup, + fairLaunch, + fairLaunchTicket, + payer: walletKeyPair.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + signers: [], + }); + + console.log('Created seq'); + }); + +program + .command('mint_from_dummy') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option( + '-d, --destination ', + `Destination wallet location`, + '--destination not provided', + ) + .option('-a, --amount ', 'amount') + .option('-m, --mint ', 'mint') + .action(async (_, cmd) => { + const { env, keypair, amount, destination, mint } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + const amountNumber = parseFloat(amount); + const mintKey = new anchor.web3.PublicKey(mint); + const dest = new anchor.web3.PublicKey(destination); + const token = (await getAtaForMint(mintKey, dest))[0]; + const instructions = []; + const tokenApp = new Token( + anchorProgram.provider.connection, + //@ts-ignore + new anchor.web3.PublicKey(mint), + TOKEN_PROGRAM_ID, + walletKeyPair, + ); + + const mintInfo = await tokenApp.getMintInfo(); + + const mantissa = 10 ** mintInfo.decimals; + const assocToken = await anchorProgram.provider.connection.getAccountInfo( + token, + ); + if (!assocToken) { + instructions.push( + createAssociatedTokenAccountInstruction( + token, + walletKeyPair.publicKey, + dest, + mintKey, + ), + ); + } + + instructions.push( + Token.createMintToInstruction( + TOKEN_PROGRAM_ID, + mintKey, + token, + walletKeyPair.publicKey, + [], + amountNumber * mantissa, + ), + ); + + await sendTransactionWithRetryWithKeypair( + anchorProgram.provider.connection, + walletKeyPair, + instructions, + [], + 'single', + ); + console.log(`Minted ${amount} to ${token.toBase58()}.`); + }); + +program + .command('create_dummy_payment_mint') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .action(async (_, cmd) => { + const { env, keypair } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + const mint = anchor.web3.Keypair.generate(); + const token = ( + await getAtaForMint(mint.publicKey, walletKeyPair.publicKey) + )[0]; + const instructions: anchor.web3.TransactionInstruction[] = [ + anchor.web3.SystemProgram.createAccount({ + fromPubkey: walletKeyPair.publicKey, + newAccountPubkey: mint.publicKey, + space: MintLayout.span, + lamports: + await anchorProgram.provider.connection.getMinimumBalanceForRentExemption( + MintLayout.span, + ), + programId: TOKEN_PROGRAM_ID, + }), + Token.createInitMintInstruction( + TOKEN_PROGRAM_ID, + mint.publicKey, + 6, + walletKeyPair.publicKey, + walletKeyPair.publicKey, + ), + createAssociatedTokenAccountInstruction( + token, + walletKeyPair.publicKey, + walletKeyPair.publicKey, + mint.publicKey, + ), + ]; + + const signers = [mint]; + + await sendTransactionWithRetryWithKeypair( + anchorProgram.provider.connection, + walletKeyPair, + instructions, + signers, + 'single', + ); + console.log(`create mint Done: ${mint.publicKey.toBase58()}.`); + }); + +async function adjustTicket({ + amountNumber, + fairLaunchObj, + adjuster, + fairLaunch, + fairLaunchTicket, + fairLaunchLotteryBitmap, + anchorProgram, + payer, + adjustMantissa, +}: { + amountNumber: number; + fairLaunchObj: any; + payer: anchor.web3.Keypair; + adjuster: anchor.web3.PublicKey; + fairLaunch: anchor.web3.PublicKey; + fairLaunchTicket: anchor.web3.PublicKey; + fairLaunchLotteryBitmap: anchor.web3.PublicKey; + anchorProgram: anchor.Program; + adjustMantissa: boolean; +}) { + const remainingAccounts = []; + const instructions = []; + const signers = []; + //@ts-ignore + const tokenAta = fairLaunchObj.treasuryMint + ? ( + await getAtaForMint( + //@ts-ignore + fairLaunchObj.treasuryMint, + adjuster, + ) + )[0] + : undefined; + //@ts-ignore + if (!fairLaunchObj.treasuryMint) { + if (adjustMantissa) + amountNumber = Math.ceil(amountNumber * LAMPORTS_PER_SOL); + } else { + const transferAuthority = anchor.web3.Keypair.generate(); + signers.push(transferAuthority); + const token = new Token( + anchorProgram.provider.connection, + fairLaunchObj.treasuryMint, + TOKEN_PROGRAM_ID, + payer, + ); + + const mintInfo = await token.getMintInfo(); + if (adjustMantissa) + amountNumber = Math.ceil(amountNumber * 10 ** mintInfo.decimals); + if (amountNumber > 0) { + instructions.push( + Token.createApproveInstruction( + TOKEN_PROGRAM_ID, + tokenAta, + transferAuthority.publicKey, + adjuster, + [], + //@ts-ignore + amountNumber, + ), + ); + } + + remainingAccounts.push({ + //@ts-ignore + pubkey: fairLaunchObj.treasuryMint, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: tokenAta, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: transferAuthority.publicKey, + isWritable: false, + isSigner: true, + }); + remainingAccounts.push({ + pubkey: TOKEN_PROGRAM_ID, + isWritable: false, + isSigner: false, + }); + } + + instructions.push( + await anchorProgram.instruction.adjustTicket(new anchor.BN(amountNumber), { + accounts: { + fairLaunchTicket, + fairLaunch, + fairLaunchLotteryBitmap, + //@ts-ignore + treasury: fairLaunchObj.treasury, + systemProgram: anchor.web3.SystemProgram.programId, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + //__private: { logAccounts: true }, + remainingAccounts: [ + { + pubkey: adjuster, + isSigner: adjuster.equals(payer.publicKey), + isWritable: true, + }, + ...remainingAccounts, + ], + }), + ); + + //@ts-ignore + if (fairLaunchObj.treasuryMint && amountNumber > 0) { + instructions.push( + Token.createRevokeInstruction( + FAIR_LAUNCH_PROGRAM_ID, + tokenAta, + payer.publicKey, + [], + ), + ); + } + + await sendTransactionWithRetryWithKeypair( + anchorProgram.provider.connection, + payer, + instructions, + signers, + 'single', + ); + + console.log( + `update fair launch ticket Done: ${fairLaunchTicket.toBase58()}.`, + ); +} + +program + .command('set_token_metadata') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .option('-n, --name ', 'name') + .option('-s, --symbol ', 'symbol') + .option('-u, --uri ', 'uri') + .option( + '-sfbp, --seller-fee-basis-points ', + 'seller fee basis points', + ) + .option( + '-c, --creators ', + 'comma separated creator wallets like wallet1,73,true,wallet2,27,false where its wallet, then share, then verified true/false', + ) + .option('-nm, --is_not_mutable', 'is not mutable') + .action(async (_, cmd) => { + const { + env, + keypair, + fairLaunch, + name, + symbol, + uri, + sellerFeeBasisPoints, + creators, + isNotMutable, + } = cmd.opts(); + const sellerFeeBasisPointsNumber = parseInt(sellerFeeBasisPoints); + + const creatorsListPre = creators ? creators.split(',') : []; + const creatorsList = []; + for (let i = 0; i < creatorsListPre.length; i += 3) { + creatorsList.push({ + address: new anchor.web3.PublicKey(creatorsListPre[i]), + share: parseInt(creatorsListPre[i + 1]), + verified: creatorsListPre[i + 2] == 'true' ? true : false, + }); + } + const isMutableBool = isNotMutable ? false : true; + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + + await anchorProgram.rpc.setTokenMetadata( + { + name, + symbol, + uri, + sellerFeeBasisPoints: sellerFeeBasisPointsNumber, + creators: creatorsList, + isMutable: isMutableBool, + }, + { + accounts: { + fairLaunch: fairLaunchKey, + authority: walletKeyPair.publicKey, + payer: walletKeyPair.publicKey, + //@ts-ignore + metadata: await getMetadata(fairLaunchObj.tokenMint), + //@ts-ignore + tokenMint: fairLaunchObj.tokenMint, + tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }, + ); + + console.log('Set token metadata.'); + }); + +program + .command('adjust_ticket') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .option('-a, --amount ', 'amount') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch, amount } = cmd.opts(); + const amountNumber = parseFloat(amount); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + const fairLaunchTicket = ( + await getFairLaunchTicket( + //@ts-ignore + fairLaunchObj.tokenMint, + walletKeyPair.publicKey, + ) + )[0]; + + const fairLaunchLotteryBitmap = ( //@ts-ignore + await getFairLaunchLotteryBitmap(fairLaunchObj.tokenMint) + )[0]; + + await adjustTicket({ + amountNumber, + fairLaunchObj, + adjuster: walletKeyPair.publicKey, + fairLaunch, + fairLaunchTicket, + fairLaunchLotteryBitmap, + anchorProgram, + payer: walletKeyPair, + adjustMantissa: true, + }); + }); + +program + .command('punch_and_refund_all_outstanding') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch } = cmd.opts(); + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + + const fairLaunchLotteryBitmap = ( + await getFairLaunchLotteryBitmap( + //@ts-ignore + fairLaunchObj.tokenMint, + ) + )[0]; + + const fairLaunchLotteryBitmapObj = + await anchorProgram.provider.connection.getAccountInfo( + fairLaunchLotteryBitmap, + ); + + const seqKeys = []; + //@ts-ignore + for (let i = 0; i < fairLaunchObj.numberTicketsSold; i++) { + seqKeys.push( + ( + await getFairLaunchTicketSeqLookup( + //@ts-ignore + fairLaunchObj.tokenMint, + new anchor.BN(i), + ) + )[0], + ); + } + + const ticketKeys: any[][] = await Promise.all( + chunks(Array.from(Array(seqKeys.length).keys()), 1000).map( + async allIndexesInSlice => { + let ticketKeys = []; + for (let i = 0; i < allIndexesInSlice.length; i += 100) { + console.log( + 'Pulling ticket seqs for slice', + allIndexesInSlice[i], + allIndexesInSlice[i + 100], + ); + const slice = allIndexesInSlice + .slice(i, i + 100) + .map(index => seqKeys[index]); + const result = await getMultipleAccounts( + anchorProgram.provider.connection, + slice.map(s => s.toBase58()), + 'recent', + ); + ticketKeys = ticketKeys.concat( + result.array.map( + a => + new anchor.web3.PublicKey( + new Uint8Array(a.data.slice(8, 8 + 32)), + ), + ), + ); + return ticketKeys; + } + }, + ), + ); + + const ticketsFlattened = ticketKeys.flat(); + + const ticketData: { key: anchor.web3.PublicKey; model: any }[][] = + await Promise.all( + chunks(Array.from(Array(ticketsFlattened.length).keys()), 1000).map( + async allIndexesInSlice => { + let states = []; + for (let i = 0; i < allIndexesInSlice.length; i += 100) { + console.log( + 'Pulling accounts for slice', + allIndexesInSlice[i], + allIndexesInSlice[i + 100], + ); + const slice = allIndexesInSlice + .slice(i, i + 100) + .map(index => ticketsFlattened[index]); + const result = await getMultipleAccounts( + anchorProgram.provider.connection, + slice.map(s => s.toBase58()), + 'recent', + ); + states = states.concat( + result.array.map((a, i) => ({ + key: new anchor.web3.PublicKey(result.keys[i]), + model: anchorProgram.coder.accounts.decode( + 'FairLaunchTicket', + a.data, + ), + })), + ); + return states; + } + }, + ), + ); + + const ticketDataFlat = ticketData.flat(); + + await Promise.all( + chunks(Array.from(Array(ticketDataFlat.length).keys()), 1000).map( + async allIndexesInSlice => { + for (let i = 0; i < allIndexesInSlice.length; i++) { + const ticket = ticketDataFlat[allIndexesInSlice[i]]; + if (ticket.model.state.unpunched) { + if ( + ticket.model.amount.toNumber() < + //@ts-ignore + fairLaunchObj.currentMedian.toNumber() + ) { + console.log( + 'Refunding ticket for buyer', + ticket.model.buyer.toBase58(), + ); + await adjustTicket({ + amountNumber: 0, + fairLaunchObj, + adjuster: ticket.model.buyer, + fairLaunch, + fairLaunchTicket: ticket.key, + fairLaunchLotteryBitmap, + anchorProgram, + payer: walletKeyPair, + adjustMantissa: true, + }); + } else { + const myByte = + fairLaunchLotteryBitmapObj.data[ + FAIR_LAUNCH_LOTTERY_SIZE + + Math.floor(ticket.model.seq.toNumber() / 8) + ]; + + const positionFromRight = 7 - (ticket.model.seq.toNumber() % 8); + const mask = Math.pow(2, positionFromRight); + const isWinner = myByte & mask; + if (isWinner > 0) { + console.log( + 'Punching ticket for buyer', + ticket.model.buyer.toBase58(), + ); + const diff = + ticket.model.amount.toNumber() - + //@ts-ignore + fairLaunchObj.currentMedian.toNumber(); + if (diff > 0) { + console.log( + 'Refunding first', + diff, + 'to buyer before punching', + ); + await adjustTicket({ + //@ts-ignore + amountNumber: fairLaunchObj.currentMedian.toNumber(), + fairLaunchObj, + adjuster: ticket.model.buyer, + fairLaunch, + fairLaunchTicket: ticket.key, + fairLaunchLotteryBitmap, + anchorProgram, + payer: walletKeyPair, + adjustMantissa: false, + }); + } + let tries = 0; + try { + const buyerTokenAccount = await punchTicket({ + payer: walletKeyPair, + puncher: ticket.model.buyer, + anchorProgram, + fairLaunchTicket: ticket.key, + fairLaunch, + fairLaunchLotteryBitmap, + fairLaunchObj, + }); + + console.log( + `Punched ticket and placed token in new account ${buyerTokenAccount.toBase58()}.`, + ); + } catch (e) { + if (tries > 3) { + throw e; + } else { + tries++; + } + console.log('Ticket failed to punch, trying one more time'); + await sleep(1000); + } + } else { + console.log( + 'Buyer ', + ticket.model.buyer.toBase58(), + 'was eligible but lost lottery, refunding', + ); + await adjustTicket({ + //@ts-ignore + amountNumber: 0, + fairLaunchObj, + adjuster: ticket.model.buyer, + fairLaunch, + fairLaunchTicket: ticket.key, + fairLaunchLotteryBitmap, + anchorProgram, + payer: walletKeyPair, + adjustMantissa: true, + }); + console.log('Refunded.'); + } + } + } else if (ticket.model.state.withdrawn) { + console.log( + 'Buyer', + ticket.model.buyer.toBase58(), + 'withdrawn already', + ); + } else if (ticket.model.state.punched) { + console.log( + 'Buyer', + ticket.model.buyer.toBase58(), + 'punched already', + ); + } + } + }, + ), + ); + }); + +async function punchTicket({ + puncher, + payer, + anchorProgram, + fairLaunchTicket, + fairLaunch, + fairLaunchLotteryBitmap, + fairLaunchObj, +}: { + puncher: anchor.web3.PublicKey; + anchorProgram: anchor.Program; + payer: anchor.web3.Keypair; + fairLaunchTicket: anchor.web3.PublicKey; + fairLaunch: anchor.web3.PublicKey; + fairLaunchLotteryBitmap: anchor.web3.PublicKey; + fairLaunchObj: any; +}): Promise { + const buyerTokenAccount = ( + await getAtaForMint( + //@ts-ignore + fairLaunchObj.tokenMint, + puncher, + ) + )[0]; + + await anchorProgram.rpc.punchTicket({ + accounts: { + fairLaunchTicket, + fairLaunch, + fairLaunchLotteryBitmap, + payer: payer.publicKey, + buyerTokenAccount, + //@ts-ignore + tokenMint: fairLaunchObj.tokenMint, + tokenProgram: TOKEN_PROGRAM_ID, + }, + options: { + commitment: 'single', + }, + //__private: { logAccounts: true }, + instructions: [ + createAssociatedTokenAccountInstruction( + buyerTokenAccount, + payer.publicKey, + puncher, + //@ts-ignore + fairLaunchObj.tokenMint, + ), + ], + }); + + return buyerTokenAccount; +} +program + .command('punch_ticket') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + + const fairLaunchTicket = ( + await getFairLaunchTicket( + //@ts-ignore + fairLaunchObj.tokenMint, + walletKeyPair.publicKey, + ) + )[0]; + + const fairLaunchLotteryBitmap = ( //@ts-ignore + await getFairLaunchLotteryBitmap(fairLaunchObj.tokenMint) + )[0]; + + const ticket = await anchorProgram.account.fairLaunchTicket.fetch( + fairLaunchTicket, + ); + + const diff = + //@ts-ignore + ticket.amount.toNumber() - + //@ts-ignore + fairLaunchObj.currentMedian.toNumber(); + if (diff > 0) { + console.log('Refunding first', diff, 'to buyer before punching'); + await adjustTicket({ + //@ts-ignore + amountNumber: fairLaunchObj.currentMedian.toNumber(), + fairLaunchObj, + //@ts-ignore + adjuster: ticket.buyer, + fairLaunch, + fairLaunchTicket, + fairLaunchLotteryBitmap, + anchorProgram, + payer: walletKeyPair, + adjustMantissa: false, + }); + } + + let tries = 0; + try { + const buyerTokenAccount = await punchTicket({ + puncher: walletKeyPair.publicKey, + payer: walletKeyPair, + anchorProgram, + fairLaunchTicket, + fairLaunch, + fairLaunchLotteryBitmap, + fairLaunchObj, + }); + + console.log( + `Punched ticket and placed token in new account ${buyerTokenAccount.toBase58()}.`, + ); + } catch (e) { + if (tries > 3) { + throw e; + } else { + tries++; + } + console.log('Ticket failed to punch, trying one more time'); + await sleep(1000); + } + }); + +program + .command('burn_fair_launch_tokens_warning_irreversible') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .option('-n, --number ', 'number to burn') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch, number } = cmd.opts(); + + const actual = parseInt(number); + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + + const myTokenAccount = ( + await getAtaForMint( + //@ts-ignore + fairLaunchObj.tokenMint, + walletKeyPair.publicKey, + ) + )[0]; + + const instructions = [ + Token.createBurnInstruction( + TOKEN_PROGRAM_ID, + //@ts-ignore + fairLaunchObj.tokenMint, + myTokenAccount, + walletKeyPair.publicKey, + [], + actual, + ), + ]; + + await sendTransactionWithRetryWithKeypair( + anchorProgram.provider.connection, + walletKeyPair, + instructions, + [], + 'single', + ); + + console.log( + `Burned ${actual} tokens in account ${myTokenAccount.toBase58()}.`, + ); + }); + +program + .command('start_phase_three') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + const fairLaunchLotteryBitmap = ( //@ts-ignore + await getFairLaunchLotteryBitmap(fairLaunchObj.tokenMint) + )[0]; + + await anchorProgram.rpc.startPhaseThree({ + accounts: { + fairLaunch, + fairLaunchLotteryBitmap, + authority: walletKeyPair.publicKey, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }); + + console.log(`Dang son, phase three.`); + }); + +program + .command('withdraw_funds') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + + const remainingAccounts = []; + + //@ts-ignore + if (fairLaunchObj.treasuryMint) { + remainingAccounts.push({ + //@ts-ignore + pubkey: fairLaunchObj.treasuryMint, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: ( + await getAtaForMint( + //@ts-ignore + fairLaunchObj.treasuryMint, + walletKeyPair.publicKey, + ) + )[0], + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: TOKEN_PROGRAM_ID, + isWritable: false, + isSigner: false, + }); + } + + await anchorProgram.rpc.withdrawFunds({ + accounts: { + fairLaunch, + // @ts-ignore + treasury: fairLaunchObj.treasury, + authority: walletKeyPair.publicKey, + // @ts-ignore + tokenMint: fairLaunchObj.tokenMint, + systemProgram: anchor.web3.SystemProgram.programId, + }, + remainingAccounts, + }); + + console.log(`Now you rich, give me some.`); + }); + +program + .command('restart_phase_2') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + await anchorProgram.rpc.restartPhaseTwo({ + accounts: { + fairLaunch, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }); + + console.log(`Clock restart on phase 2`); + }); + +program + .command('receive_refund') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + + const buyerTokenAccount = ( + await getAtaForMint( + //@ts-ignore + fairLaunchObj.tokenMint, + walletKeyPair.publicKey, + ) + )[0]; + + const transferAuthority = anchor.web3.Keypair.generate(); + + const signers = [transferAuthority]; + const instructions = [ + Token.createApproveInstruction( + TOKEN_PROGRAM_ID, + //@ts-ignore + buyerTokenAccount, + transferAuthority.publicKey, + walletKeyPair.publicKey, + [], + //@ts-ignore + 1, + ), + ]; + + const remainingAccounts = []; + + //@ts-ignore + if (fairLaunchObj.treasuryMint) { + remainingAccounts.push({ + //@ts-ignore + pubkey: fairLaunchObj.treasuryMint, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: ( + await getAtaForMint( + //@ts-ignore + fairLaunchObj.treasuryMint, + walletKeyPair.publicKey, + ) + )[0], + isWritable: true, + isSigner: false, + }); + } + + const txid = await anchorProgram.rpc.receiveRefund({ + accounts: { + fairLaunch, + // @ts-ignore + treasury: fairLaunchObj.treasury, + buyer: walletKeyPair.publicKey, + buyerTokenAccount, + transferAuthority: transferAuthority.publicKey, + // @ts-ignore + tokenMint: fairLaunchObj.tokenMint, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + remainingAccounts, + instructions, + signers, + }); + + console.log(`You received a refund, traitor. ${txid}`); + }); + +program + .command('create_fair_launch_lottery') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch } = cmd.opts(); + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchKey = new anchor.web3.PublicKey(fairLaunch); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunchKey, + ); + + const [fairLaunchLotteryBitmap, bump] = await getFairLaunchLotteryBitmap( + //@ts-ignore + fairLaunchObj.tokenMint, + ); + + const exists = await anchorProgram.provider.connection.getAccountInfo( + fairLaunchLotteryBitmap, + ); + + if (!exists) { + await anchorProgram.rpc.createFairLaunchLotteryBitmap(bump, { + accounts: { + fairLaunch, + fairLaunchLotteryBitmap, + authority: walletKeyPair.publicKey, + payer: walletKeyPair.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }); + + console.log( + `created fair launch lottery bitmap Done: ${fairLaunchLotteryBitmap.toBase58()}.`, + ); + } else { + console.log( + `checked fair launch lottery bitmap, exists: ${fairLaunchLotteryBitmap.toBase58()}.`, + ); + } + + const seqKeys = []; + //@ts-ignore + for (let i = 0; i < fairLaunchObj.numberTicketsSold; i++) { + seqKeys.push( + ( + await getFairLaunchTicketSeqLookup( + //@ts-ignore + fairLaunchObj.tokenMint, + new anchor.BN(i), + ) + )[0], + ); + } + + const ticketKeys: anchor.web3.PublicKey[][] = await Promise.all( + chunks(Array.from(Array(seqKeys.length).keys()), 1000).map( + async allIndexesInSlice => { + let ticketKeys = []; + for (let i = 0; i < allIndexesInSlice.length; i += 100) { + console.log( + 'Pulling ticket seqs for slice', + allIndexesInSlice[i], + allIndexesInSlice[i + 100], + ); + const slice = allIndexesInSlice + .slice(i, i + 100) + .map(index => seqKeys[index]); + const result = await getMultipleAccounts( + anchorProgram.provider.connection, + slice.map(s => s.toBase58()), + 'recent', + ); + ticketKeys = ticketKeys.concat( + result.array.map( + a => + new anchor.web3.PublicKey( + new Uint8Array(a.data.slice(8, 8 + 32)), + ), + ), + ); + return ticketKeys; + } + }, + ), + ); + + const ticketsFlattened = ticketKeys.flat(); + + const states: { seq: number; number: anchor.BN; eligible: boolean }[][] = + await Promise.all( + chunks(Array.from(Array(ticketsFlattened.length).keys()), 1000).map( + async allIndexesInSlice => { + let states = []; + for (let i = 0; i < allIndexesInSlice.length; i += 100) { + console.log( + 'Pulling states for slice', + allIndexesInSlice[i], + allIndexesInSlice[i + 100], + ); + const slice = allIndexesInSlice + .slice(i, i + 100) + .map(index => ticketsFlattened[index]); + const result = await getMultipleAccounts( + anchorProgram.provider.connection, + slice.map(s => s.toBase58()), + 'recent', + ); + states = states.concat( + result.array.map(a => { + const el = anchorProgram.coder.accounts.decode( + 'FairLaunchTicket', + a.data, + ); + return { + seq: el.seq.toNumber(), + number: el.amount.toNumber(), + eligible: !!( + el.state.unpunched && + el.amount.toNumber() >= + //@ts-ignore + fairLaunchObj.currentMedian.toNumber() + ), + }; + }), + ); + return states; + } + }, + ), + ); + + const statesFlat = states.flat(); + + let numWinnersRemaining = Math.min( + //@ts-ignore; + fairLaunchObj.data.numberOfTokens, + //@ts-ignore; + statesFlat.filter(s => s.eligible).length, + ); + + let chosen: { seq: number; eligible: boolean; chosen: boolean }[]; + if (numWinnersRemaining >= statesFlat.length) { + console.log('More or equal nfts than winners, everybody wins.'); + chosen = statesFlat.map(s => ({ ...s, chosen: true })); + } else { + chosen = statesFlat.map(s => ({ ...s, chosen: false })); + + console.log('Doing lottery for', numWinnersRemaining); + while (numWinnersRemaining > 0) { + const rand = Math.round(Math.random() * (chosen.length - 1)); + if (chosen[rand].chosen != true && chosen[rand].eligible) { + chosen[rand].chosen = true; + numWinnersRemaining--; + } + } + } + const sorted = chosen.sort((a, b) => a.seq - b.seq); + console.log('Lottery results', sorted); + + await Promise.all( + // each 8 entries is 1 byte, we want to send up 1000 bytes at a time. + // be specific here. + chunks(Array.from(Array(sorted.length).keys()), 8 * 1000).map( + async allIndexesInSlice => { + const bytes = []; + const correspondingArrayOfBits = []; + const startingOffset = allIndexesInSlice[0]; + let positionFromRight = 7; + let currByte = 0; + let currByteAsBits = []; + for (let i = 0; i < allIndexesInSlice.length; i++) { + if (chosen[allIndexesInSlice[i]].chosen) { + const mask = Math.pow(2, positionFromRight); + currByte = currByte | mask; + currByteAsBits.push(1); + } else { + currByteAsBits.push(0); + } + positionFromRight--; + if (positionFromRight < 0) { + bytes.push(currByte); + correspondingArrayOfBits.push(currByteAsBits); + currByte = 0; + currByteAsBits = []; + positionFromRight = 7; + } + } + + if (positionFromRight != 7) { + // grab the last one if the loop hasnt JUST ended exactly right before on an additional add. + bytes.push(currByte); + correspondingArrayOfBits.push(currByteAsBits); + } + + console.log( + 'Setting bytes array for', + startingOffset, + 'to', + allIndexesInSlice[allIndexesInSlice.length - 1], + 'as (with split out by bits for ease of reading)', + bytes.map((e, i) => [e, correspondingArrayOfBits[i]]), + ); + + await anchorProgram.rpc.updateFairLaunchLotteryBitmap( + startingOffset, + Buffer.from(bytes), + { + accounts: { + fairLaunch, + fairLaunchLotteryBitmap, + authority: walletKeyPair.publicKey, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }, + ); + }, + ), + ); + + console.log('All done'); + }); + +program + .command('create_missing_sequences') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (_, cmd) => { + const { env, keypair, fairLaunch } = cmd.opts(); + const fairLaunchTicketSeqStart = 8 + 32 + 32 + 8 + 1 + 1; + const fairLaunchTicketState = 8 + 32 + 32 + 8; + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunch, + ); + const tickets = await anchorProgram.provider.connection.getProgramAccounts( + FAIR_LAUNCH_PROGRAM_ID, + { + filters: [ + { + memcmp: { + offset: 8, + bytes: fairLaunch, + }, + }, + ], + }, + ); + + for (let i = 0; i < tickets.length; i++) { + const accountAndPubkey = tickets[i]; + const { account, pubkey } = accountAndPubkey; + const state = account.data[fairLaunchTicketState]; + if (state == 0) { + console.log('Missing sequence for ticket', pubkey.toBase58()); + const [fairLaunchTicketSeqLookup, seqBump] = + await getFairLaunchTicketSeqLookup( + //@ts-ignore + fairLaunchObj.tokenMint, + new anchor.BN( + account.data.slice( + fairLaunchTicketSeqStart, + fairLaunchTicketSeqStart + 8, + ), + undefined, + 'le', + ), + ); + + await anchorProgram.rpc.createTicketSeq(seqBump, { + accounts: { + fairLaunchTicketSeqLookup, + fairLaunch, + fairLaunchTicket: pubkey, + payer: walletKeyPair.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + options: { + commitment: 'single', + }, + signers: [], + }); + console.log('Created...'); + } + } + }); + +program + .command('show') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (options, cmd) => { + const { env, fairLaunch, keypair } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunch, + ); + + let treasuryAmount = 0; + // @ts-ignore + if (fairLaunchObj.treasuryMint) { + const token = + await anchorProgram.provider.connection.getTokenAccountBalance( + // @ts-ignore + fairLaunchObj.treasury, + ); + treasuryAmount = token.value.uiAmount; + } else { + treasuryAmount = await anchorProgram.provider.connection.getBalance( + // @ts-ignore + fairLaunchObj.treasury, + ); + } + + //@ts-ignore + console.log('Token Mint', fairLaunchObj.tokenMint.toBase58()); + //@ts-ignore + console.log('Treasury', fairLaunchObj.treasury.toBase58()); + //@ts-ignore + console.log('Treasury Mint', fairLaunchObj.treasuryMint?.toBase58()); + //@ts-ignore + console.log('Authority', fairLaunchObj.authority.toBase58()); + //@ts-ignore + console.log('Bump', fairLaunchObj.bump); + //@ts-ignore + console.log('Treasury Bump', fairLaunchObj.treasuryBump); + //@ts-ignore + console.log('Token Mint Bump', fairLaunchObj.tokenMintBump); + //@ts-ignore + if (fairLaunchObj.data.antiRugSetting) { + console.log('Anti-Rug Settings:'); + //@ts-ignore + console.log('Reserve bps', fairLaunchObj.data.antiRugSetting.reserveBp); + //@ts-ignore + console.log( + 'Number of tokens remaining in circulation below which you are allowed to retrieve treasury in full:', + //@ts-ignore + fairLaunchObj.data.antiRugSetting.tokenRequirement.toNumber(), + ); + console.log( + 'Self destruct date - Date at which refunds are allowed (but not required):', + //@ts-ignore + new Date(fairLaunchObj.data.antiRugSetting.selfDestructDate * 1000), + ); + } else { + console.log('Anti-Rug Settings: None'); + } + console.log( + 'Price Range Start ', + //@ts-ignore + fairLaunchObj.data.priceRangeStart.toNumber(), + ); + console.log( + 'Price Range End ', + //@ts-ignore + fairLaunchObj.data.priceRangeEnd.toNumber(), + ); + + console.log( + 'Tick Size ', + //@ts-ignore + fairLaunchObj.data.tickSize.toNumber(), + ); + + console.log( + 'Fees ', + //@ts-ignore + fairLaunchObj.data.fee.toNumber(), + ); + + console.log('Current Treasury Holdings', treasuryAmount); + + console.log( + 'Treasury Snapshot At Peak', + //@ts-ignore + fairLaunchObj.treasurySnapshot?.toNumber(), + ); + + console.log( + 'Phase One Start ', + //@ts-ignore + new Date(fairLaunchObj.data.phaseOneStart.toNumber() * 1000), + ); + console.log( + 'Phase One End ', + //@ts-ignore + new Date(fairLaunchObj.data.phaseOneEnd.toNumber() * 1000), + ); + console.log( + 'Phase Two End ', + //@ts-ignore + new Date(fairLaunchObj.data.phaseTwoEnd.toNumber() * 1000), + ); + + console.log( + 'Lottery Period End', + //@ts-ignore + new Date( + //@ts-ignore + (fairLaunchObj.data.phaseTwoEnd.toNumber() + + //@ts-ignore + fairLaunchObj.data.lotteryDuration.toNumber()) * + 1000, + ), + ); + + console.log( + 'Number of Tokens', + //@ts-ignore + fairLaunchObj.data.numberOfTokens.toNumber(), + ); + + console.log( + 'Number of Tickets Un-Sequenced ', + //@ts-ignore + fairLaunchObj.numberTicketsUnSeqed.toNumber(), + ); + + console.log( + 'Number of Tickets Sold ', + //@ts-ignore + fairLaunchObj.numberTicketsSold.toNumber(), + ); + + console.log( + 'Number of Tickets Dropped ', + //@ts-ignore + fairLaunchObj.numberTicketsDropped.toNumber(), + ); + + console.log( + 'Number of Tickets Punched ', + //@ts-ignore + fairLaunchObj.numberTicketsPunched.toNumber(), + ); + + console.log( + 'Number of Tickets Dropped + Punched', + //@ts-ignore + fairLaunchObj.numberTicketsDropped.toNumber() + + //@ts-ignore + fairLaunchObj.numberTicketsPunched.toNumber(), + ); + + console.log( + 'Number of Tokens Refunded ', + //@ts-ignore + fairLaunchObj.numberTokensBurnedForRefunds.toNumber(), + ); + + console.log( + 'Number of Tokens Preminted ', + //@ts-ignore + fairLaunchObj.numberTokensPreminted.toNumber(), + ); + + console.log( + 'Phase Three Started', + //@ts-ignore + fairLaunchObj.phaseThreeStarted, + ); + + console.log( + 'Current Eligible Holders', + //@ts-ignore + fairLaunchObj.currentEligibleHolders.toNumber(), + ); + + console.log( + 'Current Median', + //@ts-ignore + fairLaunchObj.currentMedian.toNumber(), + ); + + console.log('Counts at Each Tick'); + //@ts-ignore + fairLaunchObj.countsAtEachTick.forEach((c, i) => + console.log( + //@ts-ignore + fairLaunchObj.data.priceRangeStart.toNumber() + + //@ts-ignore + i * fairLaunchObj.data.tickSize.toNumber(), + ':', + c.toNumber(), + ), + ); + }); + +program + .command('show_ticket') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .option('-b, --fair-launch-ticket-buyer ', 'fair launch ticket buyer') + .action(async (options, cmd) => { + const { env, fairLaunch, keypair, fairLaunchTicketBuyer } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunch, + ); + + const fairLaunchTicket = ( + await getFairLaunchTicket( + //@ts-ignore + fairLaunchObj.tokenMint, + fairLaunchTicketBuyer + ? new anchor.web3.PublicKey(fairLaunchTicketBuyer) + : walletKeyPair.publicKey, + ) + )[0]; + + const fairLaunchTicketObj = + await anchorProgram.account.fairLaunchTicket.fetch(fairLaunchTicket); + + //@ts-ignore + console.log('Buyer', fairLaunchTicketObj.buyer.toBase58()); + //@ts-ignore + console.log('Fair Launch', fairLaunchTicketObj.fairLaunch.toBase58()); + //@ts-ignore + console.log('Current Amount', fairLaunchTicketObj.amount.toNumber()); + //@ts-ignore + console.log('State', fairLaunchTicketObj.state); + //@ts-ignore + console.log('Bump', fairLaunchTicketObj.bump); + //@ts-ignore + console.log('Sequence', fairLaunchTicketObj.seq.toNumber()); + }); + +program + .command('show_lottery') + .option( + '-e, --env ', + 'Solana cluster env name', + 'devnet', //mainnet-beta, testnet, devnet + ) + .option( + '-k, --keypair ', + `Solana wallet location`, + '--keypair not provided', + ) + .option('-f, --fair-launch ', 'fair launch id') + .action(async (options, cmd) => { + const { env, fairLaunch, keypair } = cmd.opts(); + + const walletKeyPair = loadWalletKey(keypair); + const anchorProgram = await loadFairLaunchProgram(walletKeyPair, env); + + const fairLaunchObj = await anchorProgram.account.fairLaunch.fetch( + fairLaunch, + ); + + const fairLaunchLottery = ( + await getFairLaunchLotteryBitmap( + //@ts-ignore + fairLaunchObj.tokenMint, + ) + )[0]; + + const fairLaunchLotteryBitmapObj = + await anchorProgram.provider.connection.getAccountInfo(fairLaunchLottery); + + const fairLaunchLotteryBitmapAnchorObj = + await anchorProgram.account.fairLaunchLotteryBitmap.fetch( + fairLaunchLottery, + ); + const seqKeys = []; + //@ts-ignore + for (let i = 0; i < fairLaunchObj.numberTicketsSold; i++) { + seqKeys.push( + ( + await getFairLaunchTicketSeqLookup( + //@ts-ignore + fairLaunchObj.tokenMint, + new anchor.BN(i), + ) + )[0], + ); + } + const buyers: { seq: anchor.BN; buyer: anchor.web3.PublicKey }[][] = + await Promise.all( + chunks(Array.from(Array(seqKeys.length).keys()), 1000).map( + async allIndexesInSlice => { + let ticketKeys = []; + for (let i = 0; i < allIndexesInSlice.length; i += 100) { + console.log( + 'Pulling ticket seqs for slice', + allIndexesInSlice[i], + allIndexesInSlice[i + 100], + ); + const slice = allIndexesInSlice + .slice(i, i + 100) + .map(index => seqKeys[index]); + const result = await getMultipleAccounts( + anchorProgram.provider.connection, + slice.map(s => s.toBase58()), + 'recent', + ); + ticketKeys = ticketKeys.concat( + result.array.map(a => ({ + buyer: new anchor.web3.PublicKey( + new Uint8Array(a.data.slice(8 + 32, 8 + 32 + 32)), + ), + seq: new anchor.BN( + a.data.slice(8 + 32 + 32, 8 + 32 + 32 + 8), + undefined, + 'le', + ), + })), + ); + + return ticketKeys; + } + }, + ), + ); + + const buyersFlattened = buyers + .flat() + .sort((a, b) => a.seq.toNumber() - b.seq.toNumber()); + + for (let i = 0; i < buyersFlattened.length; i++) { + const buyer = buyersFlattened[i]; + + const myByte = + fairLaunchLotteryBitmapObj.data[ + FAIR_LAUNCH_LOTTERY_SIZE + Math.floor(buyer.seq.toNumber() / 8) + ]; + + const positionFromRight = 7 - (buyer.seq.toNumber() % 8); + const mask = Math.pow(2, positionFromRight); + const isWinner = myByte & mask; + console.log( + 'Ticket', + buyer.seq, + buyer.buyer.toBase58(), + isWinner > 0 ? 'won' : 'lost', + ); + } + + console.log( + 'Bit Map ones', + //@ts-ignore + fairLaunchLotteryBitmapAnchorObj.bitmapOnes.toNumber(), + ); + }); +program.parse(process.argv); diff --git a/js/packages/cli/src/helpers/accounts.ts b/js/packages/cli/src/helpers/accounts.ts index ec27c59..87af7dd 100644 --- a/js/packages/cli/src/helpers/accounts.ts +++ b/js/packages/cli/src/helpers/accounts.ts @@ -5,13 +5,14 @@ import { SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, TOKEN_METADATA_PROGRAM_ID, TOKEN_PROGRAM_ID, + FAIR_LAUNCH_PROGRAM_ID, } from './constants'; import * as anchor from '@project-serum/anchor'; import fs from 'fs'; -import BN from "bn.js"; -import { createConfigAccount } from "./instructions"; -import { web3 } from "@project-serum/anchor"; -import log from "loglevel"; +import BN from 'bn.js'; +import { createConfigAccount } from './instructions'; +import { web3 } from '@project-serum/anchor'; +import log from 'loglevel'; export const createConfig = async function ( anchorProgram: anchor.Program, @@ -99,6 +100,78 @@ export const getConfig = async ( ); }; +export const getTokenMint = async ( + authority: anchor.web3.PublicKey, + uuid: string, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [ + Buffer.from('fair_launch'), + authority.toBuffer(), + Buffer.from('mint'), + Buffer.from(uuid), + ], + FAIR_LAUNCH_PROGRAM_ID, + ); +}; + +export const getFairLaunch = async ( + tokenMint: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from('fair_launch'), tokenMint.toBuffer()], + FAIR_LAUNCH_PROGRAM_ID, + ); +}; + +export const getFairLaunchTicket = async ( + tokenMint: anchor.web3.PublicKey, + buyer: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from('fair_launch'), tokenMint.toBuffer(), buyer.toBuffer()], + FAIR_LAUNCH_PROGRAM_ID, + ); +}; + +export const getFairLaunchLotteryBitmap = async ( + tokenMint: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from('fair_launch'), tokenMint.toBuffer(), Buffer.from('lottery')], + FAIR_LAUNCH_PROGRAM_ID, + ); +}; + +export const getFairLaunchTicketSeqLookup = async ( + tokenMint: anchor.web3.PublicKey, + seq: anchor.BN, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from('fair_launch'), tokenMint.toBuffer(), seq.toBuffer('le', 8)], + FAIR_LAUNCH_PROGRAM_ID, + ); +}; + +export const getAtaForMint = async ( + mint: anchor.web3.PublicKey, + buyer: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [buyer.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, + ); +}; + +export const getTreasury = async ( + tokenMint: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from('fair_launch'), tokenMint.toBuffer(), Buffer.from('treasury')], + FAIR_LAUNCH_PROGRAM_ID, + ); +}; + export const getMetadata = async ( mint: anchor.web3.PublicKey, ): Promise => { @@ -131,14 +204,17 @@ export const getMasterEdition = async ( }; export function loadWalletKey(keypair): Keypair { + if (!keypair || keypair == '') { + throw new Error('Keypair is required!'); + } const loaded = Keypair.fromSecretKey( new Uint8Array(JSON.parse(fs.readFileSync(keypair).toString())), ); - log.info(`wallet public key: ${loaded.publicKey}`) + log.info(`wallet public key: ${loaded.publicKey}`); return loaded; } -export async function loadAnchorProgram(walletKeyPair: Keypair, env: string) { +export async function loadCandyProgram(walletKeyPair: Keypair, env: string) { // @ts-ignore const solConnection = new web3.Connection(web3.clusterApiUrl(env)); const walletWrapper = new anchor.Wallet(walletKeyPair); @@ -148,6 +224,21 @@ export async function loadAnchorProgram(walletKeyPair: Keypair, env: string) { const idl = await anchor.Program.fetchIdl(CANDY_MACHINE_PROGRAM_ID, provider); const program = new anchor.Program(idl, CANDY_MACHINE_PROGRAM_ID, provider); - log.debug("program id from anchor", program.programId.toBase58()); + log.debug('program id from anchor', program.programId.toBase58()); return program; } + +export async function loadFairLaunchProgram( + walletKeyPair: Keypair, + env: string, +) { + // @ts-ignore + const solConnection = new anchor.web3.Connection(web3.clusterApiUrl(env)); + const walletWrapper = new anchor.Wallet(walletKeyPair); + const provider = new anchor.Provider(solConnection, walletWrapper, { + preflightCommitment: 'recent', + }); + const idl = await anchor.Program.fetchIdl(FAIR_LAUNCH_PROGRAM_ID, provider); + + return new anchor.Program(idl, FAIR_LAUNCH_PROGRAM_ID, provider); +} diff --git a/js/packages/cli/src/helpers/cache.ts b/js/packages/cli/src/helpers/cache.ts index 9f2be51..1bc0509 100644 --- a/js/packages/cli/src/helpers/cache.ts +++ b/js/packages/cli/src/helpers/cache.ts @@ -1,19 +1,36 @@ -import path from "path"; -import { CACHE_PATH } from "./constants"; -import fs from "fs"; +import path from 'path'; +import { CACHE_PATH } from './constants'; +import fs from 'fs'; -export function cachePath(env: string, cacheName: string, cPath: string = CACHE_PATH) { +export function cachePath( + env: string, + cacheName: string, + cPath: string = CACHE_PATH, +) { return path.join(cPath, `${env}-${cacheName}`); } -export function loadCache(cacheName: string, env: string, cPath: string = CACHE_PATH) { +export function loadCache( + cacheName: string, + env: string, + cPath: string = CACHE_PATH, +) { const path = cachePath(env, cacheName, cPath); return fs.existsSync(path) ? JSON.parse(fs.readFileSync(path).toString()) : undefined; } -export function saveCache(cacheName: string, env: string, cacheContent, cPath: string = CACHE_PATH) { - fs.writeFileSync(cachePath(env, cacheName, cPath), JSON.stringify(cacheContent)); +export function saveCache( + cacheName: string, + env: string, + cacheContent, + cPath: string = CACHE_PATH, +) { + cacheContent.env = env; + cacheContent.cacheName = cacheName; + fs.writeFileSync( + cachePath(env, cacheName, cPath), + JSON.stringify(cacheContent), + ); } - diff --git a/js/packages/cli/src/helpers/constants.ts b/js/packages/cli/src/helpers/constants.ts index 7600f97..7db77ac 100644 --- a/js/packages/cli/src/helpers/constants.ts +++ b/js/packages/cli/src/helpers/constants.ts @@ -4,11 +4,24 @@ export const MAX_NAME_LENGTH = 32; export const MAX_URI_LENGTH = 200; export const MAX_SYMBOL_LENGTH = 10; export const MAX_CREATOR_LEN = 32 + 1 + 1; -export const ARWEAVE_PAYMENT_WALLET = new PublicKey('HvwC9QSAzvGXhhVrgPmauVwFWcYZhne3hVot9EbHuFTm'); -export const CANDY_MACHINE_PROGRAM_ID = new PublicKey('cndyAnrLdpjq1Ssp1z8xxDsB8dxe7u4HL5Nxi2K5WXZ'); -export const TOKEN_METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); -export const SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); -export const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); +export const ARWEAVE_PAYMENT_WALLET = new PublicKey( + 'HvwC9QSAzvGXhhVrgPmauVwFWcYZhne3hVot9EbHuFTm', +); +export const CANDY_MACHINE_PROGRAM_ID = new PublicKey( + 'cndyAnrLdpjq1Ssp1z8xxDsB8dxe7u4HL5Nxi2K5WXZ', +); +export const TOKEN_METADATA_PROGRAM_ID = new PublicKey( + 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s', +); +export const SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID = new PublicKey( + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', +); +export const TOKEN_PROGRAM_ID = new PublicKey( + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', +); +export const FAIR_LAUNCH_PROGRAM_ID = new PublicKey( + 'faircnAB9k59Y4TXmLabBULeuTLgV7TkGMGNkjnA15j', +); export const CONFIG_ARRAY_START = 32 + // authority 4 + diff --git a/js/packages/cli/src/helpers/transactions.ts b/js/packages/cli/src/helpers/transactions.ts index f21a942..9b61405 100644 --- a/js/packages/cli/src/helpers/transactions.ts +++ b/js/packages/cli/src/helpers/transactions.ts @@ -13,7 +13,7 @@ import { } from '@solana/web3.js'; import { getUnixTs, sleep } from './various'; import { DEFAULT_TIMEOUT } from './constants'; -import log from "loglevel"; +import log from 'loglevel'; interface BlockhashAndFeeCalculator { blockhash: Blockhash; @@ -47,11 +47,11 @@ export const sendTransactionWithRetryWithKeypair = async ( } if (signers.length > 0) { - transaction.partialSign(...signers); + transaction.sign(...[wallet, ...signers]); + } else { + transaction.sign(wallet); } - transaction.sign(wallet); - if (beforeSend) { beforeSend(); } @@ -242,7 +242,7 @@ async function awaitTransactionSignatureConfirmation( } else if (!status.confirmations) { log.error('REST no confirmations for', txid, status); } else { - log.info('REST confirmation for', txid, status); + log.debug('REST confirmation for', txid, status); done = true; resolve(status); } diff --git a/js/packages/cli/src/helpers/upload/arweave.ts b/js/packages/cli/src/helpers/upload/arweave.ts new file mode 100644 index 0000000..e8614f5 --- /dev/null +++ b/js/packages/cli/src/helpers/upload/arweave.ts @@ -0,0 +1,73 @@ +import * as anchor from '@project-serum/anchor'; +import FormData from 'form-data'; +import fs from 'fs'; +import log from 'loglevel'; +import fetch from 'node-fetch'; +import { ARWEAVE_PAYMENT_WALLET } from '../constants'; +import { sendTransactionWithRetryWithKeypair } from '../transactions'; + +async function upload(data: FormData, manifest, index) { + log.debug(`trying to upload ${index}.png: ${manifest.name}`); + return await ( + await fetch( + 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile4', + { + method: 'POST', + // @ts-ignore + body: data, + }, + ) + ).json(); +} + +export async function arweaveUpload( + walletKeyPair, + anchorProgram, + env, + image, + manifestBuffer, + manifest, + index, +) { + const storageCost = 10; + + const instructions = [ + anchor.web3.SystemProgram.transfer({ + fromPubkey: walletKeyPair.publicKey, + toPubkey: ARWEAVE_PAYMENT_WALLET, + lamports: storageCost, + }), + ]; + + const tx = await sendTransactionWithRetryWithKeypair( + anchorProgram.provider.connection, + walletKeyPair, + instructions, + [], + 'single', + ); + log.debug('transaction for arweave payment:', tx); + + const data = new FormData(); + data.append('transaction', tx['txid']); + data.append('env', env); + data.append('file[]', fs.createReadStream(image), { + filename: `image.png`, + contentType: 'image/png', + }); + data.append('file[]', manifestBuffer, 'metadata.json'); + + const result = await upload(data, manifest, index); + + const metadataFile = result.messages?.find( + m => m.filename === 'manifest.json', + ); + if (metadataFile?.transactionId) { + const link = `https://arweave.net/${metadataFile.transactionId}`; + log.debug(`File uploaded: ${link}`); + return link; + } else { + // @todo improve + throw new Error(`No transaction ID for upload: ${index}`); + } +} diff --git a/js/packages/cli/src/helpers/upload/ipfs.ts b/js/packages/cli/src/helpers/upload/ipfs.ts new file mode 100644 index 0000000..0d06ff7 --- /dev/null +++ b/js/packages/cli/src/helpers/upload/ipfs.ts @@ -0,0 +1,67 @@ +import log from 'loglevel'; +import fetch from 'node-fetch'; +import { create, globSource } from 'ipfs-http-client'; + +export interface ipfsCreds { + projectId: string; + secretKey: string; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function ipfsUpload( + ipfsCredentials: ipfsCreds, + image: string, + manifestBuffer: Buffer, +) { + const tokenIfps = `${ipfsCredentials.projectId}:${ipfsCredentials.secretKey}`; + // @ts-ignore + const ipfs = create('https://ipfs.infura.io:5001'); + + const uploadToIpfs = async source => { + const { cid } = await ipfs.add(source).catch(); + return cid; + }; + + const mediaHash = await uploadToIpfs(globSource(image, { recursive: true })); + log.debug('mediaHash:', mediaHash); + const mediaUrl = `https://ipfs.io/ipfs/${mediaHash}`; + log.debug('mediaUrl:', mediaUrl); + const authIFPS = Buffer.from(tokenIfps).toString('base64'); + await fetch(`https://ipfs.infura.io:5001/api/v0/pin/add?arg=${mediaHash}`, { + headers: { + Authorization: `Basic ${authIFPS}`, + }, + method: 'POST', + }); + log.info('uploaded image for file:', image); + + await sleep(500); + + const manifestJson = JSON.parse(manifestBuffer.toString('utf8')); + manifestJson.image = mediaUrl; + manifestJson.properties.files = manifestJson.properties.files.map(f => { + return { ...f, uri: mediaUrl }; + }); + + const manifestHash = await uploadToIpfs( + Buffer.from(JSON.stringify(manifestJson)), + ); + await fetch( + `https://ipfs.infura.io:5001/api/v0/pin/add?arg=${manifestHash}`, + { + headers: { + Authorization: `Basic ${authIFPS}`, + }, + method: 'POST', + }, + ); + + await sleep(500); + const link = `https://ipfs.io/ipfs/${manifestHash}`; + log.info('uploaded manifest: ', link); + + return link; +} diff --git a/js/packages/cli/src/helpers/various.ts b/js/packages/cli/src/helpers/various.ts index cf9b8b5..3a23c7e 100644 --- a/js/packages/cli/src/helpers/various.ts +++ b/js/packages/cli/src/helpers/various.ts @@ -1,7 +1,4 @@ -import { LAMPORTS_PER_SOL } from '@solana/web3.js'; -import path from "path"; -import { CACHE_PATH } from "./constants"; -import fs from "fs"; +import { LAMPORTS_PER_SOL, AccountInfo } from '@solana/web3.js'; export const getUnixTs = () => { return new Date().getTime() / 1000; }; @@ -54,16 +51,71 @@ export function parsePrice(price: string, mantissa: number = LAMPORTS_PER_SOL) { return Math.ceil(parseFloat(price) * mantissa); } -export async function upload(data: FormData, manifest, index) { - console.log(`trying to upload ${index}.png: ${manifest.name}`); - return await ( - await fetch( - 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile4', - { - method: 'POST', - // @ts-ignore - body: data, - }, - ) - ).json(); +export function parseDate(date) { + if (date === 'now') { + return Date.now() / 1000; + } + return Date.parse(date) / 1000; } + +export const getMultipleAccounts = async ( + connection: any, + keys: string[], + commitment: string, +) => { + const result = await Promise.all( + chunks(keys, 99).map(chunk => + getMultipleAccountsCore(connection, chunk, commitment), + ), + ); + + const array = result + .map( + a => + //@ts-ignore + a.array.map(acc => { + if (!acc) { + return undefined; + } + + const { data, ...rest } = acc; + const obj = { + ...rest, + data: Buffer.from(data[0], 'base64'), + } as AccountInfo; + return obj; + }) as AccountInfo[], + ) + //@ts-ignore + .flat(); + return { keys, array }; +}; + +export function chunks(array, size) { + return Array.apply(0, new Array(Math.ceil(array.length / size))).map( + (_, index) => array.slice(index * size, (index + 1) * size), + ); +} + +const getMultipleAccountsCore = async ( + connection: any, + keys: string[], + commitment: string, +) => { + const args = connection._buildArgs([keys], commitment, 'base64'); + + const unsafeRes = await connection._rpcRequest('getMultipleAccounts', args); + if (unsafeRes.error) { + throw new Error( + 'failed to get info about account ' + unsafeRes.error.message, + ); + } + + if (unsafeRes.result.value) { + const array = unsafeRes.result.value as AccountInfo[]; + return { keys, array }; + } + + // TODO: fix + throw new Error(); +}; diff --git a/js/packages/cli/src/tsconfig.json b/js/packages/cli/src/tsconfig.json index 2c03376..393bb0d 100644 --- a/js/packages/cli/src/tsconfig.json +++ b/js/packages/cli/src/tsconfig.json @@ -14,7 +14,8 @@ "noLib": false, "preserveConstEnums": true, "suppressImplicitAnyIndexErrors": true, - "lib": ["dom", "es6"] + "resolveJsonModule": true, + "lib": ["dom", "es2019"] }, "exclude": ["node_modules", "typings/browser", "typings/browser.d.ts"], "atom": { diff --git a/js/packages/cli/src/types.ts b/js/packages/cli/src/types.ts index 2de2083..893ab3e 100644 --- a/js/packages/cli/src/types.ts +++ b/js/packages/cli/src/types.ts @@ -1,16 +1,12 @@ -import { BN } from "@project-serum/anchor"; -import { PublicKey, AccountInfo } from "@solana/web3.js"; +import { BN } from '@project-serum/anchor'; +import { PublicKey, AccountInfo } from '@solana/web3.js'; export class Creator { address: PublicKey; verified: boolean; share: number; - constructor(args: { - address: PublicKey; - verified: boolean; - share: number; - }) { + constructor(args: { address: PublicKey; verified: boolean; share: number }) { this.address = args.address; this.verified = args.verified; this.share = args.share; @@ -67,7 +63,7 @@ export enum MetadataKey { EditionV1 = 1, MasterEditionV1 = 2, MasterEditionV2 = 6, - EditionMarker = 7 + EditionMarker = 7, } export class MasterEditionV1 { @@ -89,49 +85,38 @@ export class MasterEditionV1 { this.printingMint = args.printingMint; this.oneTimePrintingAuthorizationMint = args.oneTimePrintingAuthorizationMint; - }; + } } export class MasterEditionV2 { key: MetadataKey; supply: BN; maxSupply?: BN; - constructor(args: { - key: MetadataKey; - supply: BN; - maxSupply?: BN; - }) { + constructor(args: { key: MetadataKey; supply: BN; maxSupply?: BN }) { this.key = MetadataKey.MasterEditionV2; this.supply = args.supply; this.maxSupply = args.maxSupply; - }; + } } export class EditionMarker { key: MetadataKey; ledger: number[]; - constructor(args: { - key: MetadataKey; - ledger: number[]; - }) { + constructor(args: { key: MetadataKey; ledger: number[] }) { this.key = MetadataKey.EditionMarker; this.ledger = args.ledger; - }; + } } export class Edition { key: MetadataKey; parent: PublicKey; edition: BN; - constructor(args: { - key: MetadataKey; - parent: PublicKey; - edition: BN; - }) { + constructor(args: { key: MetadataKey; parent: PublicKey; edition: BN }) { this.key = MetadataKey.EditionV1; this.parent = args.parent; this.edition = args.edition; - }; + } } export class Data { @@ -152,7 +137,7 @@ export class Data { this.uri = args.uri; this.sellerFeeBasisPoints = args.sellerFeeBasisPoints; this.creators = args.creators; - }; + } } export class Metadata { @@ -178,7 +163,7 @@ export class Metadata { this.data = args.data; this.primarySaleHappened = args.primarySaleHappened; this.isMutable = args.isMutable; - }; + } } export const METADATA_SCHEMA = new Map([ @@ -265,4 +250,4 @@ export const METADATA_SCHEMA = new Map([ ], }, ], -]); \ No newline at end of file +]); diff --git a/js/packages/common/package.json b/js/packages/common/package.json index 4912ed4..acba69d 100644 --- a/js/packages/common/package.json +++ b/js/packages/common/package.json @@ -24,7 +24,7 @@ "watch-css": "less-watch-compiler src/ dist/lib/", "watch-css-src": "less-watch-compiler src/ src/", "watch": "tsc --watch", - "test": "jest test", + "test": "jest test --passWithNoTests", "clean": "rm -rf dist", "prepare": "run-s clean build", "format:fix": "prettier --write \"**/*.+(js|jsx|ts|tsx|json|css|md)\"" diff --git a/js/packages/common/src/actions/auction.ts b/js/packages/common/src/actions/auction.ts index 793529a..ad96056 100644 --- a/js/packages/common/src/actions/auction.ts +++ b/js/packages/common/src/actions/auction.ts @@ -14,7 +14,7 @@ import { findProgramAddress, StringPublicKey, toPublicKey } from '../utils'; export const AUCTION_PREFIX = 'auction'; export const METADATA = 'metadata'; export const EXTENDED = 'extended'; -export const MAX_AUCTION_DATA_EXTENDED_SIZE = 8 + 9 + 2 + 200; +export const MAX_AUCTION_DATA_EXTENDED_SIZE = 8 + 9 + 2 + 9 + 33 + 158; export enum AuctionState { Created = 0, @@ -184,15 +184,21 @@ export class AuctionDataExtended { totalUncancelledBids: BN; tickSize: BN | null; gapTickSizePercentage: number | null; + instantSalePrice: BN | null; + name: number[] | null; constructor(args: { totalUncancelledBids: BN; tickSize: BN | null; gapTickSizePercentage: number | null; + instantSalePrice: BN | null; + name: number[] | null; }) { this.totalUncancelledBids = args.totalUncancelledBids; this.tickSize = args.tickSize; this.gapTickSizePercentage = args.gapTickSizePercentage; + this.instantSalePrice = args.instantSalePrice; + this.name = args.name; } } @@ -225,6 +231,8 @@ export class AuctionData { /// Used for precalculation on the front end, not a backend key bidRedemptionKey?: StringPublicKey; + auctionDataExtended?: StringPublicKey; + public timeToEnd(): CountdownState { const now = moment().unix(); const ended = { days: 0, hours: 0, minutes: 0, seconds: 0 }; @@ -370,10 +378,14 @@ export interface IPartialCreateAuctionArgs { tickSize: BN | null; gapTickSizePercentage: number | null; + + instantSalePrice: BN | null; + + name: number[] | null; } export class CreateAuctionArgs implements IPartialCreateAuctionArgs { - instruction: number = 1; + instruction: number = 7; /// How many winners are allowed for this auction. See AuctionData. winners: WinnerLimit; /// End time is the cut-off point that the auction is forced to end by. See AuctionData. @@ -393,6 +405,10 @@ export class CreateAuctionArgs implements IPartialCreateAuctionArgs { gapTickSizePercentage: number | null; + instantSalePrice: BN | null; + + name: number[] | null; + constructor(args: { winners: WinnerLimit; endAuctionAt: BN | null; @@ -403,6 +419,8 @@ export class CreateAuctionArgs implements IPartialCreateAuctionArgs { priceFloor: PriceFloor; tickSize: BN | null; gapTickSizePercentage: number | null; + name: number[] | null; + instantSalePrice: BN | null; }) { this.winners = args.winners; this.endAuctionAt = args.endAuctionAt; @@ -413,6 +431,8 @@ export class CreateAuctionArgs implements IPartialCreateAuctionArgs { this.priceFloor = args.priceFloor; this.tickSize = args.tickSize; this.gapTickSizePercentage = args.gapTickSizePercentage; + this.name = args.name; + this.instantSalePrice = args.instantSalePrice; } } @@ -465,6 +485,8 @@ export const AUCTION_SCHEMA = new Map([ ['priceFloor', PriceFloor], ['tickSize', { kind: 'option', type: 'u64' }], ['gapTickSizePercentage', { kind: 'option', type: 'u8' }], + ['instantSalePrice', { kind: 'option', type: 'u64' }], + ['name', { kind: 'option', type: [32] }], ], }, ], @@ -542,6 +564,8 @@ export const AUCTION_SCHEMA = new Map([ ['totalUncancelledBids', 'u64'], ['tickSize', { kind: 'option', type: 'u64' }], ['gapTickSizePercentage', { kind: 'option', type: 'u8' }], + ['instantSalePrice', { kind: 'option', type: 'u64' }], + ['name', { kind: 'option', type: [32] }], ], }, ], diff --git a/js/packages/common/src/actions/metadata.ts b/js/packages/common/src/actions/metadata.ts index e00d74a..7fb2b51 100644 --- a/js/packages/common/src/actions/metadata.ts +++ b/js/packages/common/src/actions/metadata.ts @@ -1,4 +1,5 @@ import { + PublicKey, SystemProgram, SYSVAR_RENT_PUBKEY, TransactionInstruction, @@ -249,13 +250,27 @@ export class Metadata { this.data = args.data; this.primarySaleHappened = args.primarySaleHappened; this.isMutable = args.isMutable; - this.editionNonce = args.editionNonce; + this.editionNonce = args.editionNonce ?? null; } public async init() { - const edition = await getEdition(this.mint); - this.edition = edition; - this.masterEdition = edition; + const metadata = toPublicKey(programIds().metadata); + if (this.editionNonce !== null) { + this.edition = ( + await PublicKey.createProgramAddress( + [ + Buffer.from(METADATA_PREFIX), + metadata.toBuffer(), + toPublicKey(this.mint).toBuffer(), + new Uint8Array([this.editionNonce || 0]), + ], + metadata, + ) + ).toBase58(); + } else { + this.edition = await getEdition(this.mint); + } + this.masterEdition = this.edition; } } diff --git a/js/packages/common/src/contexts/connection.tsx b/js/packages/common/src/contexts/connection.tsx index f0509a1..f777dc7 100644 --- a/js/packages/common/src/contexts/connection.tsx +++ b/js/packages/common/src/contexts/connection.tsx @@ -16,6 +16,7 @@ import { import React, { useContext, useEffect, useMemo, useState } from 'react'; import { notify } from '../utils/notifications'; import { ExplorerLink } from '../components/ExplorerLink'; +import { useQuerySearch } from '../hooks'; import { TokenInfo, TokenListProvider, @@ -87,10 +88,16 @@ const ConnectionContext = React.createContext({ }); export function ConnectionProvider({ children = undefined as any }) { - const [endpoint, setEndpoint] = useLocalStorageState( + const searchParams = useQuerySearch(); + const network = searchParams.get('network'); + const queryEndpoint = + network && ENDPOINTS.find(({ name }) => name.startsWith(network))?.endpoint; + + const [savedEndpoint, setEndpoint] = useLocalStorageState( 'connectionEndpoint', ENDPOINTS[0].endpoint, ); + const endpoint = queryEndpoint || savedEndpoint; const connection = useMemo( () => new Connection(endpoint, 'recent'), diff --git a/js/packages/common/src/contexts/meta/getEmptyMetaState.ts b/js/packages/common/src/contexts/meta/getEmptyMetaState.ts index bd6a1db..c6361b4 100644 --- a/js/packages/common/src/contexts/meta/getEmptyMetaState.ts +++ b/js/packages/common/src/contexts/meta/getEmptyMetaState.ts @@ -22,6 +22,4 @@ export const getEmptyMetaState = (): MetaState => ({ prizeTrackingTickets: {}, safetyDepositConfigsByAuctionManagerAndIndex: {}, bidRedemptionV2sByAuctionManagerAndWinningIndex: {}, - stores: {}, - creators: {}, }); diff --git a/js/packages/common/src/contexts/meta/isMetadataPartOfStore.ts b/js/packages/common/src/contexts/meta/isMetadataPartOfStore.ts index 2bcf158..455cb18 100644 --- a/js/packages/common/src/contexts/meta/isMetadataPartOfStore.ts +++ b/js/packages/common/src/contexts/meta/isMetadataPartOfStore.ts @@ -4,20 +4,20 @@ import { ParsedAccount } from '../accounts/types'; export const isMetadataPartOfStore = ( m: ParsedAccount, - store: ParsedAccount | null, whitelistedCreatorsByCreator: Record< string, ParsedAccount >, + store?: ParsedAccount | null, ) => { - if (!m?.info?.data?.creators || !store?.info) { + if (!m?.info?.data?.creators) { return false; } return m.info.data.creators.some( c => c.verified && - (store.info.public || + (store?.info.public || whitelistedCreatorsByCreator[c.address]?.info?.activated), ); }; diff --git a/js/packages/common/src/contexts/meta/loadAccounts.ts b/js/packages/common/src/contexts/meta/loadAccounts.ts index f79ac02..408ab4c 100644 --- a/js/packages/common/src/contexts/meta/loadAccounts.ts +++ b/js/packages/common/src/contexts/meta/loadAccounts.ts @@ -16,9 +16,18 @@ import { MAX_SYMBOL_LENGTH, MAX_URI_LENGTH, METADATA_PREFIX, + decodeMetadata, + getAuctionExtended, } from '../../actions'; -import { AccountInfo, Connection, PublicKey } from '@solana/web3.js'; -import { AccountAndPubkey, MetaState, ProcessAccountsFunc } from './types'; +import { WhitelistedCreator } from '../../models/metaplex'; +import { Connection, PublicKey } from '@solana/web3.js'; +import { + AccountAndPubkey, + MetaState, + ProcessAccountsFunc, + UpdateStateValueFunc, + UnPromise, +} from './types'; import { isMetadataPartOfStore } from './isMetadataPartOfStore'; import { processAuctions } from './processAuctions'; import { processMetaplexAccounts } from './processMetaplexAccounts'; @@ -27,260 +36,444 @@ import { processVaultData } from './processVaultData'; import { ParsedAccount } from '../accounts/types'; import { getEmptyMetaState } from './getEmptyMetaState'; import { getMultipleAccounts } from '../accounts/getMultipleAccounts'; +import { getProgramAccounts } from './web3'; +import { createPipelineExecutor } from '../../utils/createPipelineExecutor'; -async function getProgramAccounts( - connection: Connection, - programId: StringPublicKey, - configOrCommitment?: any, -): Promise> { - const extra: any = {}; - let commitment; - //let encoding; +export const USE_SPEED_RUN = false; +const WHITELISTED_METADATA = ['98vYFjBYS9TguUMWQRPjy2SZuxKuUMcqR4vnQiLjZbte']; +const WHITELISTED_AUCTION = ['D8wMB5iLZnsV7XQjpwqXaDynUtFuDs7cRXvEGNj1NF1e']; +const AUCTION_TO_METADATA: Record = { + D8wMB5iLZnsV7XQjpwqXaDynUtFuDs7cRXvEGNj1NF1e: [ + '98vYFjBYS9TguUMWQRPjy2SZuxKuUMcqR4vnQiLjZbte', + ], +}; +const AUCTION_TO_VAULT: Record = { + D8wMB5iLZnsV7XQjpwqXaDynUtFuDs7cRXvEGNj1NF1e: + '3wHCBd3fYRPWjd5GqzrXanLJUKRyU3nECKbTPKfVwcFX', +}; +const WHITELISTED_AUCTION_MANAGER = [ + '3HD2C8oCL8dpqbXo8hq3CMw6tRSZDZJGajLxnrZ3ZkYx', +]; +const WHITELISTED_VAULT = ['3wHCBd3fYRPWjd5GqzrXanLJUKRyU3nECKbTPKfVwcFX']; - if (configOrCommitment) { - if (typeof configOrCommitment === 'string') { - commitment = configOrCommitment; - } else { - commitment = configOrCommitment.commitment; - //encoding = configOrCommitment.encoding; - - if (configOrCommitment.dataSlice) { - extra.dataSlice = configOrCommitment.dataSlice; - } - - if (configOrCommitment.filters) { - extra.filters = configOrCommitment.filters; - } - } - } - - const args = connection._buildArgs([programId], commitment, 'base64', extra); - const unsafeRes = await (connection as any)._rpcRequest( - 'getProgramAccounts', - args, - ); - - const data = ( - unsafeRes.result as Array<{ - account: AccountInfo<[string, string]>; - pubkey: string; - }> - ).map(item => { - return { - account: { - // TODO: possible delay parsing could be added here - data: Buffer.from(item.account.data[0], 'base64'), - executable: item.account.executable, - lamports: item.account.lamports, - // TODO: maybe we can do it in lazy way? or just use string - owner: item.account.owner, - } as AccountInfo, - pubkey: item.pubkey, - }; - }); - - return data; -} - -export const loadAccounts = async (connection: Connection, all: boolean) => { +export const limitedLoadAccounts = async (connection: Connection) => { const tempCache: MetaState = getEmptyMetaState(); const updateTemp = makeSetter(tempCache); const forEach = (fn: ProcessAccountsFunc) => async (accounts: AccountAndPubkey[]) => { for (const account of accounts) { - await fn(account, updateTemp, all); + await fn(account, updateTemp); } }; - let isSelectivePullMetadata = false; - const pullMetadata = async (creators: AccountAndPubkey[]) => { - await forEach(processMetaplexAccounts)(creators); + const pullMetadata = async (metadata: string) => { + const mdKey = new PublicKey(metadata); + const md = await connection.getAccountInfo(mdKey); + const mdObject = decodeMetadata( + Buffer.from(md?.data || new Uint8Array([])), + ); + const editionKey = await getEdition(mdObject.mint); + const editionData = await connection.getAccountInfo( + new PublicKey(editionKey), + ); + if (md) { + //@ts-ignore + md.owner = md.owner.toBase58(); + processMetaData( + { + pubkey: metadata, + account: md, + }, + updateTemp, + ); + if (editionData) { + //@ts-ignore + editionData.owner = editionData.owner.toBase58(); + processMetaData( + { + pubkey: editionKey, + account: editionData, + }, + updateTemp, + ); + } + } + }; - const whitelistedCreators = Object.values( - tempCache.whitelistedCreatorsByCreator, + const pullAuction = async (auction: string) => { + const auctionExtendedKey = await getAuctionExtended({ + auctionProgramId: AUCTION_ID, + resource: AUCTION_TO_VAULT[auction], + }); + + const auctionData = await getMultipleAccounts( + connection, + [auction, auctionExtendedKey], + 'single', ); - if (whitelistedCreators.length > 3) { - console.log(' too many creators, pulling all nfts in one go'); - additionalPromises.push( - getProgramAccounts(connection, METADATA_PROGRAM_ID).then( - forEach(processMetaData), - ), - ); - } else { - console.log('pulling optimized nfts'); - isSelectivePullMetadata = true; + if (auctionData) { + auctionData.keys.map((pubkey, i) => { + processAuctions( + { + pubkey, + account: auctionData.array[i], + }, + updateTemp, + ); + }); + } + }; - for (let i = 0; i < MAX_CREATOR_LIMIT; i++) { - for (let j = 0; j < whitelistedCreators.length; j++) { - additionalPromises.push( - getProgramAccounts(connection, METADATA_PROGRAM_ID, { + const pullAuctionManager = async (auctionManager: string) => { + const auctionManagerKey = new PublicKey(auctionManager); + const auctionManagerData = await connection.getAccountInfo( + auctionManagerKey, + ); + if (auctionManagerData) { + //@ts-ignore + auctionManagerData.owner = auctionManagerData.owner.toBase58(); + processMetaplexAccounts( + { + pubkey: auctionManager, + account: auctionManagerData, + }, + updateTemp, + ); + } + }; + + const pullVault = async (vault: string) => { + const vaultKey = new PublicKey(vault); + const vaultData = await connection.getAccountInfo(vaultKey); + if (vaultData) { + //@ts-ignore + vaultData.owner = vaultData.owner.toBase58(); + processVaultData( + { + pubkey: vault, + account: vaultData, + }, + updateTemp, + ); + } + }; + + const promises = [ + ...WHITELISTED_METADATA.map(md => pullMetadata(md)), + ...WHITELISTED_AUCTION.map(a => pullAuction(a)), + ...WHITELISTED_AUCTION_MANAGER.map(a => pullAuctionManager(a)), + ...WHITELISTED_VAULT.map(a => pullVault(a)), + // bidder metadata pull + ...WHITELISTED_AUCTION.map(a => + getProgramAccounts(connection, AUCTION_ID, { + filters: [ + { + memcmp: { + offset: 32, + bytes: a, + }, + }, + ], + }).then(forEach(processAuctions)), + ), + // bidder pot pull + ...WHITELISTED_AUCTION.map(a => + getProgramAccounts(connection, AUCTION_ID, { + filters: [ + { + memcmp: { + offset: 64, + bytes: a, + }, + }, + ], + }).then(forEach(processAuctions)), + ), + // safety deposit pull + ...WHITELISTED_VAULT.map(v => + getProgramAccounts(connection, VAULT_ID, { + filters: [ + { + memcmp: { + offset: 1, + bytes: v, + }, + }, + ], + }).then(forEach(processVaultData)), + ), + // bid redemptions + ...WHITELISTED_AUCTION_MANAGER.map(a => + getProgramAccounts(connection, METAPLEX_ID, { + filters: [ + { + memcmp: { + offset: 9, + bytes: a, + }, + }, + ], + }).then(forEach(processMetaplexAccounts)), + ), + // safety deposit configs + ...WHITELISTED_AUCTION_MANAGER.map(a => + getProgramAccounts(connection, METAPLEX_ID, { + filters: [ + { + memcmp: { + offset: 1, + bytes: a, + }, + }, + ], + }).then(forEach(processMetaplexAccounts)), + ), + // prize tracking tickets + ...Object.keys(AUCTION_TO_METADATA) + .map(key => + AUCTION_TO_METADATA[key] + .map(md => + getProgramAccounts(connection, METAPLEX_ID, { filters: [ { memcmp: { - offset: - 1 + // key - 32 + // update auth - 32 + // mint - 4 + // name string length - MAX_NAME_LENGTH + // name - 4 + // uri string length - MAX_URI_LENGTH + // uri - 4 + // symbol string length - MAX_SYMBOL_LENGTH + // symbol - 2 + // seller fee basis points - 1 + // whether or not there is a creators vec - 4 + // creators vec length - i * MAX_CREATOR_LEN, - bytes: whitelistedCreators[j].info.address, + offset: 1, + bytes: md, }, }, ], - }).then(forEach(processMetaData)), - ); - } - } - } - }; - - const pullEditions = async () => { - console.log('Pulling editions for optimized metadata'); - let setOf100MetadataEditionKeys: string[] = []; - const editionPromises: Promise<{ - keys: string[]; - array: AccountInfo[]; - }>[] = []; - - for (let i = 0; i < tempCache.metadata.length; i++) { - let edition: StringPublicKey; - if (tempCache.metadata[i].info.editionNonce != null) { - edition = ( - await PublicKey.createProgramAddress( - [ - Buffer.from(METADATA_PREFIX), - toPublicKey(METADATA_PROGRAM_ID).toBuffer(), - toPublicKey(tempCache.metadata[i].info.mint).toBuffer(), - new Uint8Array([tempCache.metadata[i].info.editionNonce || 0]), - ], - toPublicKey(METADATA_PROGRAM_ID), + }).then(forEach(processMetaplexAccounts)), ) - ).toBase58(); - } else { - edition = await getEdition(tempCache.metadata[i].info.mint); - } - - setOf100MetadataEditionKeys.push(edition); - - if (setOf100MetadataEditionKeys.length >= 100) { - editionPromises.push( - getMultipleAccounts( - connection, - setOf100MetadataEditionKeys, - 'recent', - ), - ); - setOf100MetadataEditionKeys = []; - } - } - - if (setOf100MetadataEditionKeys.length >= 0) { - editionPromises.push( - getMultipleAccounts(connection, setOf100MetadataEditionKeys, 'recent'), - ); - setOf100MetadataEditionKeys = []; - } - - const responses = await Promise.all(editionPromises); - for (let i = 0; i < responses.length; i++) { - const returnedAccounts = responses[i]; - for (let j = 0; j < returnedAccounts.array.length; j++) { - processMetaData( - { - pubkey: returnedAccounts.keys[j], - account: returnedAccounts.array[j], - }, - updateTemp, - all, - ); - } - } - console.log( - 'Edition size', - Object.keys(tempCache.editions).length, - Object.keys(tempCache.masterEditions).length, - ); - }; - - const IS_BIG_STORE = - all || process.env.NEXT_PUBLIC_BIG_STORE?.toLowerCase() === 'true'; - console.log(`Is big store: ${IS_BIG_STORE}`); - - const additionalPromises: Promise[] = []; - const basePromises = [ - getProgramAccounts(connection, VAULT_ID).then(forEach(processVaultData)), - getProgramAccounts(connection, AUCTION_ID).then(forEach(processAuctions)), - getProgramAccounts(connection, METAPLEX_ID).then( - forEach(processMetaplexAccounts), - ), - IS_BIG_STORE - ? getProgramAccounts(connection, METADATA_PROGRAM_ID).then( - forEach(processMetaData), - ) - : getProgramAccounts(connection, METAPLEX_ID, { - filters: [ - { - dataSize: MAX_WHITELISTED_CREATOR_SIZE, - }, - ], - }).then(pullMetadata), + .flat(), + ) + .flat(), + // whitelisted creators + getProgramAccounts(connection, METAPLEX_ID, { + filters: [ + { + dataSize: MAX_WHITELISTED_CREATOR_SIZE, + }, + ], + }).then(forEach(processMetaplexAccounts)), ]; - await Promise.all(basePromises); - await Promise.all(additionalPromises); - await postProcessMetadata(tempCache, all); - console.log('Metadata size', tempCache.metadata.length); + await Promise.all(promises); - if (isSelectivePullMetadata) { - await pullEditions(); - } + await postProcessMetadata(tempCache); return tempCache; }; +export const loadAccounts = async (connection: Connection) => { + const state: MetaState = getEmptyMetaState(); + const updateState = makeSetter(state); + const forEachAccount = processingAccounts(updateState); + + const loadVaults = () => + getProgramAccounts(connection, VAULT_ID).then( + forEachAccount(processVaultData), + ); + const loadAuctions = () => + getProgramAccounts(connection, AUCTION_ID).then( + forEachAccount(processAuctions), + ); + const loadMetaplex = () => + getProgramAccounts(connection, METAPLEX_ID).then( + forEachAccount(processMetaplexAccounts), + ); + const loadCreators = () => + getProgramAccounts(connection, METAPLEX_ID, { + filters: [ + { + dataSize: MAX_WHITELISTED_CREATOR_SIZE, + }, + ], + }).then(forEachAccount(processMetaplexAccounts)); + const loadMetadata = () => + pullMetadataByCreators(connection, state, updateState); + const loadEditions = () => pullEditions(connection, updateState, state); + + const loading = [ + loadCreators().then(loadMetadata).then(loadEditions), + loadVaults(), + loadAuctions(), + loadMetaplex(), + ]; + + await Promise.all(loading); + + console.log('Metadata size', state.metadata.length); + + return state; +}; + +const pullEditions = async ( + connection: Connection, + updater: UpdateStateValueFunc, + state: MetaState, +) => { + console.log('Pulling editions for optimized metadata'); + + type MultipleAccounts = UnPromise>; + let setOf100MetadataEditionKeys: string[] = []; + const editionPromises: Promise[] = []; + + const loadBatch = () => { + editionPromises.push( + getMultipleAccounts( + connection, + setOf100MetadataEditionKeys, + 'recent', + ).then(processEditions), + ); + setOf100MetadataEditionKeys = []; + }; + + const processEditions = (returnedAccounts: MultipleAccounts) => { + for (let j = 0; j < returnedAccounts.array.length; j++) { + processMetaData( + { + pubkey: returnedAccounts.keys[j], + account: returnedAccounts.array[j], + }, + updater, + ); + } + }; + + for (const metadata of state.metadata) { + let editionKey: StringPublicKey; + if (metadata.info.editionNonce === null) { + editionKey = await getEdition(metadata.info.mint); + } else { + editionKey = ( + await PublicKey.createProgramAddress( + [ + Buffer.from(METADATA_PREFIX), + toPublicKey(METADATA_PROGRAM_ID).toBuffer(), + toPublicKey(metadata.info.mint).toBuffer(), + new Uint8Array([metadata.info.editionNonce || 0]), + ], + toPublicKey(METADATA_PROGRAM_ID), + ) + ).toBase58(); + } + + setOf100MetadataEditionKeys.push(editionKey); + + if (setOf100MetadataEditionKeys.length >= 100) { + loadBatch(); + } + } + + if (setOf100MetadataEditionKeys.length >= 0) { + loadBatch(); + } + + await Promise.all(editionPromises); + + console.log( + 'Edition size', + Object.keys(state.editions).length, + Object.keys(state.masterEditions).length, + ); +}; + +const pullMetadataByCreators = ( + connection: Connection, + state: MetaState, + updater: UpdateStateValueFunc, +): Promise => { + console.log('pulling optimized nfts'); + + const whitelistedCreators = Object.values(state.whitelistedCreatorsByCreator); + + const setter: UpdateStateValueFunc = async (prop, key, value) => { + if (prop === 'metadataByMint') { + await initMetadata(value, state.whitelistedCreatorsByCreator, updater); + } else { + updater(prop, key, value); + } + }; + const forEachAccount = processingAccounts(setter); + + const additionalPromises: Promise[] = []; + for (const creator of whitelistedCreators) { + for (let i = 0; i < MAX_CREATOR_LIMIT; i++) { + const promise = getProgramAccounts(connection, METADATA_PROGRAM_ID, { + filters: [ + { + memcmp: { + offset: + 1 + // key + 32 + // update auth + 32 + // mint + 4 + // name string length + MAX_NAME_LENGTH + // name + 4 + // uri string length + MAX_URI_LENGTH + // uri + 4 + // symbol string length + MAX_SYMBOL_LENGTH + // symbol + 2 + // seller fee basis points + 1 + // whether or not there is a creators vec + 4 + // creators vec length + i * MAX_CREATOR_LEN, + bytes: creator.info.address, + }, + }, + ], + }).then(forEachAccount(processMetaData)); + additionalPromises.push(promise); + } + } + + return Promise.all(additionalPromises); +}; + export const makeSetter = - (state: MetaState) => - (prop: keyof MetaState, key: string, value: ParsedAccount) => { + (state: MetaState): UpdateStateValueFunc => + (prop, key, value) => { if (prop === 'store') { state[prop] = value; - } else if (prop !== 'metadata') { + } else if (prop === 'metadata') { + state.metadata.push(value); + } else { state[prop][key] = value; } return state; }; -const postProcessMetadata = async (tempCache: MetaState, all: boolean) => { - const values = Object.values(tempCache.metadataByMint); +export const processingAccounts = + (updater: UpdateStateValueFunc) => + (fn: ProcessAccountsFunc) => + async (accounts: AccountAndPubkey[]) => { + await createPipelineExecutor( + accounts.values(), + account => fn(account, updater), + { + sequence: 10, + delay: 1, + jobsCount: 3, + }, + ); + }; + +const postProcessMetadata = async (state: MetaState) => { + const values = Object.values(state.metadataByMint); for (const metadata of values) { - await metadataByMintUpdater(metadata, tempCache, all); + await metadataByMintUpdater(metadata, state); } }; export const metadataByMintUpdater = async ( metadata: ParsedAccount, state: MetaState, - all: boolean, ) => { const key = metadata.info.mint; - if ( - all || - isMetadataPartOfStore( - metadata, - state.store, - state.whitelistedCreatorsByCreator, - ) - ) { + if (isMetadataPartOfStore(metadata, state.whitelistedCreatorsByCreator)) { await metadata.info.init(); const masterEditionKey = metadata.info?.masterEdition; if (masterEditionKey) { @@ -293,3 +486,19 @@ export const metadataByMintUpdater = async ( } return state; }; + +export const initMetadata = async ( + metadata: ParsedAccount, + whitelistedCreators: Record>, + setter: UpdateStateValueFunc, +) => { + if (isMetadataPartOfStore(metadata, whitelistedCreators)) { + await metadata.info.init(); + setter('metadataByMint', metadata.info.mint, metadata); + setter('metadata', '', metadata); + const masterEditionKey = metadata.info?.masterEdition; + if (masterEditionKey) { + setter('metadataByMasterEdition', masterEditionKey, metadata); + } + } +}; diff --git a/js/packages/common/src/contexts/meta/meta.tsx b/js/packages/common/src/contexts/meta/meta.tsx index facd1da..4fc9356 100644 --- a/js/packages/common/src/contexts/meta/meta.tsx +++ b/js/packages/common/src/contexts/meta/meta.tsx @@ -2,22 +2,26 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; import { queryExtendedMetadata } from './queryExtendedMetadata'; import { subscribeAccountsChange } from './subscribeAccountsChange'; import { getEmptyMetaState } from './getEmptyMetaState'; -import { loadAccounts } from './loadAccounts'; +import { + limitedLoadAccounts, + loadAccounts, + USE_SPEED_RUN, +} from './loadAccounts'; import { MetaContextState, MetaState } from './types'; import { useConnection } from '../connection'; import { useStore } from '../store'; -import { useQuerySearch } from '../../hooks'; +import { AuctionData, BidderMetadata, BidderPot } from '../../actions'; const MetaContext = React.createContext({ ...getEmptyMetaState(), isLoading: false, + // @ts-ignore + update: () => [AuctionData, BidderMetadata, BidderPot], }); export function MetaProvider({ children = null as any }) { const connection = useConnection(); const { isReady, storeAddress } = useStore(); - const searchParams = useQuerySearch(); - const all = searchParams.get('all') == 'true'; const [state, setState] = useState(getEmptyMetaState()); @@ -26,17 +30,15 @@ export function MetaProvider({ children = null as any }) { const updateMints = useCallback( async metadataByMint => { try { - if (!all) { - const { metadata, mintToMetadata } = await queryExtendedMetadata( - connection, - metadataByMint, - ); - setState(current => ({ - ...current, - metadata, - metadataByMint: mintToMetadata, - })); - } + const { metadata, mintToMetadata } = await queryExtendedMetadata( + connection, + metadataByMint, + ); + setState(current => ({ + ...current, + metadata, + metadataByMint: mintToMetadata, + })); } catch (er) { console.error(er); } @@ -44,30 +46,43 @@ export function MetaProvider({ children = null as any }) { [setState], ); - useEffect(() => { - (async () => { - if (!storeAddress) { - if (isReady) { - setIsLoading(false); - } - return; - } else if (!state.store) { - setIsLoading(true); + async function update(auctionAddress?: any, bidderAddress?: any) { + if (!storeAddress) { + if (isReady) { + setIsLoading(false); } + return; + } else if (!state.store) { + setIsLoading(true); + } - console.log('-----> Query started'); + console.log('-----> Query started'); - const nextState = await loadAccounts(connection, all); + const nextState = !USE_SPEED_RUN + ? await loadAccounts(connection) + : await limitedLoadAccounts(connection); - console.log('------->Query finished'); + console.log('------->Query finished'); - setState(nextState); + setState(nextState); - setIsLoading(false); - console.log('------->set finished'); + setIsLoading(false); + console.log('------->set finished'); - updateMints(nextState.metadataByMint); - })(); + await updateMints(nextState.metadataByMint); + + if (auctionAddress && bidderAddress) { + const auctionBidderKey = auctionAddress + '-' + bidderAddress; + return [ + nextState.auctions[auctionAddress], + nextState.bidderPotsByAuctionAndBidder[auctionBidderKey], + nextState.bidderMetadataByAuctionAndBidder[auctionBidderKey], + ]; + } + } + + useEffect(() => { + update(); }, [connection, setState, updateMints, storeAddress, isReady]); useEffect(() => { @@ -75,7 +90,7 @@ export function MetaProvider({ children = null as any }) { return; } - return subscribeAccountsChange(connection, all, () => state, setState); + return subscribeAccountsChange(connection, () => state, setState); }, [connection, setState, isLoading]); // TODO: fetch names dynamically @@ -110,6 +125,8 @@ export function MetaProvider({ children = null as any }) { diff --git a/js/packages/common/src/contexts/meta/onChangeAccount.ts b/js/packages/common/src/contexts/meta/onChangeAccount.ts index 273204a..9f93656 100644 --- a/js/packages/common/src/contexts/meta/onChangeAccount.ts +++ b/js/packages/common/src/contexts/meta/onChangeAccount.ts @@ -6,7 +6,6 @@ export const onChangeAccount = ( process: ProcessAccountsFunc, setter: UpdateStateValueFunc, - all: boolean, ): ProgramAccountChangeCallback => async info => { const pubkey = pubkeyToString(info.accountId); @@ -16,6 +15,5 @@ export const onChangeAccount = account: info.accountInfo, }, setter, - all, ); }; diff --git a/js/packages/common/src/contexts/meta/processAuctions.ts b/js/packages/common/src/contexts/meta/processAuctions.ts index b1f4105..0925c54 100644 --- a/js/packages/common/src/contexts/meta/processAuctions.ts +++ b/js/packages/common/src/contexts/meta/processAuctions.ts @@ -11,9 +11,9 @@ import { BIDDER_POT_LEN, MAX_AUCTION_DATA_EXTENDED_SIZE, } from '../../actions'; -import { AUCTION_ID } from '../../utils'; -import { ParsedAccount } from '../accounts/types'; -import { cache } from '../accounts/cache'; +import { AUCTION_ID, pubkeyToString } from '../../utils'; +import { ParsedAccount } from '../accounts'; +import { cache } from '../accounts'; import { CheckAccountFunc, ProcessAccountsFunc } from './types'; export const processAuctions: ProcessAccountsFunc = ( @@ -92,7 +92,7 @@ export const processAuctions: ProcessAccountsFunc = ( }; const isAuctionAccount: CheckAccountFunc = account => - (account.owner as unknown as any) === AUCTION_ID; + pubkeyToString(account.owner) === AUCTION_ID; const isExtendedAuctionAccount: CheckAccountFunc = account => account.data.length === MAX_AUCTION_DATA_EXTENDED_SIZE; diff --git a/js/packages/common/src/contexts/meta/processMetaData.ts b/js/packages/common/src/contexts/meta/processMetaData.ts index 03020bc..93e7636 100644 --- a/js/packages/common/src/contexts/meta/processMetaData.ts +++ b/js/packages/common/src/contexts/meta/processMetaData.ts @@ -12,14 +12,13 @@ import { MetadataKey, } from '../../actions'; import { ParsedAccount } from '../accounts/types'; -import { METADATA_PROGRAM_ID } from '../../utils'; +import { METADATA_PROGRAM_ID, pubkeyToString } from '../../utils'; -export const processMetaData: ProcessAccountsFunc = ( +export const processMetaData: ProcessAccountsFunc = async ( { account, pubkey }, setter, ) => { if (!isMetadataAccount(account)) return; - try { if (isMetadataV1Account(account)) { const metadata = decodeMetadata(account.data); @@ -33,7 +32,7 @@ export const processMetaData: ProcessAccountsFunc = ( account, info: metadata, }; - setter('metadataByMint', metadata.mint, parsedAccount); + await setter('metadataByMint', metadata.mint, parsedAccount); } } @@ -84,9 +83,8 @@ export const processMetaData: ProcessAccountsFunc = ( } }; -const isMetadataAccount = (account: AccountInfo) => { - return (account.owner as unknown as any) === METADATA_PROGRAM_ID; -}; +const isMetadataAccount = (account: AccountInfo) => + account && pubkeyToString(account.owner) === METADATA_PROGRAM_ID; const isMetadataV1Account = (account: AccountInfo) => account.data[0] === MetadataKey.MetadataV1; diff --git a/js/packages/common/src/contexts/meta/processMetaplexAccounts.ts b/js/packages/common/src/contexts/meta/processMetaplexAccounts.ts index da324f1..79bab35 100644 --- a/js/packages/common/src/contexts/meta/processMetaplexAccounts.ts +++ b/js/packages/common/src/contexts/meta/processMetaplexAccounts.ts @@ -18,16 +18,15 @@ import { BidRedemptionTicketV2, decodeSafetyDepositConfig, SafetyDepositConfig, -} from '../../models/metaplex'; +} from '../../models'; import { ProcessAccountsFunc } from './types'; -import { METAPLEX_ID, programIds } from '../../utils'; -import { ParsedAccount } from '../accounts/types'; -import { cache } from '../accounts/cache'; +import { METAPLEX_ID, programIds, pubkeyToString } from '../../utils'; +import { ParsedAccount } from '../accounts'; +import { cache } from '../accounts'; export const processMetaplexAccounts: ProcessAccountsFunc = async ( { account, pubkey }, setter, - useAll, ) => { if (!isMetaplexAccount(account)) return; @@ -40,7 +39,7 @@ export const processMetaplexAccounts: ProcessAccountsFunc = async ( ) { const storeKey = new PublicKey(account.data.slice(1, 33)); - if ((STORE_ID && storeKey.equals(STORE_ID)) || useAll) { + if (STORE_ID && storeKey.equals(STORE_ID)) { const auctionManager = decodeAuctionManager(account.data); const parsedAccount: ParsedAccount< @@ -112,7 +111,6 @@ export const processMetaplexAccounts: ProcessAccountsFunc = async ( if (STORE_ID && pubkey === STORE_ID.toBase58()) { setter('store', pubkey, parsedAccount); } - setter('stores', pubkey, parsedAccount); } if (isSafetyDepositConfigV1Account(account)) { @@ -152,14 +150,6 @@ export const processMetaplexAccounts: ProcessAccountsFunc = async ( ); } } - - if (useAll) { - setter( - 'creators', - parsedAccount.info.address + '-' + pubkey, - parsedAccount, - ); - } } } catch { // ignore errors @@ -168,7 +158,7 @@ export const processMetaplexAccounts: ProcessAccountsFunc = async ( }; const isMetaplexAccount = (account: AccountInfo) => - (account.owner as unknown as any) === METAPLEX_ID; + pubkeyToString(account.owner) === METAPLEX_ID; const isAuctionManagerV1Account = (account: AccountInfo) => account.data[0] === MetaplexKey.AuctionManagerV1; diff --git a/js/packages/common/src/contexts/meta/processVaultData.ts b/js/packages/common/src/contexts/meta/processVaultData.ts index 1ab148b..2d25381 100644 --- a/js/packages/common/src/contexts/meta/processVaultData.ts +++ b/js/packages/common/src/contexts/meta/processVaultData.ts @@ -6,7 +6,7 @@ import { Vault, VaultKey, } from '../../actions'; -import { VAULT_ID } from '../../utils'; +import { VAULT_ID, pubkeyToString } from '../../utils'; import { ParsedAccount } from '../accounts/types'; import { ProcessAccountsFunc } from './types'; @@ -47,7 +47,7 @@ export const processVaultData: ProcessAccountsFunc = ( }; const isVaultAccount = (account: AccountInfo) => - (account.owner as unknown as any) === VAULT_ID; + pubkeyToString(account.owner) === VAULT_ID; const isSafetyDepositBoxV1Account = (account: AccountInfo) => account.data[0] === VaultKey.SafetyDepositBoxV1; diff --git a/js/packages/common/src/contexts/meta/queryExtendedMetadata.ts b/js/packages/common/src/contexts/meta/queryExtendedMetadata.ts index f265168..10f429b 100644 --- a/js/packages/common/src/contexts/meta/queryExtendedMetadata.ts +++ b/js/packages/common/src/contexts/meta/queryExtendedMetadata.ts @@ -1,10 +1,10 @@ import { MintInfo } from '@solana/spl-token'; import { Connection } from '@solana/web3.js'; import { Metadata } from '../../actions'; -import { ParsedAccount } from '../accounts/types'; -import { cache } from '../accounts/cache'; -import { getMultipleAccounts } from '../accounts/getMultipleAccounts'; -import { MintParser } from '../accounts/parsesrs'; +import { ParsedAccount } from '../accounts'; +import { cache } from '../accounts'; +import { getMultipleAccounts } from '../accounts'; +import { MintParser } from '../accounts'; export const queryExtendedMetadata = async ( connection: Connection, diff --git a/js/packages/common/src/contexts/meta/subscribeAccountsChange.ts b/js/packages/common/src/contexts/meta/subscribeAccountsChange.ts index d1c32cb..d9ad9f2 100644 --- a/js/packages/common/src/contexts/meta/subscribeAccountsChange.ts +++ b/js/packages/common/src/contexts/meta/subscribeAccountsChange.ts @@ -6,7 +6,7 @@ import { toPublicKey, VAULT_ID, } from '../../utils'; -import { makeSetter, metadataByMintUpdater } from './loadAccounts'; +import { makeSetter, initMetadata } from './loadAccounts'; import { onChangeAccount } from './onChangeAccount'; import { processAuctions } from './processAuctions'; import { processMetaData } from './processMetaData'; @@ -16,7 +16,6 @@ import { MetaState, UpdateStateValueFunc } from './types'; export const subscribeAccountsChange = ( connection: Connection, - all: boolean, getState: () => MetaState, setState: (v: MetaState) => void, ) => { @@ -31,40 +30,49 @@ export const subscribeAccountsChange = ( subscriptions.push( connection.onProgramAccountChange( toPublicKey(VAULT_ID), - onChangeAccount(processVaultData, updateStateValue, all), + onChangeAccount(processVaultData, updateStateValue), ), ); subscriptions.push( connection.onProgramAccountChange( toPublicKey(AUCTION_ID), - onChangeAccount(processAuctions, updateStateValue, all), + onChangeAccount(processAuctions, updateStateValue), ), ); subscriptions.push( connection.onProgramAccountChange( toPublicKey(METAPLEX_ID), - onChangeAccount(processMetaplexAccounts, updateStateValue, all), + onChangeAccount(processMetaplexAccounts, updateStateValue), ), ); subscriptions.push( connection.onProgramAccountChange( toPublicKey(METADATA_PROGRAM_ID), - onChangeAccount( - processMetaData, - async (prop, key, value) => { - if (prop === 'metadataByMint') { - const state = getState(); - const nextState = await metadataByMintUpdater(value, state, all); - setState(nextState); - } else { - updateStateValue(prop, key, value); - } - }, - all, - ), + onChangeAccount(processMetaData, async (prop, key, value) => { + const state = { ...getState() }; + const setter = makeSetter(state); + let hasChanges = false; + const updater: UpdateStateValueFunc = (...args) => { + hasChanges = true; + setter(...args); + }; + + if (prop === 'metadataByMint') { + await initMetadata( + value, + state.whitelistedCreatorsByCreator, + updater, + ); + } else { + updater(prop, key, value); + } + if (hasChanges) { + setState(state); + } + }), ), ); diff --git a/js/packages/common/src/contexts/meta/types.ts b/js/packages/common/src/contexts/meta/types.ts index e41f7f0..fe1c992 100644 --- a/js/packages/common/src/contexts/meta/types.ts +++ b/js/packages/common/src/contexts/meta/types.ts @@ -71,12 +71,18 @@ export interface MetaState { ParsedAccount >; payoutTickets: Record>; - stores: Record>; - creators: Record>; } export interface MetaContextState extends MetaState { isLoading: boolean; + update: ( + auctionAddress?: any, + bidderAddress?: any, + ) => [ + ParsedAccount, + ParsedAccount, + ParsedAccount, + ]; } export type AccountAndPubkey = { @@ -84,16 +90,19 @@ export type AccountAndPubkey = { account: AccountInfo; }; -export type UpdateStateValueFunc = ( +export type UpdateStateValueFunc = ( prop: keyof MetaState, key: string, value: ParsedAccount, -) => void; +) => T; export type ProcessAccountsFunc = ( account: PublicKeyStringAndAccount, setter: UpdateStateValueFunc, - useAll: boolean, ) => void; export type CheckAccountFunc = (account: AccountInfo) => boolean; + +export type UnPromise> = T extends Promise + ? U + : never; diff --git a/js/packages/common/src/contexts/meta/web3.ts b/js/packages/common/src/contexts/meta/web3.ts new file mode 100644 index 0000000..4449101 --- /dev/null +++ b/js/packages/common/src/contexts/meta/web3.ts @@ -0,0 +1,61 @@ +import { AccountInfo, Connection } from '@solana/web3.js'; +import { StringPublicKey } from '../../utils/ids'; +import { AccountAndPubkey } from './types'; + +export async function getProgramAccounts( + connection: Connection, + programId: StringPublicKey, + configOrCommitment?: any, +): Promise> { + const extra: any = {}; + let commitment; + //let encoding; + + if (configOrCommitment) { + if (typeof configOrCommitment === 'string') { + commitment = configOrCommitment; + } else { + commitment = configOrCommitment.commitment; + //encoding = configOrCommitment.encoding; + + if (configOrCommitment.dataSlice) { + extra.dataSlice = configOrCommitment.dataSlice; + } + + if (configOrCommitment.filters) { + extra.filters = configOrCommitment.filters; + } + } + } + + const args = connection._buildArgs([programId], commitment, 'base64', extra); + const unsafeRes = await (connection as any)._rpcRequest( + 'getProgramAccounts', + args, + ); + + return unsafeResAccounts(unsafeRes.result); +} + +export function unsafeAccount(account: AccountInfo<[string, string]>) { + return { + // TODO: possible delay parsing could be added here + data: Buffer.from(account.data[0], 'base64'), + executable: account.executable, + lamports: account.lamports, + // TODO: maybe we can do it in lazy way? or just use string + owner: account.owner, + } as AccountInfo; +} + +export function unsafeResAccounts( + data: Array<{ + account: AccountInfo<[string, string]>; + pubkey: string; + }>, +) { + return data.map(item => ({ + account: unsafeAccount(item.account), + pubkey: item.pubkey, + })); +} diff --git a/js/packages/common/src/models/metaplex/claimBid.ts b/js/packages/common/src/models/metaplex/claimBid.ts index a36c045..b18dadf 100644 --- a/js/packages/common/src/models/metaplex/claimBid.ts +++ b/js/packages/common/src/models/metaplex/claimBid.ts @@ -2,7 +2,7 @@ import { SYSVAR_CLOCK_PUBKEY, TransactionInstruction } from '@solana/web3.js'; import { serialize } from 'borsh'; import { getAuctionKeys, ClaimBidArgs, SCHEMA } from '.'; -import { getBidderPotKey } from '../../actions'; +import { getBidderPotKey, getAuctionExtended } from '../../actions'; import { programIds, StringPublicKey, toPublicKey } from '../../utils'; export async function claimBid( @@ -30,6 +30,11 @@ export async function claimBid( const value = new ClaimBidArgs(); const data = Buffer.from(serialize(SCHEMA, value)); + const auctionExtendedKey = await getAuctionExtended({ + auctionProgramId: PROGRAM_IDS.auction, + resource: vault, + }); + const keys = [ { pubkey: toPublicKey(acceptPayment), @@ -92,6 +97,11 @@ export async function claimBid( isSigner: false, isWritable: false, }, + { + pubkey: toPublicKey(auctionExtendedKey), + isSigner: false, + isWritable: false, + }, ]; instructions.push( diff --git a/js/packages/common/src/models/metaplex/index.ts b/js/packages/common/src/models/metaplex/index.ts index 237afb5..a1c613b 100644 --- a/js/packages/common/src/models/metaplex/index.ts +++ b/js/packages/common/src/models/metaplex/index.ts @@ -11,6 +11,7 @@ import { Metadata, SafetyDepositBox, Vault, + getAuctionExtended, } from '../../actions'; import { AccountParser, ParsedAccount } from '../../contexts'; import { @@ -88,6 +89,7 @@ export class PayoutTicket { this.amountPaid = args.amountPaid; } } + export class AuctionManager { pubkey: StringPublicKey; store: StringPublicKey; @@ -259,6 +261,7 @@ export class AuctionManagerV2 { vault: StringPublicKey; acceptPayment: StringPublicKey; state: AuctionManagerStateV2; + auctionDataExtended?: StringPublicKey; constructor(args: { store: StringPublicKey; @@ -275,6 +278,13 @@ export class AuctionManagerV2 { this.vault = args.vault; this.acceptPayment = args.acceptPayment; this.state = args.state; + + const auction = programIds().auction; + + getAuctionExtended({ + auctionProgramId: auction, + resource: this.vault, + }).then(val => (this.auctionDataExtended = val)); } } @@ -319,6 +329,15 @@ export class RedeemFullRightsTransferBidArgs { export class StartAuctionArgs { instruction = 5; } + +export class EndAuctionArgs { + instruction = 21; + reveal: BN[] | null; + constructor(args: { reveal: BN[] | null }) { + this.reveal = args.reveal; + } +} + export class ClaimBidArgs { instruction = 6; } @@ -960,6 +979,16 @@ export const SCHEMA = new Map([ fields: [['instruction', 'u8']], }, ], + [ + EndAuctionArgs, + { + kind: 'struct', + fields: [ + ['instruction', 'u8'], + ['reveal', { kind: 'option', type: [BN] }], + ], + }, + ], [ ClaimBidArgs, { diff --git a/js/packages/common/src/models/metaplex/redeemBid.ts b/js/packages/common/src/models/metaplex/redeemBid.ts index 9f1ad94..9d3bb7f 100644 --- a/js/packages/common/src/models/metaplex/redeemBid.ts +++ b/js/packages/common/src/models/metaplex/redeemBid.ts @@ -14,7 +14,7 @@ import { RedeemUnusedWinningConfigItemsAsAuctioneerArgs, SCHEMA, } from '.'; -import { VAULT_PREFIX } from '../../actions'; +import { VAULT_PREFIX, getAuctionExtended } from '../../actions'; import { findProgramAddress, programIds, @@ -68,6 +68,11 @@ export async function redeemBid( safetyDeposit, ); + const auctionExtended = await getAuctionExtended({ + auctionProgramId: PROGRAM_IDS.auction, + resource: vault, + }); + const value = auctioneerReclaimIndex !== undefined ? new RedeemUnusedWinningConfigItemsAsAuctioneerArgs({ @@ -172,6 +177,11 @@ export async function redeemBid( isSigner: false, isWritable: false, }, + { + pubkey: toPublicKey(auctionExtended), + isSigner: false, + isWritable: false, + }, ]; if (isPrintingType && masterEdition && reservationList) { diff --git a/js/packages/common/src/models/metaplex/redeemFullRightsTransferBid.ts b/js/packages/common/src/models/metaplex/redeemFullRightsTransferBid.ts index 64377f9..ec0839c 100644 --- a/js/packages/common/src/models/metaplex/redeemFullRightsTransferBid.ts +++ b/js/packages/common/src/models/metaplex/redeemFullRightsTransferBid.ts @@ -14,7 +14,7 @@ import { RedeemUnusedWinningConfigItemsAsAuctioneerArgs, SCHEMA, } from '.'; -import { VAULT_PREFIX } from '../../actions'; +import { VAULT_PREFIX, getAuctionExtended } from '../../actions'; import { findProgramAddress, programIds, @@ -67,6 +67,11 @@ export async function redeemFullRightsTransferBid( safetyDeposit, ); + const auctionExtended = await getAuctionExtended({ + auctionProgramId: PROGRAM_IDS.auction, + resource: vault, + }); + const value = auctioneerReclaimIndex !== undefined ? new RedeemUnusedWinningConfigItemsAsAuctioneerArgs({ @@ -181,6 +186,12 @@ export async function redeemFullRightsTransferBid( isSigner: false, isWritable: false, }, + + { + pubkey: toPublicKey(auctionExtended), + isSigner: false, + isWritable: false, + }, ]; instructions.push( diff --git a/js/packages/common/src/models/metaplex/redeemPrintingV2Bid.ts b/js/packages/common/src/models/metaplex/redeemPrintingV2Bid.ts index 3ed4c15..f7cc5ba 100644 --- a/js/packages/common/src/models/metaplex/redeemPrintingV2Bid.ts +++ b/js/packages/common/src/models/metaplex/redeemPrintingV2Bid.ts @@ -14,7 +14,12 @@ import { SCHEMA, getSafetyDepositConfig, } from '.'; -import { getEdition, getEditionMarkPda, getMetadata } from '../../actions'; +import { + getEdition, + getEditionMarkPda, + getMetadata, + getAuctionExtended, +} from '../../actions'; import { programIds, StringPublicKey, toPublicKey } from '../../utils'; export async function redeemPrintingV2Bid( @@ -63,6 +68,10 @@ export async function redeemPrintingV2Bid( const value = new RedeemPrintingV2BidArgs({ editionOffset, winIndex }); const data = Buffer.from(serialize(SCHEMA, value)); + const extended = await getAuctionExtended({ + auctionProgramId: PROGRAM_IDS.auction, + resource: vault, + }); const keys = [ { pubkey: toPublicKey(auctionManagerKey), @@ -193,6 +202,11 @@ export async function redeemPrintingV2Bid( isSigner: false, isWritable: false, }, + { + pubkey: toPublicKey(extended), + isSigner: false, + isWritable: false, + }, ]; instructions.push( diff --git a/js/packages/common/src/utils/createPipelineExecutor.ts b/js/packages/common/src/utils/createPipelineExecutor.ts new file mode 100644 index 0000000..44bc937 --- /dev/null +++ b/js/packages/common/src/utils/createPipelineExecutor.ts @@ -0,0 +1,54 @@ +export async function createPipelineExecutor( + data: IterableIterator, + executor: (d: T) => void, + { + delay = 0, + jobsCount = 1, + sequence = 1, + }: { + delay?: number; + jobsCount?: number; + sequence?: number; + } = {}, +) { + function execute(iter: IteratorResult) { + executor(iter.value); + } + + async function next() { + if (sequence <= 1) { + const iter = data.next(); + if (iter.done) { + return; + } + await execute(iter); + } else { + const promises: any[] = []; + let isDone = false; + for (let i = 0; i < sequence; i++) { + const iter = data.next(); + if (!iter.done) { + promises.push(execute(iter)); + } else { + isDone = true; + break; + } + } + await Promise.all(promises); + if (isDone) { + return; + } + } + if (delay > 0) { + await new Promise(resolve => setTimeout(resolve, delay)); + } else { + await Promise.resolve(); + } + await next(); + } + const result = new Array>(jobsCount); + for (let i = 0; i < jobsCount; i++) { + result[i] = next(); + } + await Promise.all(result); +} diff --git a/js/packages/common/src/utils/index.tsx b/js/packages/common/src/utils/index.tsx index e27e980..c01f409 100644 --- a/js/packages/common/src/utils/index.tsx +++ b/js/packages/common/src/utils/index.tsx @@ -9,3 +9,4 @@ export * from './strings'; export * as shortvec from './shortvec'; export * from './isValidHttpUrl'; export * from './borsh'; +export * from './createPipelineExecutor'; diff --git a/js/packages/fair-launch/.env b/js/packages/fair-launch/.env new file mode 100644 index 0000000..134570d --- /dev/null +++ b/js/packages/fair-launch/.env @@ -0,0 +1,7 @@ +REACT_APP_CANDY_MACHINE_ID=EodXoBBFMWMXe3KKpwAFRa3BHDDWF3y7S8DcGRUTdG9U + +REACT_APP_SOLANA_NETWORK=mainnet-beta +REACT_APP_SOLANA_RPC_HOST=https://api.mainnet-beta.solana.com + +# Phase 1 +REACT_APP_FAIR_LAUNCH_ID=4stZ5uFD1EdS8wKgrLSqE54YW5dS4KUSyGUCeehVua3P diff --git a/js/packages/fair-launch/package.json b/js/packages/fair-launch/package.json new file mode 100644 index 0000000..24a0762 --- /dev/null +++ b/js/packages/fair-launch/package.json @@ -0,0 +1,62 @@ +{ + "name": "candy-machine-mint", + "version": "0.1.0", + "private": true, + "dependencies": { + "@material-ui/core": "^4.12.3", + "@material-ui/icons": "^4.11.2", + "@material-ui/lab": "^4.0.0-alpha.60", + "@project-serum/anchor": "^0.14.0", + "@solana/spl-token": "^0.1.8", + "@solana/wallet-adapter-base": "^0.5.2", + "@solana/wallet-adapter-material-ui": "^0.8.3", + "@solana/wallet-adapter-react": "^0.9.1", + "@solana/wallet-adapter-react-ui": "^0.1.0", + "@solana/wallet-adapter-wallets": "^0.7.5", + "canvas-confetti": "^1.4.0", + "@solana/web3.js": "^1.24.1", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.1.0", + "@testing-library/user-event": "^12.1.10", + "@types/jest": "^26.0.15", + "@types/node": "^12.0.0", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "react": "^17.0.2", + "react-countdown": "^2.3.2", + "react-dom": "^17.0.2", + "react-scripts": "4.0.3", + "styled-components": "^5.3.1", + "typescript": "^4.1.2", + "web-vitals": "^1.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "deploy:gh": "gh-pages -d ./build/ --repo https://github.com/metaplex-foundation/metaplex -t true --branch gh-pages", + "deploy": "cross-env ASSET_PREFIX=/metaplex/ yarn build && yarn deploy:gh" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/styled-components": "^5.1.14" + } +} diff --git a/js/packages/fair-launch/public/favicon.ico b/js/packages/fair-launch/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/js/packages/fair-launch/public/favicon.ico differ diff --git a/js/packages/fair-launch/public/index.html b/js/packages/fair-launch/public/index.html new file mode 100644 index 0000000..aa069f2 --- /dev/null +++ b/js/packages/fair-launch/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/js/packages/fair-launch/public/logo192.png b/js/packages/fair-launch/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/js/packages/fair-launch/public/logo192.png differ diff --git a/js/packages/fair-launch/public/logo512.png b/js/packages/fair-launch/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/js/packages/fair-launch/public/logo512.png differ diff --git a/js/packages/fair-launch/public/manifest.json b/js/packages/fair-launch/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/js/packages/fair-launch/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/js/packages/fair-launch/public/robots.txt b/js/packages/fair-launch/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/js/packages/fair-launch/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/js/packages/fair-launch/src/App.css b/js/packages/fair-launch/src/App.css new file mode 100644 index 0000000..e994711 --- /dev/null +++ b/js/packages/fair-launch/src/App.css @@ -0,0 +1,3 @@ +.App { + text-align: center; +} \ No newline at end of file diff --git a/js/packages/fair-launch/src/App.test.tsx b/js/packages/fair-launch/src/App.test.tsx new file mode 100644 index 0000000..2a68616 --- /dev/null +++ b/js/packages/fair-launch/src/App.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + render(); + const linkElement = screen.getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/js/packages/fair-launch/src/App.tsx b/js/packages/fair-launch/src/App.tsx new file mode 100644 index 0000000..bbdb4ab --- /dev/null +++ b/js/packages/fair-launch/src/App.tsx @@ -0,0 +1,76 @@ +import './App.css'; +import { useMemo } from 'react'; + +import Home from './Home'; + +import * as anchor from '@project-serum/anchor'; +import { clusterApiUrl } from '@solana/web3.js'; +import { WalletAdapterNetwork } from '@solana/wallet-adapter-base'; +import { + getPhantomWallet, + getSolflareWallet, + getSolletWallet, +} from '@solana/wallet-adapter-wallets'; + +import { + ConnectionProvider, + WalletProvider, +} from '@solana/wallet-adapter-react'; + +import { WalletDialogProvider } from '@solana/wallet-adapter-material-ui'; +import { ThemeProvider, createTheme } from '@material-ui/core'; +import { ConfettiProvider } from './confetti'; + +const theme = createTheme({ + palette: { + type: 'dark', + }, +}); + +const candyMachineId = process.env.REACT_APP_CANDY_MACHINE_ID + ? new anchor.web3.PublicKey(process.env.REACT_APP_CANDY_MACHINE_ID) + : undefined; + +const fairLaunchId = new anchor.web3.PublicKey( + process.env.REACT_APP_FAIR_LAUNCH_ID!, +); + +const network = process.env.REACT_APP_SOLANA_NETWORK as WalletAdapterNetwork; + +const rpcHost = process.env.REACT_APP_SOLANA_RPC_HOST!; +const connection = new anchor.web3.Connection(rpcHost); + +const startDateSeed = parseInt(process.env.REACT_APP_CANDY_START_DATE!, 10); + +const txTimeout = 30000; // milliseconds (confirm this works for your project) + +const App = () => { + const endpoint = useMemo(() => clusterApiUrl(network), []); + + const wallets = useMemo( + () => [getPhantomWallet(), getSolflareWallet(), getSolletWallet()], + [], + ); + + return ( + + + + + + + + + + + + ); +}; + +export default App; diff --git a/js/packages/fair-launch/src/Home.tsx b/js/packages/fair-launch/src/Home.tsx new file mode 100644 index 0000000..0d369c3 --- /dev/null +++ b/js/packages/fair-launch/src/Home.tsx @@ -0,0 +1,1196 @@ +import { useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { + CircularProgress, + Container, + IconButton, + Link, + Slider, + Snackbar, +} from '@material-ui/core'; +import Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import Grid from '@material-ui/core/Grid'; +import { createStyles, Theme } from '@material-ui/core/styles'; +import { PhaseCountdown } from './countdown'; +import Dialog from '@material-ui/core/Dialog'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import CloseIcon from '@material-ui/icons/Close'; + +import Alert from '@material-ui/lab/Alert'; + +import * as anchor from '@project-serum/anchor'; + +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; + +import { useWallet } from '@solana/wallet-adapter-react'; +import { WalletDialogButton } from '@solana/wallet-adapter-material-ui'; + +import { + awaitTransactionSignatureConfirmation, + CandyMachineAccount, + getCandyMachineState, + mintOneToken, +} from './candy-machine'; + +import { + FairLaunchAccount, + getFairLaunchState, + punchTicket, + purchaseTicket, + receiveRefund, +} from './fair-launch'; + +import { formatNumber, getAtaForMint, toDate } from './utils'; +import Countdown from 'react-countdown'; + +const ConnectButton = styled(WalletDialogButton)` + width: 100%; + height: 60px; + margin-top: 10px; + margin-bottom: 5px; + background: linear-gradient(180deg, #604ae5 0%, #813eee 100%); + color: white; + font-size: 16px; + font-weight: bold; +`; + +const MintContainer = styled.div``; // add your styles here + +const MintButton = styled(Button)` + width: 100%; + height: 60px; + margin-top: 10px; + margin-bottom: 5px; + background: linear-gradient(180deg, #604ae5 0%, #813eee 100%); + color: white; + font-size: 16px; + font-weight: bold; +`; // add your styles here + +const dialogStyles: any = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }); + +const ValueSlider = styled(Slider)({ + color: '#C0D5FE', + height: 8, + '& > *': { + height: 4, + }, + '& .MuiSlider-track': { + border: 'none', + height: 4, + }, + '& .MuiSlider-thumb': { + height: 24, + width: 24, + marginTop: -10, + background: 'linear-gradient(180deg, #604AE5 0%, #813EEE 100%)', + border: '2px solid currentColor', + '&:focus, &:hover, &.Mui-active, &.Mui-focusVisible': { + boxShadow: 'inherit', + }, + '&:before': { + display: 'none', + }, + }, + '& .MuiSlider-valueLabel': { + '& > *': { + background: 'linear-gradient(180deg, #604AE5 0%, #813EEE 100%)', + }, + lineHeight: 1.2, + fontSize: 12, + padding: 0, + width: 32, + height: 32, + marginLeft: 9, + }, +}); + +enum Phase { + Phase0, + Phase1, + Phase2, + Lottery, + Phase3, + Phase4, + Unknown, +} + +const Header = (props: { + phaseName: string; + desc: string; + date: anchor.BN | undefined; + status?: string; +}) => { + const { phaseName, desc, date, status } = props; + return ( + + + + {phaseName} + + + {desc} + + + + + + + ); +}; + +function getPhase( + fairLaunch: FairLaunchAccount | undefined, + candyMachine: CandyMachineAccount | undefined, +): Phase { + const curr = new Date().getTime(); + + const phaseOne = toDate(fairLaunch?.state.data.phaseOneStart)?.getTime(); + const phaseOneEnd = toDate(fairLaunch?.state.data.phaseOneEnd)?.getTime(); + const phaseTwoEnd = toDate(fairLaunch?.state.data.phaseTwoEnd)?.getTime(); + const candyMachineGoLive = toDate(candyMachine?.state.goLiveDate)?.getTime(); + + if (phaseOne && curr < phaseOne) { + return Phase.Phase0; + } else if (phaseOneEnd && curr <= phaseOneEnd) { + return Phase.Phase1; + } else if (phaseTwoEnd && curr <= phaseTwoEnd) { + return Phase.Phase2; + } else if (!fairLaunch?.state.phaseThreeStarted) { + return Phase.Lottery; + } else if ( + fairLaunch?.state.phaseThreeStarted && + candyMachineGoLive && + curr > candyMachineGoLive + ) { + return Phase.Phase4; + } else if (fairLaunch?.state.phaseThreeStarted) { + return Phase.Phase3; + } + + return Phase.Unknown; +} + +export interface HomeProps { + candyMachineId?: anchor.web3.PublicKey; + fairLaunchId: anchor.web3.PublicKey; + connection: anchor.web3.Connection; + startDate: number; + txTimeout: number; +} + +const FAIR_LAUNCH_LOTTERY_SIZE = + 8 + // discriminator + 32 + // fair launch + 1 + // bump + 8; // size of bitmask ones + +const isWinner = (fairLaunch: FairLaunchAccount | undefined): boolean => { + if ( + !fairLaunch?.lottery.data || + !fairLaunch?.lottery.data.length || + !fairLaunch?.ticket.data?.seq || + !fairLaunch?.state.phaseThreeStarted + ) { + return false; + } + + const myByte = + fairLaunch.lottery.data[ + FAIR_LAUNCH_LOTTERY_SIZE + + Math.floor(fairLaunch.ticket.data?.seq.toNumber() / 8) + ]; + + const positionFromRight = 7 - (fairLaunch.ticket.data?.seq.toNumber() % 8); + const mask = Math.pow(2, positionFromRight); + const isWinner = myByte & mask; + return isWinner > 0; +}; + +const Home = (props: HomeProps) => { + const [fairLaunchBalance, setFairLaunchBalance] = useState(); + const [yourSOLBalance, setYourSOLBalance] = useState(null); + + const [isMinting, setIsMinting] = useState(false); // true when user got to press MINT + const [contributed, setContributed] = useState(0); + + const wallet = useWallet(); + + const anchorWallet = useMemo(() => { + if ( + !wallet || + !wallet.publicKey || + !wallet.signAllTransactions || + !wallet.signTransaction + ) { + return; + } + + return { + publicKey: wallet.publicKey, + signAllTransactions: wallet.signAllTransactions, + signTransaction: wallet.signTransaction, + } as anchor.Wallet; + }, [wallet]); + + const [alertState, setAlertState] = useState({ + open: false, + message: '', + severity: undefined, + }); + + const [fairLaunch, setFairLaunch] = useState(); + const [candyMachine, setCandyMachine] = useState(); + const [howToOpen, setHowToOpen] = useState(false); + const [refundExplainerOpen, setRefundExplainerOpen] = useState(false); + const [antiRugPolicyOpen, setAnitRugPolicyOpen] = useState(false); + + const onMint = async () => { + try { + setIsMinting(true); + if (wallet.connected && candyMachine?.program && wallet.publicKey) { + if (fairLaunch?.ticket.data?.state.unpunched && isWinner(fairLaunch)) { + await onPunchTicket(); + } + + const mintTxId = await mintOneToken(candyMachine, wallet.publicKey); + + const status = await awaitTransactionSignatureConfirmation( + mintTxId, + props.txTimeout, + props.connection, + 'singleGossip', + false, + ); + + if (!status?.err) { + setAlertState({ + open: true, + message: 'Congratulations! Mint succeeded!', + severity: 'success', + }); + } else { + setAlertState({ + open: true, + message: 'Mint failed! Please try again!', + severity: 'error', + }); + } + } + } catch (error: any) { + // TODO: blech: + let message = error.msg || 'Minting failed! Please try again!'; + if (!error.msg) { + if (!error.message) { + message = 'Transaction Timeout! Please try again.'; + } else if (error.message.indexOf('0x138')) { + } else if (error.message.indexOf('0x137')) { + message = `SOLD OUT!`; + } else if (error.message.indexOf('0x135')) { + message = `Insufficient funds to mint. Please fund your wallet.`; + } + } else { + if (error.code === 311) { + message = `SOLD OUT!`; + window.location.reload(); + } else if (error.code === 312) { + message = `Minting period hasn't started yet.`; + } + } + + setAlertState({ + open: true, + message, + severity: 'error', + }); + } finally { + setIsMinting(false); + } + }; + + useEffect(() => { + (async () => { + if (!anchorWallet) { + return; + } + + try { + const balance = await props.connection.getBalance( + anchorWallet.publicKey, + ); + setYourSOLBalance(balance); + + const state = await getFairLaunchState( + anchorWallet, + props.fairLaunchId, + props.connection, + ); + + setFairLaunch(state); + + try { + if (state.state.tokenMint) { + const fairLaunchBalance = + await props.connection.getTokenAccountBalance( + ( + await getAtaForMint( + state.state.tokenMint, + anchorWallet.publicKey, + ) + )[0], + ); + + if (fairLaunchBalance.value) { + setFairLaunchBalance(fairLaunchBalance.value.uiAmount || 0); + } + } + } catch (e) { + console.log('Problem getting fair launch token balance'); + console.log(e); + } + setContributed( + ( + state.state.currentMedian || state.state.data.priceRangeStart + ).toNumber() / LAMPORTS_PER_SOL, + ); + } catch (e) { + console.log('Problem getting fair launch state'); + console.log(e); + } + if (props.candyMachineId) { + try { + const cndy = await getCandyMachineState( + anchorWallet, + props.candyMachineId, + props.connection, + ); + setCandyMachine(cndy); + } catch (e) { + console.log('Problem getting candy machine state'); + console.log(e); + } + } else { + console.log('No candy machine detected in configuration.'); + } + })(); + }, [ + anchorWallet, + props.candyMachineId, + props.connection, + props.fairLaunchId, + ]); + + const min = formatNumber.asNumber(fairLaunch?.state.data.priceRangeStart); + const max = formatNumber.asNumber(fairLaunch?.state.data.priceRangeEnd); + const step = formatNumber.asNumber(fairLaunch?.state.data.tickSize); + const median = formatNumber.asNumber(fairLaunch?.state.currentMedian); + const marks = [ + { + value: min || 0, + label: `${min} SOL`, + }, + // TODO:L + { + value: median || 0, + label: `${median}`, + }, + // display user comitted value + // { + // value: 37, + // label: '37°C', + // }, + { + value: max || 0, + label: `${max} SOL`, + }, + ].filter(_ => _ !== undefined && _.value !== 0) as any; + + const onDeposit = async () => { + if (!anchorWallet) { + return; + } + + console.log('deposit'); + setIsMinting(true); + try { + await purchaseTicket(contributed, anchorWallet, fairLaunch); + setIsMinting(false); + setAlertState({ + open: true, + message: `Congratulations! Bid ${ + fairLaunch?.ticket.data ? 'updated' : 'inserted' + }!`, + severity: 'success', + }); + } catch (e) { + console.log(e); + setIsMinting(false); + setAlertState({ + open: true, + message: 'Something went wrong.', + severity: 'error', + }); + } + }; + const onRugRefund = async () => { + if (!anchorWallet) { + return; + } + + console.log('refund'); + try { + setIsMinting(true); + await receiveRefund(anchorWallet, fairLaunch); + setIsMinting(false); + setAlertState({ + open: true, + message: + 'Congratulations! You have received a refund. This is an irreversible action.', + severity: 'success', + }); + } catch (e) { + console.log(e); + setIsMinting(false); + setAlertState({ + open: true, + message: 'Something went wrong.', + severity: 'error', + }); + } + }; + const onRefundTicket = async () => { + if (!anchorWallet) { + return; + } + + console.log('refund'); + try { + setIsMinting(true); + await purchaseTicket(0, anchorWallet, fairLaunch); + setIsMinting(false); + setAlertState({ + open: true, + message: + 'Congratulations! Funds withdrawn. This is an irreversible action.', + severity: 'success', + }); + } catch (e) { + console.log(e); + setIsMinting(false); + setAlertState({ + open: true, + message: 'Something went wrong.', + severity: 'error', + }); + } + }; + + const onPunchTicket = async () => { + if (!anchorWallet || !fairLaunch || !fairLaunch.ticket) { + return; + } + + console.log('punch'); + setIsMinting(true); + try { + await punchTicket(anchorWallet, fairLaunch); + setIsMinting(false); + setAlertState({ + open: true, + message: 'Congratulations! Ticket punched!', + severity: 'success', + }); + } catch (e) { + console.log(e); + setIsMinting(false); + setAlertState({ + open: true, + message: 'Something went wrong.', + severity: 'error', + }); + } + }; + + const phase = getPhase(fairLaunch, candyMachine); + + const candyMachinePredatesFairLaunch = + candyMachine?.state.goLiveDate && + fairLaunch?.state.data.phaseTwoEnd && + candyMachine?.state.goLiveDate.lt(fairLaunch?.state.data.phaseTwoEnd); + + const notEnoughSOL = !!( + yourSOLBalance != null && + fairLaunch?.state.data.priceRangeStart && + fairLaunch?.state.data.fee && + yourSOLBalance + (fairLaunch?.ticket?.data?.amount.toNumber() || 0) < + contributed * LAMPORTS_PER_SOL + + fairLaunch?.state.data.fee.toNumber() + + 0.01 + ); + + return ( + + +
+ { + setAnitRugPolicyOpen(true); + }} + > + Anti-Rug Policy + +
+
+ + + + {phase === Phase.Phase0 && ( +
+ )} + {phase === Phase.Phase1 && ( +
+ )} + + {phase === Phase.Phase2 && ( +
+ )} + + {phase === Phase.Lottery && ( +
+ )} + + {phase === Phase.Phase3 && !candyMachine && ( +
+ )} + + {phase === Phase.Phase3 && candyMachine && ( +
+ )} + + {phase === Phase.Phase4 && ( +
+ )} + + {fairLaunch && ( + + {fairLaunch.ticket.data ? ( + <> + Your bid + + {formatNumber.format( + (fairLaunch?.ticket.data?.amount.toNumber() || 0) / + LAMPORTS_PER_SOL, + )}{' '} + SOL + + + ) : [Phase.Phase0, Phase.Phase1].includes(phase) ? ( + + You haven't entered this raffle yet.
+ {fairLaunch?.state?.data?.fee && ( + + + All initial bids will incur a ◎{' '} + {fairLaunch?.state?.data?.fee.toNumber() / + LAMPORTS_PER_SOL}{' '} + fee. + + + )} +
+ ) : ( + + You didn't participate in this raffle. + + )} +
+ )} + + {fairLaunch && ( + <> + {[ + Phase.Phase1, + Phase.Phase2, + Phase.Phase3, + Phase.Lottery, + ].includes(phase) && + fairLaunch?.ticket?.data?.state.withdrawn && ( +
+ + Your bid was withdrawn and cannot be adjusted or + re-inserted. + +
+ )} + {[Phase.Phase1, Phase.Phase2].includes(phase) && + fairLaunch.state.currentMedian && + fairLaunch?.ticket?.data?.amount && + !fairLaunch?.ticket?.data?.state.withdrawn && + fairLaunch.state.currentMedian.gt( + fairLaunch?.ticket?.data?.amount, + ) && ( +
+ + Your bid is currently below the median and will not be + eligible for the raffle. + +
+ )} + {[Phase.Phase3, Phase.Lottery].includes(phase) && + fairLaunch.state.currentMedian && + fairLaunch?.ticket?.data?.amount && + !fairLaunch?.ticket?.data?.state.withdrawn && + fairLaunch.state.currentMedian.gt( + fairLaunch?.ticket?.data?.amount, + ) && ( +
+ + Your bid was below the median and was not included in + the raffle. You may click Withdraw when the + raffle ends or you will be automatically issued one when + the Fair Launch authority withdraws from the treasury. + +
+ )} + {notEnoughSOL && ( + + You do not have enough SOL in your account to place this + bid. + + )} + + )} + + {[Phase.Phase1, Phase.Phase2].includes(phase) && ( + <> + + setContributed(val as any)} + valueLabelDisplay="auto" + style={{ + width: 'calc(100% - 40px)', + marginLeft: 20, + height: 30, + }} + /> + + + )} + + {!wallet.connected ? ( + + Connect{' '} + {[Phase.Phase1].includes(phase) ? 'to bid' : 'to see status'} + + ) : ( +
+ {[Phase.Phase1, Phase.Phase2].includes(phase) && ( + <> + + {isMinting ? ( + + ) : !fairLaunch?.ticket.data ? ( + 'Place bid' + ) : ( + 'Change bid' + )} + {} + + + )} + + {[Phase.Phase3].includes(phase) && ( + <> + {isWinner(fairLaunch) && ( + + {isMinting ? : 'Punch Ticket'} + + )} + + {!isWinner(fairLaunch) && ( + + {isMinting ? : 'Withdraw'} + + )} + + )} + + {phase === Phase.Phase4 && ( + <> + {(!fairLaunch || isWinner(fairLaunch)) && ( + + + {fairLaunch?.ticket?.data?.state.punched && + fairLaunchBalance === 0 ? ( + 'MINTED' + ) : candyMachine?.state.isSoldOut ? ( + 'SOLD OUT' + ) : isMinting ? ( + + ) : ( + 'MINT' + )} + + + )} + + {!isWinner(fairLaunch) && ( + + {isMinting ? : 'Withdraw'} + + )} + + )} +
+ )} + + + { + setHowToOpen(true); + }} + > + How this raffle works + + {fairLaunch?.ticket.data && ( + { + if ( + !fairLaunch || + phase === Phase.Lottery || + isWinner(fairLaunch) + ) { + setRefundExplainerOpen(true); + } else { + onRefundTicket(); + } + }} + > + Withdraw funds + + )} + + setRefundExplainerOpen(false)} + PaperProps={{ + style: { backgroundColor: '#222933', borderRadius: 6 }, + }} + > + + During raffle phases, or if you are a winner, or if this website + is not configured to be a fair launch but simply a candy + machine, refunds are disallowed. + + + { + setAnitRugPolicyOpen(false); + }} + PaperProps={{ + style: { backgroundColor: '#222933', borderRadius: 6 }, + }} + > + + {!fairLaunch?.state.data.antiRugSetting && ( + This Fair Launch has no anti-rug settings. + )} + {fairLaunch?.state.data.antiRugSetting && + fairLaunch.state.data.antiRugSetting.selfDestructDate && ( +
+

Anti-Rug Policy

+

+ This raffle is governed by a smart contract to prevent + the artist from running away with your money. +

+

How it works:

+ This project will retain{' '} + {fairLaunch.state.data.antiRugSetting.reserveBp / 100}% (◎{' '} + {(fairLaunch?.treasury * + fairLaunch.state.data.antiRugSetting.reserveBp) / + (LAMPORTS_PER_SOL * 10000)} + ) of the pledged amount in a locked state until all but{' '} + {fairLaunch.state.data.antiRugSetting.tokenRequirement.toNumber()}{' '} + NFTs (out of up to{' '} + {fairLaunch.state.data.numberOfTokens.toNumber()}) have + been minted. +

+ If more than{' '} + {fairLaunch.state.data.antiRugSetting.tokenRequirement.toNumber()}{' '} + NFTs remain as of{' '} + {toDate( + fairLaunch.state.data.antiRugSetting.selfDestructDate, + )?.toLocaleDateString()}{' '} + at{' '} + {toDate( + fairLaunch.state.data.antiRugSetting.selfDestructDate, + )?.toLocaleTimeString()} + , you will have the option to get a refund of{' '} + {fairLaunch.state.data.antiRugSetting.reserveBp / 100}% + of the cost of your token. +

+ {fairLaunch?.ticket?.data && + !fairLaunch?.ticket?.data.state.withdrawn && ( + + {isMinting ? ( + + ) : Date.now() / 1000 < + fairLaunch.state.data.antiRugSetting.selfDestructDate.toNumber() ? ( + + Refund in... + + + ) : ( + 'Refund' + )} + {} + + )} +
+ {fairLaunch?.ticket?.data && + !fairLaunch?.ticket?.data?.state.punched && ( + + You currently have a ticket but it has not been + punched yet, so cannot be refunded. + + )} +
+
+ )} +
+
+ setHowToOpen(false)} + PaperProps={{ + style: { backgroundColor: '#222933', borderRadius: 6 }, + }} + > + + { + setHowToOpen(true); + }} + > + How it works + + setHowToOpen(false)} + > + + + + + + Phase 1 - Set the fair price: + + + Enter a bid in the range provided by the artist. The median of + all bids will be the "fair" price of the raffle ticket.{' '} + {fairLaunch?.state?.data?.fee && ( + + + All bids will incur a ◎{' '} + {fairLaunch?.state?.data?.fee.toNumber() / + LAMPORTS_PER_SOL}{' '} + fee. + + + )} + + Phase 2 - Grace period: + + If your bid was at or above the fair price, you automatically + get a raffle ticket at that price. There's nothing else you + need to do. Your excess SOL will be returned to you when the + Fair Launch authority withdraws from the treasury. If your bid + is below the median price, you can still opt in at the fair + price during this phase. + + {candyMachinePredatesFairLaunch ? ( + <> + + Phase 3 - The Candy Machine: + + + Everyone who got a raffle ticket at the fair price is + entered to win an NFT. If you win an NFT, congrats. If you + don’t, no worries, your SOL will go right back into your + wallet. + + + ) : ( + <> + Phase 3 - The Lottery: + + Everyone who got a raffle ticket at the fair price is + entered to win a Fair Launch Token that entitles them to + an NFT at a later date using a Candy Machine here. If you + don’t win, no worries, your SOL will go right back into + your wallet. + + + Phase 4 - The Candy Machine: + + + On{' '} + {candyMachine?.state.goLiveDate + ? toDate( + candyMachine?.state.goLiveDate, + )?.toLocaleString() + : ' some later date'} + , you will be able to exchange your Fair Launch token for + an NFT using the Candy Machine at this site by pressing + the Mint Button. + + + )} + + + + {/* {wallet.connected && ( +

+ Address: {shortenAddress(wallet.publicKey?.toBase58() || '')} +

+ )} + + {wallet.connected && ( +

Balance: {(balance || 0).toLocaleString()} SOL

+ )} */} + + + + + {fairLaunch && ( + +
+ + + + Bids + + + {fairLaunch?.state.numberTicketsSold.toNumber() || 0} + + + + + Median bid + + + ◎ {formatNumber.format(median)} + + + + + Total raised + + + ◎{' '} + {formatNumber.format( + (fairLaunch?.treasury || 0) / LAMPORTS_PER_SOL, + )} + + + +
+
+ )} + setAlertState({ ...alertState, open: false })} + > + setAlertState({ ...alertState, open: false })} + severity={alertState.severity} + > + {alertState.message} + + + + ); +}; + +interface AlertState { + open: boolean; + message: string; + severity: 'success' | 'info' | 'warning' | 'error' | undefined; +} + +export default Home; diff --git a/js/packages/fair-launch/src/candy-machine.ts b/js/packages/fair-launch/src/candy-machine.ts new file mode 100644 index 0000000..36dbe38 --- /dev/null +++ b/js/packages/fair-launch/src/candy-machine.ts @@ -0,0 +1,337 @@ +import * as anchor from '@project-serum/anchor'; + +import { MintLayout, TOKEN_PROGRAM_ID, Token } from '@solana/spl-token'; +import { SystemProgram } from '@solana/web3.js'; +import { sendTransactionWithRetry } from './connection'; + +import { + getAtaForMint, + SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, +} from './utils'; + +export const CANDY_MACHINE_PROGRAM = new anchor.web3.PublicKey( + 'cndyAnrLdpjq1Ssp1z8xxDsB8dxe7u4HL5Nxi2K5WXZ', +); + +const TOKEN_METADATA_PROGRAM_ID = new anchor.web3.PublicKey( + 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s', +); + +interface CandyMachineState { + itemsAvailable: number; + itemsRedeemed: number; + itemsRemaining: number; + config: anchor.web3.PublicKey; + treasury: anchor.web3.PublicKey; + tokenMint: anchor.web3.PublicKey; + isSoldOut: boolean; + isActive: boolean; + goLiveDate: anchor.BN; + price: anchor.BN; +} + +export interface CandyMachineAccount { + id: anchor.web3.PublicKey; + program: anchor.Program; + state: CandyMachineState; +} + +export const awaitTransactionSignatureConfirmation = async ( + txid: anchor.web3.TransactionSignature, + timeout: number, + connection: anchor.web3.Connection, + commitment: anchor.web3.Commitment = 'recent', + queryStatus = false, +): Promise => { + let done = false; + let status: anchor.web3.SignatureStatus | null | void = { + slot: 0, + confirmations: 0, + err: null, + }; + let subId = 0; + status = await new Promise(async (resolve, reject) => { + setTimeout(() => { + if (done) { + return; + } + done = true; + console.log('Rejecting for timeout...'); + reject({ timeout: true }); + }, timeout); + while (!done && queryStatus) { + // eslint-disable-next-line no-loop-func + (async () => { + try { + const signatureStatuses = await connection.getSignatureStatuses([ + txid, + ]); + status = signatureStatuses && signatureStatuses.value[0]; + if (!done) { + if (!status) { + console.log('REST null result for', txid, status); + } else if (status.err) { + console.log('REST error for', txid, status); + done = true; + reject(status.err); + } else if (!status.confirmations) { + console.log('REST no confirmations for', txid, status); + } else { + console.log('REST confirmation for', txid, status); + done = true; + resolve(status); + } + } + } catch (e) { + if (!done) { + console.log('REST connection error: txid', txid, e); + } + } + })(); + await sleep(2000); + } + }); + + //@ts-ignore + if (connection._signatureSubscriptions[subId]) { + connection.removeSignatureListener(subId); + } + done = true; + console.log('Returning status', status); + return status; +}; + +/* export */ const createAssociatedTokenAccountInstruction = ( + associatedTokenAddress: anchor.web3.PublicKey, + payer: anchor.web3.PublicKey, + walletAddress: anchor.web3.PublicKey, + splTokenMintAddress: anchor.web3.PublicKey, +) => { + const keys = [ + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: associatedTokenAddress, isSigner: false, isWritable: true }, + { pubkey: walletAddress, isSigner: false, isWritable: false }, + { pubkey: splTokenMintAddress, isSigner: false, isWritable: false }, + { + pubkey: anchor.web3.SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { + pubkey: anchor.web3.SYSVAR_RENT_PUBKEY, + isSigner: false, + isWritable: false, + }, + ]; + return new anchor.web3.TransactionInstruction({ + keys, + programId: SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, + data: Buffer.from([]), + }); +}; + +export const getCandyMachineState = async ( + anchorWallet: anchor.Wallet, + candyMachineId: anchor.web3.PublicKey, + connection: anchor.web3.Connection, +): Promise => { + const provider = new anchor.Provider(connection, anchorWallet, { + preflightCommitment: 'recent', + }); + + const idl = await anchor.Program.fetchIdl(CANDY_MACHINE_PROGRAM, provider); + + const program = new anchor.Program(idl, CANDY_MACHINE_PROGRAM, provider); + + const state: any = await program.account.candyMachine.fetch(candyMachineId); + const itemsAvailable = state.data.itemsAvailable.toNumber(); + const itemsRedeemed = state.itemsRedeemed.toNumber(); + const itemsRemaining = itemsAvailable - itemsRedeemed; + + return { + id: candyMachineId, + program, + state: { + itemsAvailable, + itemsRedeemed, + itemsRemaining, + isSoldOut: itemsRemaining === 0, + isActive: state.data.goLiveDate.toNumber() < new Date().getTime() / 1000, + goLiveDate: state.data.goLiveDate, + treasury: state.wallet, + tokenMint: state.tokenMint, + config: state.config, + price: state.data.price, + }, + }; +}; + +const getMasterEdition = async ( + mint: anchor.web3.PublicKey, +): Promise => { + return ( + await anchor.web3.PublicKey.findProgramAddress( + [ + Buffer.from('metadata'), + TOKEN_METADATA_PROGRAM_ID.toBuffer(), + mint.toBuffer(), + Buffer.from('edition'), + ], + TOKEN_METADATA_PROGRAM_ID, + ) + )[0]; +}; + +const getMetadata = async ( + mint: anchor.web3.PublicKey, +): Promise => { + return ( + await anchor.web3.PublicKey.findProgramAddress( + [ + Buffer.from('metadata'), + TOKEN_METADATA_PROGRAM_ID.toBuffer(), + mint.toBuffer(), + ], + TOKEN_METADATA_PROGRAM_ID, + ) + )[0]; +}; + +export const mintOneToken = async ( + candyMachine: CandyMachineAccount, + payer: anchor.web3.PublicKey, +): Promise => { + const mint = anchor.web3.Keypair.generate(); + + const userTokenAccountAddress = ( + await getAtaForMint(mint.publicKey, payer) + )[0]; + + const userPayingAccountAddress = ( + await getAtaForMint(candyMachine.state.tokenMint, payer) + )[0]; + + const candyMachineAddress = candyMachine.id; + + const remainingAccounts = []; + const signers: anchor.web3.Keypair[] = [mint]; + const instructions = [ + anchor.web3.SystemProgram.createAccount({ + fromPubkey: payer, + newAccountPubkey: mint.publicKey, + space: MintLayout.span, + lamports: + await candyMachine.program.provider.connection.getMinimumBalanceForRentExemption( + MintLayout.span, + ), + programId: TOKEN_PROGRAM_ID, + }), + Token.createInitMintInstruction( + TOKEN_PROGRAM_ID, + mint.publicKey, + 0, + payer, + payer, + ), + createAssociatedTokenAccountInstruction( + userTokenAccountAddress, + payer, + payer, + mint.publicKey, + ), + Token.createMintToInstruction( + TOKEN_PROGRAM_ID, + mint.publicKey, + userTokenAccountAddress, + payer, + [], + 1, + ), + ]; + + let tokenAccount; + if (candyMachine.state.tokenMint) { + const transferAuthority = anchor.web3.Keypair.generate(); + + signers.push(transferAuthority); + remainingAccounts.push({ + pubkey: userPayingAccountAddress, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: transferAuthority.publicKey, + isWritable: false, + isSigner: true, + }); + + instructions.push( + Token.createApproveInstruction( + TOKEN_PROGRAM_ID, + userPayingAccountAddress, + transferAuthority.publicKey, + payer, + [], + candyMachine.state.price.toNumber(), + ), + ); + } + const metadataAddress = await getMetadata(mint.publicKey); + const masterEdition = await getMasterEdition(mint.publicKey); + + instructions.push( + await candyMachine.program.instruction.mintNft({ + accounts: { + config: candyMachine.state.config, + candyMachine: candyMachineAddress, + payer, + wallet: candyMachine.state.treasury, + mint: mint.publicKey, + metadata: metadataAddress, + masterEdition, + mintAuthority: payer, + updateAuthority: payer, + tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + remainingAccounts: + remainingAccounts.length > 0 ? remainingAccounts : undefined, + }), + ); + + if (tokenAccount) { + instructions.push( + Token.createRevokeInstruction( + TOKEN_PROGRAM_ID, + userPayingAccountAddress, + payer, + [], + ), + ); + } + try { + return ( + await sendTransactionWithRetry( + candyMachine.program.provider.connection, + candyMachine.program.provider.wallet, + instructions, + signers, + ) + ).txid; + } catch (e) { + console.log(e); + } + return 'j'; +}; + +export const shortenAddress = (address: string, chars = 4): string => { + return `${address.slice(0, chars)}...${address.slice(-chars)}`; +}; + +const sleep = (ms: number): Promise => { + return new Promise(resolve => setTimeout(resolve, ms)); +}; diff --git a/js/packages/fair-launch/src/confetti.tsx b/js/packages/fair-launch/src/confetti.tsx new file mode 100644 index 0000000..4dd11d4 --- /dev/null +++ b/js/packages/fair-launch/src/confetti.tsx @@ -0,0 +1,74 @@ +import React, { useContext, useEffect, useMemo, useRef } from 'react'; +import confetti from 'canvas-confetti'; + +export interface ConfettiContextState { + dropConfetti: () => void; +} + +const ConfettiContext = React.createContext(null); + +export const ConfettiProvider = ({ children = null as any }) => { + const canvasRef = useRef(); + const confettiRef = useRef(); + + const dropConfetti = useMemo( + () => () => { + if (confettiRef.current && canvasRef.current) { + canvasRef.current.style.visibility = 'visible'; + confettiRef + .current({ + particleCount: 400, + spread: 160, + origin: { y: 0.3 }, + }) + ?.finally(() => { + if (canvasRef.current) { + canvasRef.current.style.visibility = 'hidden'; + } + }); + } + }, + [], + ); + + useEffect(() => { + if (canvasRef.current && !confettiRef.current) { + canvasRef.current.style.visibility = 'hidden'; + confettiRef.current = confetti.create(canvasRef.current, { + resize: true, + useWorker: true, + }); + } + }, []); + + const canvasStyle: React.CSSProperties = { + width: '100vw', + height: '100vh', + position: 'absolute', + zIndex: 1, + top: 0, + left: 0, + }; + + return ( + + + {children} + + ); +}; + +export const Confetti = () => { + const { dropConfetti } = useConfetti(); + + useEffect(() => { + dropConfetti(); + }, [dropConfetti]); + + return <>; +}; + +export const useConfetti = () => { + const context = useContext(ConfettiContext); + return context as ConfettiContextState; +}; diff --git a/js/packages/fair-launch/src/connection.tsx b/js/packages/fair-launch/src/connection.tsx new file mode 100644 index 0000000..8f8bb84 --- /dev/null +++ b/js/packages/fair-launch/src/connection.tsx @@ -0,0 +1,536 @@ +import { + Keypair, + Commitment, + Connection, + RpcResponseAndContext, + SignatureStatus, + SimulatedTransactionResponse, + Transaction, + TransactionInstruction, + TransactionSignature, + Blockhash, + FeeCalculator, +} from '@solana/web3.js'; + +import { + WalletNotConnectedError, +} from '@solana/wallet-adapter-base'; + +interface BlockhashAndFeeCalculator { + blockhash: Blockhash; + feeCalculator: FeeCalculator; +} + +export const getErrorForTransaction = async ( + connection: Connection, + txid: string, +) => { + // wait for all confirmation before geting transaction + await connection.confirmTransaction(txid, 'max'); + + const tx = await connection.getParsedConfirmedTransaction(txid); + + const errors: string[] = []; + if (tx?.meta && tx.meta.logMessages) { + tx.meta.logMessages.forEach(log => { + const regex = /Error: (.*)/gm; + let m; + while ((m = regex.exec(log)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + + if (m.length > 1) { + errors.push(m[1]); + } + } + }); + } + + return errors; +}; + +export enum SequenceType { + Sequential, + Parallel, + StopOnFailure, +} + +export async function sendTransactionsWithManualRetry( + connection: Connection, + wallet: any, + instructions: TransactionInstruction[][], + signers: Keypair[][], +) { + let stopPoint = 0; + let tries = 0; + let lastInstructionsLength = null; + let toRemoveSigners: Record = {}; + instructions = instructions.filter((instr, i) => { + if (instr.length > 0) { + return true; + } else { + toRemoveSigners[i] = true; + return false; + } + }); + let filteredSigners = signers.filter((_, i) => !toRemoveSigners[i]); + + while (stopPoint < instructions.length && tries < 3) { + instructions = instructions.slice(stopPoint, instructions.length); + filteredSigners = filteredSigners.slice(stopPoint, filteredSigners.length); + + if (instructions.length === lastInstructionsLength) tries = tries + 1; + else tries = 0; + + try { + if (instructions.length === 1) { + await sendTransactionWithRetry( + connection, + wallet, + instructions[0], + filteredSigners[0], + 'single', + ); + stopPoint = 1; + } else { + stopPoint = await sendTransactions( + connection, + wallet, + instructions, + filteredSigners, + SequenceType.StopOnFailure, + 'single', + ); + } + } catch (e) { + console.error(e); + } + console.log( + 'Died on ', + stopPoint, + 'retrying from instruction', + instructions[stopPoint], + 'instructions length is', + instructions.length, + ); + lastInstructionsLength = instructions.length; + } +} + +export const sendTransactions = async ( + connection: Connection, + wallet: any, + instructionSet: TransactionInstruction[][], + signersSet: Keypair[][], + sequenceType: SequenceType = SequenceType.Parallel, + commitment: Commitment = 'singleGossip', + successCallback: (txid: string, ind: number) => void = (txid, ind) => {}, + failCallback: (reason: string, ind: number) => boolean = (txid, ind) => false, + block?: BlockhashAndFeeCalculator, +): Promise => { + if (!wallet.publicKey) throw new WalletNotConnectedError(); + + const unsignedTxns: Transaction[] = []; + + if (!block) { + block = await connection.getRecentBlockhash(commitment); + } + + for (let i = 0; i < instructionSet.length; i++) { + const instructions = instructionSet[i]; + const signers = signersSet[i]; + + if (instructions.length === 0) { + continue; + } + + let transaction = new Transaction(); + instructions.forEach(instruction => transaction.add(instruction)); + transaction.recentBlockhash = block.blockhash; + transaction.setSigners( + // fee payed by the wallet owner + wallet.publicKey, + ...signers.map(s => s.publicKey), + ); + + if (signers.length > 0) { + transaction.partialSign(...signers); + } + + unsignedTxns.push(transaction); + } + + const signedTxns = await wallet.signAllTransactions(unsignedTxns); + + const pendingTxns: Promise<{ txid: string; slot: number }>[] = []; + + let breakEarlyObject = { breakEarly: false, i: 0 }; + console.log( + 'Signed txns length', + signedTxns.length, + 'vs handed in length', + instructionSet.length, + ); + for (let i = 0; i < signedTxns.length; i++) { + const signedTxnPromise = sendSignedTransaction({ + connection, + signedTransaction: signedTxns[i], + }); + + signedTxnPromise + .then(({ txid, slot }) => { + successCallback(txid, i); + }) + .catch(reason => { + // @ts-ignore + failCallback(signedTxns[i], i); + if (sequenceType === SequenceType.StopOnFailure) { + breakEarlyObject.breakEarly = true; + breakEarlyObject.i = i; + } + }); + + if (sequenceType !== SequenceType.Parallel) { + try { + await signedTxnPromise; + } catch (e) { + console.log('Caught failure', e); + if (breakEarlyObject.breakEarly) { + console.log('Died on ', breakEarlyObject.i); + return breakEarlyObject.i; // Return the txn we failed on by index + } + } + } else { + pendingTxns.push(signedTxnPromise); + } + } + + if (sequenceType !== SequenceType.Parallel) { + await Promise.all(pendingTxns); + } + + return signedTxns.length; +}; + +export const sendTransaction = async ( + connection: Connection, + wallet: any, + instructions: TransactionInstruction[], + signers: Keypair[], + awaitConfirmation = true, + commitment: Commitment = 'singleGossip', + includesFeePayer: boolean = false, + block?: BlockhashAndFeeCalculator, +) => { + if (!wallet.publicKey) throw new WalletNotConnectedError(); + + let transaction = new Transaction(); + instructions.forEach(instruction => transaction.add(instruction)); + transaction.recentBlockhash = ( + block || (await connection.getRecentBlockhash(commitment)) + ).blockhash; + + if (includesFeePayer) { + transaction.setSigners(...signers.map(s => s.publicKey)); + } else { + transaction.setSigners( + // fee payed by the wallet owner + wallet.publicKey, + ...signers.map(s => s.publicKey), + ); + } + + if (signers.length > 0) { + transaction.partialSign(...signers); + } + if (!includesFeePayer) { + transaction = await wallet.signTransaction(transaction); + } + + const rawTransaction = transaction.serialize(); + let options = { + skipPreflight: true, + commitment, + }; + + const txid = await connection.sendRawTransaction(rawTransaction, options); + let slot = 0; + + if (awaitConfirmation) { + const confirmation = await awaitTransactionSignatureConfirmation( + txid, + DEFAULT_TIMEOUT, + connection, + commitment, + ); + + if (!confirmation) + throw new Error('Timed out awaiting confirmation on transaction'); + slot = confirmation?.slot || 0; + + if (confirmation?.err) { + const errors = await getErrorForTransaction(connection, txid); + + console.log(errors); + throw new Error(`Raw transaction ${txid} failed`); + } + } + + return { txid, slot }; +}; + +export const sendTransactionWithRetry = async ( + connection: Connection, + wallet: any, + instructions: TransactionInstruction[], + signers: Keypair[], + commitment: Commitment = 'singleGossip', + includesFeePayer: boolean = false, + block?: BlockhashAndFeeCalculator, + beforeSend?: () => void, +) => { + if (!wallet.publicKey) throw new WalletNotConnectedError(); + + let transaction = new Transaction(); + instructions.forEach(instruction => transaction.add(instruction)); + transaction.recentBlockhash = ( + block || (await connection.getRecentBlockhash(commitment)) + ).blockhash; + + if (includesFeePayer) { + transaction.setSigners(...signers.map(s => s.publicKey)); + } else { + transaction.setSigners( + // fee payed by the wallet owner + wallet.publicKey, + ...signers.map(s => s.publicKey), + ); + } + + if (signers.length > 0) { + transaction.partialSign(...signers); + } + if (!includesFeePayer) { + transaction = await wallet.signTransaction(transaction); + } + + if (beforeSend) { + beforeSend(); + } + + const { txid, slot } = await sendSignedTransaction({ + connection, + signedTransaction: transaction, + }); + + return { txid, slot }; +}; + +export const getUnixTs = () => { + return new Date().getTime() / 1000; +}; + +const DEFAULT_TIMEOUT = 15000; + +export async function sendSignedTransaction({ + signedTransaction, + connection, + timeout = DEFAULT_TIMEOUT, +}: { + signedTransaction: Transaction; + connection: Connection; + sendingMessage?: string; + sentMessage?: string; + successMessage?: string; + timeout?: number; +}): Promise<{ txid: string; slot: number }> { + const rawTransaction = signedTransaction.serialize(); + const startTime = getUnixTs(); + let slot = 0; + const txid: TransactionSignature = await connection.sendRawTransaction( + rawTransaction, + { + skipPreflight: true, + }, + ); + + console.log('Started awaiting confirmation for', txid); + + let done = false; + (async () => { + while (!done && getUnixTs() - startTime < timeout) { + connection.sendRawTransaction(rawTransaction, { + skipPreflight: true, + }); + await sleep(500); + } + })(); + try { + const confirmation = await awaitTransactionSignatureConfirmation( + txid, + timeout, + connection, + 'recent', + true, + ); + + if (!confirmation) + throw new Error('Timed out awaiting confirmation on transaction'); + + if (confirmation.err) { + console.error(confirmation.err); + throw new Error('Transaction failed: Custom instruction error'); + } + + slot = confirmation?.slot || 0; + } catch (err: any) { + console.error('Timeout Error caught', err); + if (err.timeout) { + throw new Error('Timed out awaiting confirmation on transaction'); + } + let simulateResult: SimulatedTransactionResponse | null = null; + try { + simulateResult = ( + await simulateTransaction(connection, signedTransaction, 'single') + ).value; + } catch (e) {} + if (simulateResult && simulateResult.err) { + if (simulateResult.logs) { + for (let i = simulateResult.logs.length - 1; i >= 0; --i) { + const line = simulateResult.logs[i]; + if (line.startsWith('Program log: ')) { + throw new Error( + 'Transaction failed: ' + line.slice('Program log: '.length), + ); + } + } + } + throw new Error(JSON.stringify(simulateResult.err)); + } + // throw new Error('Transaction failed'); + } finally { + done = true; + } + + console.log('Latency', txid, getUnixTs() - startTime); + return { txid, slot }; +} + +async function simulateTransaction( + connection: Connection, + transaction: Transaction, + commitment: Commitment, +): Promise> { + // @ts-ignore + transaction.recentBlockhash = await connection._recentBlockhash( + // @ts-ignore + connection._disableBlockhashCaching, + ); + + const signData = transaction.serializeMessage(); + // @ts-ignore + const wireTransaction = transaction._serialize(signData); + const encodedTransaction = wireTransaction.toString('base64'); + const config: any = { encoding: 'base64', commitment }; + const args = [encodedTransaction, config]; + + // @ts-ignore + const res = await connection._rpcRequest('simulateTransaction', args); + if (res.error) { + throw new Error('failed to simulate transaction: ' + res.error.message); + } + return res.result; +} + +async function awaitTransactionSignatureConfirmation( + txid: TransactionSignature, + timeout: number, + connection: Connection, + commitment: Commitment = 'recent', + queryStatus = false, +): Promise { + let done = false; + let status: SignatureStatus | null | void = { + slot: 0, + confirmations: 0, + err: null, + }; + let subId = 0; + status = await new Promise(async (resolve, reject) => { + setTimeout(() => { + if (done) { + return; + } + done = true; + console.log('Rejecting for timeout...'); + reject({ timeout: true }); + }, timeout); + try { + subId = connection.onSignature( + txid, + (result, context) => { + done = true; + status = { + err: result.err, + slot: context.slot, + confirmations: 0, + }; + if (result.err) { + console.log('Rejected via websocket', result.err); + reject(status); + } else { + console.log('Resolved via websocket', result); + resolve(status); + } + }, + commitment, + ); + } catch (e) { + done = true; + console.error('WS error in setup', txid, e); + } + while (!done && queryStatus) { + // eslint-disable-next-line no-loop-func + (async () => { + try { + const signatureStatuses = await connection.getSignatureStatuses([ + txid, + ]); + status = signatureStatuses && signatureStatuses.value[0]; + if (!done) { + if (!status) { + console.log('REST null result for', txid, status); + } else if (status.err) { + console.log('REST error for', txid, status); + done = true; + reject(status.err); + } else if (!status.confirmations) { + console.log('REST no confirmations for', txid, status); + } else { + console.log('REST confirmation for', txid, status); + done = true; + resolve(status); + } + } + } catch (e) { + if (!done) { + console.log('REST connection error: txid', txid, e); + } + } + })(); + await sleep(2000); + } + }); + + //@ts-ignore + if (connection._signatureSubscriptions[subId]) + connection.removeSignatureListener(subId); + done = true; + console.log('Returning status', status); + return status; +} +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/js/packages/fair-launch/src/countdown.tsx b/js/packages/fair-launch/src/countdown.tsx new file mode 100644 index 0000000..9de103a --- /dev/null +++ b/js/packages/fair-launch/src/countdown.tsx @@ -0,0 +1,136 @@ +import { Paper } from '@material-ui/core'; +import Countdown from 'react-countdown'; +import { Theme, createStyles, makeStyles } from '@material-ui/core/styles'; +import { useState } from 'react'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + display: 'flex', + padding: theme.spacing(0), + '& > *': { + margin: theme.spacing(0.5), + marginRight: 0, + width: theme.spacing(6), + height: theme.spacing(6), + display: 'flex', + flexDirection: 'column', + alignContent: 'center', + alignItems: 'center', + justifyContent: 'center', + background: '#384457', + color: 'white', + borderRadius: 5, + fontSize: 10, + }, + }, + done: { + display: 'flex', + margin: theme.spacing(1), + marginRight: 0, + padding: theme.spacing(1), + flexDirection: 'column', + alignContent: 'center', + alignItems: 'center', + justifyContent: 'center', + background: '#384457', + color: 'white', + borderRadius: 5, + fontWeight: 'bold', + fontSize: 18, + }, + item: { + fontWeight: 'bold', + fontSize: 18, + } + }), +); + + +interface PhaseCountdownProps { + date: Date | undefined; + style?: React.CSSProperties; + status?: string; + onComplete?: () => void; + start?: Date; + end?: Date; +} + +interface CountdownRender { + days: number; + hours: number; + minutes: number; + seconds: number; + completed: boolean; +} + +export const PhaseCountdown: React.FC = ({ + date, + status, + style, + start, + end, + onComplete, +}) => { + const classes = useStyles(); + + const [isFixed, setIsFixed] = useState(start && end && date ? start.getTime() - Date.now() < 0 : false); + + const renderCountdown = ({ days, hours, minutes, seconds, completed }: CountdownRender) => { + hours += days * 24 + if (completed) { + return status ? {status} : null; + } else { + return ( +
+ {isFixed && + + + + + } + + + {hours < 10 ? `0${hours}` : hours} + + hrs + + + + {minutes < 10 ? `0${minutes}` : minutes} + + mins + + + + {seconds < 10 ? `0${seconds}` : seconds} + + secs + +
+ ) + } + } + + if (date && start && end) { + if (isFixed) { + end.getTime()} + onComplete={() => setIsFixed(false)} + renderer={renderCountdown} + /> + } + } + + if (date) { + return ( + + ) + } else { + return null + } +} diff --git a/js/packages/fair-launch/src/fair-launch.ts b/js/packages/fair-launch/src/fair-launch.ts new file mode 100644 index 0000000..eeac85d --- /dev/null +++ b/js/packages/fair-launch/src/fair-launch.ts @@ -0,0 +1,634 @@ +import * as anchor from '@project-serum/anchor'; + +import { TOKEN_PROGRAM_ID, Token } from '@solana/spl-token'; +import { LAMPORTS_PER_SOL, TransactionInstruction } from '@solana/web3.js'; +import { + createAssociatedTokenAccountInstruction, + getAtaForMint, + getFairLaunchTicketSeqLookup, +} from './utils'; + +export const FAIR_LAUNCH_PROGRAM = new anchor.web3.PublicKey( + 'faircnAB9k59Y4TXmLabBULeuTLgV7TkGMGNkjnA15j', +); + +export interface FairLaunchAccount { + id: anchor.web3.PublicKey; + program: anchor.Program; + state: FairLaunchState; + + ticket: { + pubkey: anchor.web3.PublicKey; + bump: number; + data?: FairLaunchTicket; + }; + lottery: { + pubkey: anchor.web3.PublicKey; + data?: Uint8Array; + }; + treasury: number; +} + +export interface FairLaunchTicket { + fairLaunch: anchor.web3.PublicKey; + buyer: anchor.web3.PublicKey; + amount: anchor.BN; + state: { + punched?: {}; + unpunched?: {}; + withdrawn?: {}; + no_sequence_struct: {}; + }; + bump: number; + seq: anchor.BN; +} + +export interface AntiRugSetting { + reserveBp: number; + tokenRequirement: anchor.BN; + selfDestructDate: anchor.BN; +} +export interface FairLaunchState { + authority: anchor.web3.PublicKey; + bump: number; + + currentMedian: anchor.BN; + currentEligibleHolders: anchor.BN; + data: { + antiRugSetting?: AntiRugSetting; + fee: anchor.BN; + numberOfTokens: anchor.BN; + phaseOneEnd: anchor.BN; + phaseOneStart: anchor.BN; + phaseTwoEnd: anchor.BN; + priceRangeEnd: anchor.BN; + priceRangeStart: anchor.BN; + lotteryDuration: anchor.BN; + tickSize: anchor.BN; + uuid: string; + }; + numberTicketsDropped: anchor.BN; + numberTicketsPunched: anchor.BN; + numberTicketsSold: anchor.BN; + numberTicketsUnSeqed: anchor.BN; + numberTokensBurnedForRefunds: anchor.BN; + numberTokensPreminted: anchor.BN; + phaseThreeStarted: boolean; + tokenMint: anchor.web3.PublicKey; + tokenMintBump: number; + treasury: anchor.web3.PublicKey; + treasuryBump: number; + treasuryMint: anchor.web3.PublicKey; // only for SPL tokens + treasurySnapshot: null; +} + +export enum LotteryState { + Brewing = 'Brewing', + Finished = 'Finished', + PastDue = 'Past Due', +} + +export const getLotteryState = ( + phaseThree: boolean | undefined, + lottery: Uint8Array | null, + lotteryDuration: anchor.BN, + phaseTwoEnd: anchor.BN, +): LotteryState => { + if ( + !phaseThree && + (!lottery || lottery.length === 0) && + phaseTwoEnd.add(lotteryDuration).lt(new anchor.BN(Date.now() / 1000)) + ) { + return LotteryState.PastDue; + } else if (phaseThree) { + return LotteryState.Finished; + } else { + return LotteryState.Brewing; + } +}; + +export const getFairLaunchState = async ( + anchorWallet: anchor.Wallet, + fairLaunchId: anchor.web3.PublicKey, + connection: anchor.web3.Connection, +): Promise => { + const provider = new anchor.Provider(connection, anchorWallet, { + preflightCommitment: 'recent', + }); + + const idl = await anchor.Program.fetchIdl(FAIR_LAUNCH_PROGRAM, provider); + + const program = new anchor.Program(idl, FAIR_LAUNCH_PROGRAM, provider); + const state: any = await program.account.fairLaunch.fetch(fairLaunchId); + + const [fairLaunchTicket, bump] = await getFairLaunchTicket( + //@ts-ignore + state.tokenMint, + anchorWallet.publicKey, + ); + + let fairLaunchData: any; + + try { + fairLaunchData = await program.account.fairLaunchTicket.fetch( + fairLaunchTicket, + ); + } catch { + console.log('No ticket'); + } + + const treasury = await program.provider.connection.getBalance(state.treasury); + + let lotteryData: Uint8Array = new Uint8Array([]); + let fairLaunchLotteryBitmap = ( + await getFairLaunchLotteryBitmap( + //@ts-ignore + state.tokenMint, + ) + )[0]; + + try { + const fairLaunchLotteryBitmapObj = + await program.provider.connection.getAccountInfo(fairLaunchLotteryBitmap); + + lotteryData = new Uint8Array(fairLaunchLotteryBitmapObj?.data || []); + } catch (e) { + console.log('Could not find fair launch lottery.'); + console.log(e); + } + + return { + id: fairLaunchId, + state, + program, + ticket: { + pubkey: fairLaunchTicket, + bump, + data: fairLaunchData, + }, + lottery: { + pubkey: fairLaunchLotteryBitmap, + data: lotteryData, + }, + treasury, + }; +}; + +export const punchTicket = async ( + anchorWallet: anchor.Wallet, + fairLaunch: FairLaunchAccount, +) => { + const fairLaunchTicket = ( + await getFairLaunchTicket( + //@ts-ignore + fairLaunch.state.tokenMint, + anchorWallet.publicKey, + ) + )[0]; + + const ticket = fairLaunch.ticket.data; + + const fairLaunchLotteryBitmap = //@ts-ignore + (await getFairLaunchLotteryBitmap(fairLaunch.state.tokenMint))[0]; + + const buyerTokenAccount = ( + await getAtaForMint( + //@ts-ignore + fairLaunch.state.tokenMint, + anchorWallet.publicKey, + ) + )[0]; + + if (ticket?.amount.gt(fairLaunch.state.currentMedian)) { + console.log( + 'Adjusting down...', + ticket?.amount.toNumber(), + fairLaunch.state.currentMedian.toNumber(), + ); + const { remainingAccounts, instructions, signers } = + await getSetupForTicketing( + fairLaunch.program, + fairLaunch.state.currentMedian.toNumber(), + anchorWallet, + fairLaunch, + fairLaunchTicket, + ); + await fairLaunch.program.rpc.adjustTicket(fairLaunch.state.currentMedian, { + accounts: { + fairLaunchTicket, + fairLaunch: fairLaunch.id, + fairLaunchLotteryBitmap, + //@ts-ignore + treasury: fairLaunch.state.treasury, + systemProgram: anchor.web3.SystemProgram.programId, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + __private: { logAccounts: true }, + instructions: instructions.length > 0 ? instructions : undefined, + remainingAccounts: [ + { + pubkey: anchorWallet.publicKey, + isSigner: true, + isWritable: true, + }, + ...remainingAccounts, + ], + signers, + }); + } + + const accountExists = + await fairLaunch.program.provider.connection.getAccountInfo( + buyerTokenAccount, + ); + + const instructions = !accountExists + ? [ + createAssociatedTokenAccountInstruction( + buyerTokenAccount, + anchorWallet.publicKey, + anchorWallet.publicKey, + //@ts-ignore + fairLaunch.state.tokenMint, + ), + ] + : []; + + await fairLaunch.program.rpc.punchTicket({ + accounts: { + fairLaunchTicket, + fairLaunch: fairLaunch.id, + fairLaunchLotteryBitmap, + payer: anchorWallet.publicKey, + buyerTokenAccount, + //@ts-ignore + tokenMint: fairLaunch.state.tokenMint, + tokenProgram: TOKEN_PROGRAM_ID, + }, + instructions: instructions.length > 0 ? instructions : undefined, + }); +}; + +export const getFairLaunchTicket = async ( + tokenMint: anchor.web3.PublicKey, + buyer: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from('fair_launch'), tokenMint.toBuffer(), buyer.toBuffer()], + FAIR_LAUNCH_PROGRAM, + ); +}; + +export const getFairLaunchLotteryBitmap = async ( + tokenMint: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from('fair_launch'), tokenMint.toBuffer(), Buffer.from('lottery')], + FAIR_LAUNCH_PROGRAM, + ); +}; + +const getSetupForTicketing = async ( + anchorProgram: anchor.Program, + amount: number, + anchorWallet: anchor.Wallet, + fairLaunch: FairLaunchAccount | undefined, + ticketKey: anchor.web3.PublicKey, +): Promise<{ + remainingAccounts: { + pubkey: anchor.web3.PublicKey | null; + isWritable: boolean; + isSigner: boolean; + }[]; + instructions: TransactionInstruction[]; + signers: anchor.web3.Keypair[]; + amountLamports: number; +}> => { + if (!fairLaunch) { + return { + remainingAccounts: [], + instructions: [], + signers: [], + amountLamports: 0, + }; + } + const ticket = fairLaunch.ticket; + + const remainingAccounts = []; + const instructions = []; + const signers = []; + + let amountLamports = 0; + //@ts-ignore + if (!fairLaunch.state.treasuryMint) { + if (!ticket && amount === 0) { + amountLamports = fairLaunch.state.data.priceRangeStart.toNumber(); + } else { + amountLamports = Math.ceil(amount * LAMPORTS_PER_SOL); + } + } else { + const transferAuthority = anchor.web3.Keypair.generate(); + signers.push(transferAuthority); + // NOTE this token impl will not work till you get decimal mantissa and multiply... + /// ex from cli wont work since you dont have a Signer, but an anchor.Wallet + /* + const token = new Token( + anchorProgram.provider.connection, + //@ts-ignore + fairLaunchObj.treasuryMint, + TOKEN_PROGRAM_ID, + walletKeyPair, + ); + const mintInfo = await token.getMintInfo(); + amountNumber = Math.ceil(amountNumber * 10 ** mintInfo.decimals); + */ + instructions.push( + Token.createApproveInstruction( + TOKEN_PROGRAM_ID, + //@ts-ignore + fairLaunch.state.treasuryMint, + transferAuthority.publicKey, + anchorWallet.publicKey, + [], + //@ts-ignore + + // TODO: get mint decimals + amountNumber + fairLaunch.state.data.fees.toNumber(), + ), + ); + + remainingAccounts.push({ + //@ts-ignore + pubkey: fairLaunch.state.treasuryMint, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: ( + await getAtaForMint( + //@ts-ignore + fairLaunch.state.treasuryMint, + anchorWallet.publicKey, + ) + )[0], + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: transferAuthority.publicKey, + isWritable: false, + isSigner: true, + }); + remainingAccounts.push({ + pubkey: TOKEN_PROGRAM_ID, + isWritable: false, + isSigner: false, + }); + } + + if (ticket.data) { + const [fairLaunchTicketSeqLookup, seqBump] = + await getFairLaunchTicketSeqLookup( + fairLaunch.state.tokenMint, + ticket.data?.seq, + ); + + const seq = await anchorProgram.provider.connection.getAccountInfo( + fairLaunchTicketSeqLookup, + ); + if (!seq) { + instructions.push( + await anchorProgram.instruction.createTicketSeq(seqBump, { + accounts: { + fairLaunchTicketSeqLookup, + fairLaunch: fairLaunch.id, + fairLaunchTicket: ticketKey, + payer: anchorWallet.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + signers: [], + }), + ); + } + } + + return { + remainingAccounts, + instructions, + signers, + amountLamports, + }; +}; + +export const receiveRefund = async ( + anchorWallet: anchor.Wallet, + fairLaunch: FairLaunchAccount | undefined, +) => { + if (!fairLaunch) { + return; + } + + const buyerTokenAccount = ( + await getAtaForMint(fairLaunch.state.tokenMint, anchorWallet.publicKey) + )[0]; + + const transferAuthority = anchor.web3.Keypair.generate(); + + const signers = [transferAuthority]; + const instructions = [ + Token.createApproveInstruction( + TOKEN_PROGRAM_ID, + buyerTokenAccount, + transferAuthority.publicKey, + anchorWallet.publicKey, + [], + 1, + ), + ]; + + const remainingAccounts = []; + + if (fairLaunch.state.treasuryMint) { + remainingAccounts.push({ + pubkey: fairLaunch.state.treasuryMint, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: ( + await getAtaForMint( + fairLaunch.state.treasuryMint, + anchorWallet.publicKey, + ) + )[0], + isWritable: true, + isSigner: false, + }); + } + + console.log( + 'tfr', + fairLaunch.state.treasury.toBase58(), + anchorWallet.publicKey.toBase58(), + buyerTokenAccount.toBase58(), + ); + await fairLaunch.program.rpc.receiveRefund({ + accounts: { + fairLaunch: fairLaunch.id, + treasury: fairLaunch.state.treasury, + buyer: anchorWallet.publicKey, + buyerTokenAccount, + transferAuthority: transferAuthority.publicKey, + tokenMint: fairLaunch.state.tokenMint, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + + __private: { logAccounts: true }, + remainingAccounts, + instructions, + signers, + }); +}; +export const purchaseTicket = async ( + amount: number, + anchorWallet: anchor.Wallet, + fairLaunch: FairLaunchAccount | undefined, +) => { + if (!fairLaunch) { + return; + } + + const ticket = fairLaunch.ticket.data; + + const [fairLaunchTicket, bump] = await getFairLaunchTicket( + //@ts-ignore + fairLaunch.state.tokenMint, + anchorWallet.publicKey, + ); + + const { remainingAccounts, instructions, signers, amountLamports } = + await getSetupForTicketing( + fairLaunch.program, + amount, + anchorWallet, + fairLaunch, + fairLaunchTicket, + ); + + if (ticket) { + const fairLaunchLotteryBitmap = ( //@ts-ignore + await getFairLaunchLotteryBitmap(fairLaunch.state.tokenMint) + )[0]; + console.log( + 'Anchor wallet', + anchorWallet.publicKey.toBase58(), + amountLamports, + ); + await fairLaunch.program.rpc.adjustTicket(new anchor.BN(amountLamports), { + accounts: { + fairLaunchTicket, + fairLaunch: fairLaunch.id, + fairLaunchLotteryBitmap, + //@ts-ignore + treasury: fairLaunch.state.treasury, + systemProgram: anchor.web3.SystemProgram.programId, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + __private: { logAccounts: true }, + remainingAccounts: [ + { + pubkey: anchorWallet.publicKey, + isSigner: true, + isWritable: true, + }, + ...remainingAccounts, + ], + signers, + instructions: instructions.length > 0 ? instructions : undefined, + }); + + return; + } + try { + console.log('Amount', amountLamports); + await fairLaunch.program.rpc.purchaseTicket( + bump, + new anchor.BN(amountLamports), + { + accounts: { + fairLaunchTicket, + fairLaunch: fairLaunch.id, + //@ts-ignore + treasury: fairLaunch.state.treasury, + buyer: anchorWallet.publicKey, + payer: anchorWallet.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + //__private: { logAccounts: true }, + remainingAccounts, + signers, + instructions: instructions.length > 0 ? instructions : undefined, + }, + ); + } catch (e) { + console.log(e); + throw e; + } +}; + +export const withdrawFunds = async ( + anchorWallet: anchor.Wallet, + fairLaunch: FairLaunchAccount | undefined, +) => { + if (!fairLaunch) { + return; + } + + // TODO: create sequence ticket + + const remainingAccounts = []; + + //@ts-ignore + if (fairLaunch.state.treasuryMint) { + remainingAccounts.push({ + //@ts-ignore + pubkey: fairLaunch.state.treasuryMint, + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: ( + await getAtaForMint( + //@ts-ignore + fairLaunch.state.treasuryMint, + anchorWallet.publicKey, + ) + )[0], + isWritable: true, + isSigner: false, + }); + remainingAccounts.push({ + pubkey: TOKEN_PROGRAM_ID, + isWritable: false, + isSigner: false, + }); + } + + await fairLaunch.program.rpc.withdrawFunds({ + accounts: { + fairLaunch: fairLaunch.id, + // @ts-ignore + treasury: fairLaunch.state.treasury, + authority: anchorWallet.publicKey, + // @ts-ignore + tokenMint: fairLaunch.state.tokenMint, + systemProgram: anchor.web3.SystemProgram.programId, + }, + remainingAccounts, + }); +}; diff --git a/js/packages/fair-launch/src/index.css b/js/packages/fair-launch/src/index.css new file mode 100644 index 0000000..358c698 --- /dev/null +++ b/js/packages/fair-launch/src/index.css @@ -0,0 +1,14 @@ +body { + background: #000000; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/js/packages/fair-launch/src/index.tsx b/js/packages/fair-launch/src/index.tsx new file mode 100644 index 0000000..ef2edf8 --- /dev/null +++ b/js/packages/fair-launch/src/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import App from './App'; +import reportWebVitals from './reportWebVitals'; + +ReactDOM.render( + + + , + document.getElementById('root') +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/js/packages/fair-launch/src/logo.svg b/js/packages/fair-launch/src/logo.svg new file mode 100644 index 0000000..9dfc1c0 --- /dev/null +++ b/js/packages/fair-launch/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/packages/fair-launch/src/react-app-env.d.ts b/js/packages/fair-launch/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/js/packages/fair-launch/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/js/packages/fair-launch/src/reportWebVitals.ts b/js/packages/fair-launch/src/reportWebVitals.ts new file mode 100644 index 0000000..49a2a16 --- /dev/null +++ b/js/packages/fair-launch/src/reportWebVitals.ts @@ -0,0 +1,15 @@ +import { ReportHandler } from 'web-vitals'; + +const reportWebVitals = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/js/packages/fair-launch/src/setupTests.ts b/js/packages/fair-launch/src/setupTests.ts new file mode 100644 index 0000000..8f2609b --- /dev/null +++ b/js/packages/fair-launch/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/js/packages/fair-launch/src/utils.ts b/js/packages/fair-launch/src/utils.ts new file mode 100644 index 0000000..33ce251 --- /dev/null +++ b/js/packages/fair-launch/src/utils.ts @@ -0,0 +1,130 @@ +import * as anchor from '@project-serum/anchor'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { SystemProgram } from '@solana/web3.js'; +import { + LAMPORTS_PER_SOL, + SYSVAR_RENT_PUBKEY, + TransactionInstruction, +} from '@solana/web3.js'; + +export const FAIR_LAUNCH_PROGRAM_ID = new anchor.web3.PublicKey( + 'faircnAB9k59Y4TXmLabBULeuTLgV7TkGMGNkjnA15j', +); + +export const toDate = (value?: anchor.BN) => { + if (!value) { + return; + } + + return new Date(value.toNumber() * 1000); +}; + +const numberFormater = new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +export const formatNumber = { + format: (val?: number) => { + if (!val) { + return '--'; + } + + return numberFormater.format(val); + }, + asNumber: (val?: anchor.BN) => { + if (!val) { + return undefined; + } + + return val.toNumber() / LAMPORTS_PER_SOL; + }, +}; + +export const SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID = + new anchor.web3.PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); + +export const getFairLaunchTicketSeqLookup = async ( + tokenMint: anchor.web3.PublicKey, + seq: anchor.BN, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [ + Buffer.from('fair_launch'), + tokenMint.toBuffer(), + seq.toArrayLike(Buffer, 'le', 8), + ], + FAIR_LAUNCH_PROGRAM_ID, + ); +}; + +export const getAtaForMint = async ( + mint: anchor.web3.PublicKey, + buyer: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [buyer.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, + ); +}; + +export const getFairLaunchTicket = async ( + tokenMint: anchor.web3.PublicKey, + buyer: anchor.web3.PublicKey, +): Promise<[anchor.web3.PublicKey, number]> => { + return await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from('fair_launch'), tokenMint.toBuffer(), buyer.toBuffer()], + FAIR_LAUNCH_PROGRAM_ID, + ); +}; + +export function createAssociatedTokenAccountInstruction( + associatedTokenAddress: anchor.web3.PublicKey, + payer: anchor.web3.PublicKey, + walletAddress: anchor.web3.PublicKey, + splTokenMintAddress: anchor.web3.PublicKey, +) { + const keys = [ + { + pubkey: payer, + isSigner: true, + isWritable: true, + }, + { + pubkey: associatedTokenAddress, + isSigner: false, + isWritable: true, + }, + { + pubkey: walletAddress, + isSigner: false, + isWritable: false, + }, + { + pubkey: splTokenMintAddress, + isSigner: false, + isWritable: false, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { + pubkey: TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + { + pubkey: SYSVAR_RENT_PUBKEY, + isSigner: false, + isWritable: false, + }, + ]; + return new TransactionInstruction({ + keys, + programId: SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, + data: Buffer.from([]), + }); +} diff --git a/js/packages/fair-launch/tsconfig.json b/js/packages/fair-launch/tsconfig.json new file mode 100644 index 0000000..a273b0c --- /dev/null +++ b/js/packages/fair-launch/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +} diff --git a/js/packages/web/.env b/js/packages/web/.env index fd4c2c7..e1239c0 100644 --- a/js/packages/web/.env +++ b/js/packages/web/.env @@ -1,3 +1,2 @@ REACT_APP_STORE_OWNER_ADDRESS_ADDRESS= -REACT_APP_STORE_ADDRESS= -REACT_APP_BIG_STORE=FALSE \ No newline at end of file +REACT_APP_STORE_ADDRESS= \ No newline at end of file diff --git a/js/packages/web/package.json b/js/packages/web/package.json index 2b2b26e..0061b85 100644 --- a/js/packages/web/package.json +++ b/js/packages/web/package.json @@ -44,7 +44,7 @@ "build": "next build", "export": "next export -o ../../build/web", "start:prod": "next start", - "test": "jest", + "test": "jest --passWithNoTests", "deploy:ar": "yarn export && arweave deploy-dir ../../build/web --key-file ", "deploy:gh": "yarn export && gh-pages -d ../../build/web --repo https://github.com/metaplex-foundation/metaplex -t true", "deploy": "cross-env ASSET_PREFIX=/metaplex/ yarn build && yarn deploy:gh", @@ -94,4 +94,4 @@ "react-dom": "*" }, "license": "MIT" -} \ No newline at end of file +} diff --git a/js/packages/web/src/actions/nft.tsx b/js/packages/web/src/actions/nft.tsx index ee099da..56b52b2 100644 --- a/js/packages/web/src/actions/nft.tsx +++ b/js/packages/web/src/actions/nft.tsx @@ -42,17 +42,32 @@ interface IArweaveResult { }>; } -const uploadToArweave = async (data: FormData): Promise => - ( - await fetch( - 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile4', - { - method: 'POST', - // @ts-ignore - body: data, - }, - ) - ).json(); +const uploadToArweave = async (data: FormData): Promise => { + const resp = await fetch( + 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile4', + { + method: 'POST', + // @ts-ignore + body: data, + }, + ); + + if (!resp.ok) { + return Promise.reject( + new Error( + 'Unable to upload the artwork to Arweave. Please wait and then try again.', + ), + ); + } + + const result: IArweaveResult = await resp.json(); + + if (result.error) { + return Promise.reject(new Error(result.error)); + } + + return result; +}; export const mintNFT = async ( connection: Connection, diff --git a/js/packages/web/src/actions/sendPlaceBid.ts b/js/packages/web/src/actions/sendPlaceBid.ts index 784b6be..2c47fb0 100644 --- a/js/packages/web/src/actions/sendPlaceBid.ts +++ b/js/packages/web/src/actions/sendPlaceBid.ts @@ -27,7 +27,7 @@ export async function sendPlaceBid( auctionView: AuctionView, accountsByMint: Map, // value entered by the user adjust to decimals of the mint - amount: number, + amount: number | BN, ) { const signers: Keypair[][] = []; const instructions: TransactionInstruction[][] = []; @@ -62,7 +62,8 @@ export async function setupPlaceBid( auctionView: AuctionView, accountsByMint: Map, // value entered by the user adjust to decimals of the mint - amount: number, + // If BN, then assume instant sale and decimals already adjusted. + amount: number | BN, overallInstructions: TransactionInstruction[][], overallSigners: Keypair[][], ): Promise { @@ -82,7 +83,12 @@ export async function setupPlaceBid( const mint = cache.get( tokenAccount ? tokenAccount.info.mint : QUOTE_MINT, ) as ParsedAccount; - const lamports = toLamports(amount, mint.info) + accountRentExempt; + + const lamports = + accountRentExempt + + (typeof amount === 'number' + ? toLamports(amount, mint.info) + : amount.toNumber()); let bidderPotTokenAccount: string; if (!auctionView.myBidderPot) { diff --git a/js/packages/web/src/actions/sendRedeemBid.ts b/js/packages/web/src/actions/sendRedeemBid.ts index 5983bfe..b2d86c8 100644 --- a/js/packages/web/src/actions/sendRedeemBid.ts +++ b/js/packages/web/src/actions/sendRedeemBid.ts @@ -123,7 +123,6 @@ export async function sendRedeemBid( winnerIndex = auctionView.auction.info.bidState.getWinnerIndex( auctionView.myBidderPot?.info.bidderAct, ); - console.log('Winner index', winnerIndex); if (winnerIndex !== null) { // items is a prebuilt array of arrays where each entry represents one diff --git a/js/packages/web/src/components/ArtContent/index.tsx b/js/packages/web/src/components/ArtContent/index.tsx index 3de41cd..4b8c87b 100644 --- a/js/packages/web/src/components/ArtContent/index.tsx +++ b/js/packages/web/src/components/ArtContent/index.tsx @@ -162,31 +162,31 @@ const HTMLContent = ({ uri, animationUrl, className, + preview, style, files, + artView, }: { uri?: string; animationUrl?: string; className?: string; + preview?: boolean; style?: React.CSSProperties; files?: (MetadataFile | string)[]; + artView?: boolean; }) => { + if (!artView){ + return + } const htmlURL = files && files.length > 0 && typeof files[0] === 'string' ? files[0] : animationUrl; - const { isLoading } = useCachedImage(htmlURL || '', true); - - if (isLoading) { - return ( - - ); - } return (