diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd77438 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea +sources.zip +/node_modules +/build +/tags +/package-lock.json diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..6d6062b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..24e3932 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "scripts": { + "build": "npx blueprint build", + "test": "jest", + "deploy": "npx blueprint run" + }, + "devDependencies": { + "@ton/blueprint": "^0.31.1", + "@ton/core": "^0.61.0", + "@ton/sandbox": "^0.34.0", + "@ton/test-utils": "^0.8.0", + "@types/jest": "^29.5.11", + "jest": "^29.7.0", + "ts-jest": "^29.3.2", + "ts-node": "^10.9.2", + "typescript": "^5.8.3" + } +} diff --git a/tests/Nft.spec.ts b/tests/Nft.spec.ts new file mode 100644 index 0000000..0592a26 --- /dev/null +++ b/tests/Nft.spec.ts @@ -0,0 +1,2294 @@ +import { Blockchain, SandboxContract, TreasuryContract, BlockchainSnapshot, SendMessageResult } from '@ton/sandbox'; +import { Cell, toNano, beginCell, Transaction, ExternalAddress, Address, Message } from '@ton/core'; +import '@ton/test-utils'; +import { compile } from '@ton/blueprint'; +import { randomAddress, getRandomInt } from './utils'; +import { auctionConfigToCell, AuctionParameters, ItemRestrictions, NewNftItem, NftCollection, NftContent, nftContentToCell, RoyaltyParameters, royaltyParamsToCell } from '../wrappers/NftCollection'; +import { NftItem } from '../wrappers/NftItem'; +import { Op, Errors } from '../wrappers/NftConstants'; +import { collectCellStats, computedGeneric, computeMessageForwardFees, getMsgPrices, reportGas, storageGeneric } from './gasUtils'; +import { findTransactionRequired } from '@ton/test-utils'; +import { getSecureRandomBytes, KeyPair, keyPairFromSeed, sha256 } from '@ton/crypto'; + +describe('NFT', () => { + let collection_code = new Cell(); + let item_code = new Cell(); + let blockchain: Blockchain; + let deployer:SandboxContract; + let royaltyWallet:SandboxContract; + let minterTreasury: SandboxContract; + let otherBidder: SandboxContract; + let balanceStrict = false; + + let keyPair: KeyPair; + + let nftCollection: SandboxContract; + + let regularItem: SandboxContract; + + let commonContent: string; + let royaltyFactor: number; + let royaltyBase: number; + + let defaultAuctionConfig: AuctionParameters; + + let collectionMessage: Message; + + let initialState: BlockchainSnapshot; + let itemsDeployedState: BlockchainSnapshot; + let initialAuctionDone: BlockchainSnapshot; + let ownerStartedAuction: BlockchainSnapshot; + + let msgPrices: ReturnType; + + const defaultTestName = "Test Item"; + const defaultContent: NftContent = { type: 'offchain', uri: 'my_nft.json' }; + const subwallet_id = 0; + const min_storage = toNano('0.03'); + + let nftItemByName: (name: string) => Promise>; + let getContractData:(address: Address) => Promise; + + let curTime: () => number; + let computeNextBid: (cur_bid: bigint, bid_step: bigint) => bigint; + let assertAuctionConfigIsEmpty: (item: SandboxContract, isEmpty: boolean) => Promise; + let assertStartAuction: (item: SandboxContract, from: SandboxContract, config: AuctionParameters, exp_status: number, queryId?: bigint) => Promise; + + beforeAll(async () => { + collection_code = await compile('NftCollection'); + keyPair = keyPairFromSeed(await getSecureRandomBytes(32)); + let collectionStats = collectCellStats(collection_code, []); + console.log(`Deduplicated collection code stats: ${collectionStats.bits} bits ${collectionStats.cells} cells`); + + collectionStats = collectCellStats(collection_code, [], false, true); + console.log(`Raw collection code takes ${collectionStats.bits} bits ${collectionStats.cells} cells`); + + item_code = await compile('NftItem'); + let itemStats = collectCellStats(item_code, [], false, true) + console.log(`Deduplicated item code stats: ${itemStats.bits} bits ${itemStats.cells} cells`); + itemStats = collectCellStats(item_code, [], false, true) + console.log(`Raw item code stats: ${itemStats.bits} bits ${itemStats.cells} cells`); + + blockchain = await Blockchain.create(); + + blockchain.now = 1000; + + deployer = await blockchain.treasury('deployer'); + royaltyWallet = await blockchain.treasury('Royalty$toMe'); + minterTreasury = await blockchain.treasury('Minter treasury'); + otherBidder = await blockchain.treasury('other_bidder'); + royaltyFactor = getRandomInt(10, 50); // From 1 to 5 percent + royaltyBase = 1000; + + msgPrices = getMsgPrices(blockchain.config, 0); + + defaultAuctionConfig = { + duration : 3600, + benificiary: minterTreasury.address, + min_bid: toNano('1'), + max_bid: toNano('100'), + min_extend_time: 1800, + min_bid_step: 10n, + }; + + + commonContent = 'https://raw.githubusercontent.com/Trinketer22/token-contract/main/nft/web-example/' + nftCollection = blockchain.openContract( + NftCollection.createFromConfig({ + subwallet_id, + item_code, + public_key: keyPair.publicKey, + content: {type: 'offchain', uri:'https://raw.githubusercontent.com/Trinketer22/token-contract/main/nft/web-example/my_collection.json'}, + full_domain: "", + royalty: { + address: royaltyWallet.address, + royalty_factor: royaltyFactor, + royalty_base: royaltyBase + } + }, collection_code) + ); + + const topUp = await deployer.send({ + to: nftCollection.address, + value: toNano('1'), + bounce: false + }); + const deployRes = await nftCollection.sendDeploy(deployer.getSender(), toNano('1')); + + expect(deployRes.transactions).toHaveTransaction({ + on: nftCollection.address, + aborted: false, + deploy: true + }); + + nftItemByName = async (name) => { + const idx = BigInt('0x' + (await sha256(name)).toString('hex')); + return blockchain.openContract( + NftItem.createFromAddress( + await nftCollection.getNftAddressByIndex(idx) + ) + ); + } + getContractData = async (address: Address) => { + const smc = await blockchain.getContract(address); + if(!smc.account.account) + throw("Account not found") + if(smc.account.account.storage.state.type != "active" ) + throw("Atempting to get data on inactive account"); + if(!smc.account.account.storage.state.state.data) + throw("Data is not present"); + return smc.account.account.storage.state.state.data + } + + + curTime = () => { + return blockchain.now ?? Math.floor(Date.now() / 1000); + } + + computeNextBid = (cur_bid, bid_step) => { + let nextBid = (cur_bid * (100n + bid_step) + 99n) / 100n; + let minNextBid = cur_bid + toNano('1'); + + return nextBid > minNextBid ? nextBid : minNextBid; + } + + assertAuctionConfigIsEmpty = async (item, isEmpty) => { + const auctionConfig = await item.getAuctionConfig(); + if(isEmpty) { + expect(auctionConfig.benificiary).toBeNull(); + expect(auctionConfig.max_bid).toBe(0n); + expect(auctionConfig.initial_bid).toBe(0n); + expect(auctionConfig.duration).toBe(0); + expect(auctionConfig.extend_time).toBe(0); + } else { + expect(auctionConfig.benificiary).not.toBeNull(); + // max_bid can be 0 + // expect(auctionConfig.max_bid).not.toBe(0n); + expect(auctionConfig.initial_bid).not.toBe(0n); + expect(auctionConfig.duration).not.toBe(0); + expect(auctionConfig.extend_time).not.toBe(0); + } + }; + + assertStartAuction = async (item, from, config, exp_status, queryId = 0n) => { + const msgValue = toNano('0.05'); + await assertAuctionConfigIsEmpty(item, true); + + const res = await item.sendStartAuction(from.getSender(), config, msgValue, queryId); + + if(exp_status == 0) { + const startTx = findTransactionRequired(res.transactions, { + on: item.address, + from: from.address, + op: Op.teleitem_start_auction, + aborted: false, + outMessagesCount: queryId != 0n ? 1 : 0 + }); + + if(queryId != 0n) { + const gas = computedGeneric(startTx); + expect(res.transactions).toHaveTransaction({ + on: from.address, + from: item.address, + op: Op.teleitem_ok, + value: msgValue - gas.gasFees - msgPrices.lumpPrice + }); + } + + await assertAuctionConfigIsEmpty(item, false); + } else { + expect(res.transactions).toHaveTransaction({ + on: item.address, + from: from.address, + op: Op.teleitem_start_auction, + aborted: true, + exitCode: exp_status + }); + await assertAuctionConfigIsEmpty(item, true); + } + + return res; + } + + initialState = blockchain.snapshot(); + }); + + beforeEach(async () => await blockchain.loadFrom(initialState)); + + describe('Collection', () => { + it('collection should deploy', async () => { + const collectionData = await nftCollection.getCollectionData(); + expect(collectionData.owner).toBe(null); + expect(collectionData.nextItemIndex).toBe(-1); + }); + + it('admin should be able to deploy item', async () => { + const bidValue = defaultAuctionConfig.min_bid + (BigInt(getRandomInt(1, 10)) * toNano('0.1')) + const itemContentCell = nftContentToCell({type: 'offchain', uri: `my_nft.json`}); + const token_name = "Test item"; + const nftItem = await nftItemByName(token_name); + + const res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name, + actuion_config: defaultAuctionConfig, + content: itemContentCell + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 100, + subwallet_id + }, bidValue); + + const collectionPart = findTransactionRequired(res.transactions,{ + on: nftCollection.address, + from: deployer.address, + aborted: false, + outMessagesCount: 1 + }); + + collectionMessage = collectionPart.outMessages.get(0)!; + + reportGas("Deploy on collection costs", collectionPart); + + const deployTx = findTransactionRequired(res.transactions, { + on: nftItem.address, + from: nftCollection.address, + deploy: true, + aborted: false + }); + + reportGas("Deploy on item costs", deployTx); + + const itemData = await nftItem.getNftData(); + + expect(itemData.index).toEqual(BigInt('0x' + (await sha256(token_name)).toString('hex'))); + expect(itemData.isInit).toBe(true); + expect(itemData.owner).toBe(null); + + expect(await nftItem.getTokenName()).toEqual(token_name); + + const auctionState = await nftItem.getAuctionState(); + expect(auctionState.bidder_address).toEqualAddress(deployer.address); + expect(auctionState.bid).toEqual(bidValue); + expect(auctionState.min_bid).toEqual(computeNextBid(bidValue, defaultAuctionConfig.min_bid_step)); + expect(auctionState.bid_ts).toEqual(deployTx.now); + expect(auctionState.end_time).toEqual(deployTx.now + defaultAuctionConfig.duration); + + const royaltyParams = await nftItem.getRoyaltyParams(); + expect(royaltyParams.royalty_dst).toEqualAddress(royaltyWallet.address); + expect(royaltyParams.factor).toEqual(BigInt(royaltyFactor)); + expect(royaltyParams.base).toEqual(BigInt(royaltyBase)); + + regularItem = nftItem; + + itemsDeployedState = blockchain.snapshot(); + }); + it('collection should allow to deploy item with custom royalty parameters', async () => { + const bidValue = defaultAuctionConfig.min_bid + (BigInt(getRandomInt(1, 10)) * toNano('0.1')) + const customTokenName = "Custom royalty token"; + + const nftItem = await nftItemByName(customTokenName); + const newRoyalty : RoyaltyParameters = { + address: randomAddress(0), + royalty_base: royaltyBase * 2, + royalty_factor: royaltyFactor * 3 + } + + const res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: customTokenName, + actuion_config: defaultAuctionConfig, + content: defaultContent, + royalty: newRoyalty + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 100, + subwallet_id + }, bidValue); + + const collectionPart = findTransactionRequired(res.transactions,{ + on: nftCollection.address, + from: deployer.address, + aborted: false, + outMessagesCount: 1 + }); + reportGas("Custom royalty deploy", collectionPart); + + const royaltyParams = await nftItem.getRoyaltyParams(); + expect(royaltyParams.royalty_dst).toEqualAddress(newRoyalty.address); + expect(royaltyParams.base).toEqual(BigInt(newRoyalty.royalty_base)); + expect(royaltyParams.factor).toEqual(BigInt(newRoyalty.royalty_factor)); + }); + it('Item deploy should check for same auction limits as start auction', async () => { + // Minimal value from contract + const week = 3600 * 24 * 7; + const extendsByMoreThanAweek = {...defaultAuctionConfig, min_extend_time: week + 1} + + const year = 3600 * 24 * 365; + const durationMoreThanAyear = {...defaultAuctionConfig, duration: year + 1}; + + + const minBidStepZero = {...defaultAuctionConfig, min_bid_step: 0n}; + + + const minValue = min_storage * 2n; + const minBidLessMinVal = {...defaultAuctionConfig, min_bid: minValue - 1n}; + + const maxBidLessThanMinBid = {...defaultAuctionConfig, max_bid: defaultAuctionConfig.min_bid - 1n}; + + for(let testConfig of [minBidLessMinVal, maxBidLessThanMinBid, minBidStepZero, durationMoreThanAyear, extendsByMoreThanAweek]) { + await blockchain.loadFrom(initialState) + const bidValue = defaultAuctionConfig.min_bid + (BigInt(getRandomInt(1, 10)) * toNano('0.1')) + const itemContentCell = nftContentToCell({type: 'offchain', uri: `my_nft.json`}); + const token_name = "Test item 42"; + const nftItem = await nftItemByName(token_name); + + + const res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name, + actuion_config: testConfig, + content: itemContentCell + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 100, + subwallet_id + }, bidValue); + + const collectionPart = findTransactionRequired(res.transactions,{ + on: nftCollection.address, + from: deployer.address, + aborted: false, + outMessagesCount: 1 + }); + + const deployTx = findTransactionRequired(res.transactions, { + on: nftItem.address, + from: nftCollection.address, + deploy: true, + aborted: false + }); + + const dataAfter = await nftItem.getNftData(); + expect(dataAfter.isInit).toBe(false); + } + }) + it('should return funds if item is already deployed', async () => { + await blockchain.loadFrom(itemsDeployedState); + + const bidValue = defaultAuctionConfig.min_bid + (BigInt(getRandomInt(1, 10)) * toNano('0.1')) + const itemContentCell = nftContentToCell({type: 'offchain', uri: `other_nft.json`}); + const token_name = "Test item"; + const nftItem = await nftItemByName(token_name); + + const dataBefore = await getContractData(nftItem.address); + + const res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name, + actuion_config: defaultAuctionConfig, + content: itemContentCell + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 100, + subwallet_id + }, bidValue); + + const itemDeployTx = findTransactionRequired(res.transactions, { + on: nftItem.address, + from: nftCollection.address, + aborted: false, + outMessagesCount: 1, + }); + + const inMsg = itemDeployTx.inMessage!; + + if(inMsg.info.type !== 'internal') { + throw new Error("No way!"); + } + + const gasFees = computedGeneric(itemDeployTx); + + expect(res.transactions).toHaveTransaction({ + on: deployer.address, + op: Op.teleitem_return_bid, + value: inMsg.info.value.coins - gasFees.gasFees - msgPrices.lumpPrice + }); + + const dataAfter = await getContractData(nftItem.address); + expect(dataBefore).toEqualCell(dataAfter); + }); + it.skip('collection should not allow to deploy item with malformed royalty', async () => { + const bidValue = defaultAuctionConfig.min_bid + (BigInt(getRandomInt(1, 10)) * toNano('0.1')) + const nftItem = await nftItemByName(defaultTestName); + // Definitely not parsable, because string is stored in a ref + const malformedRoyalty = beginCell().storeStringRefTail("Not a royalty you looking for").endCell(); + expect(malformedRoyalty.asSlice().remainingBits).toBe(0); + + const res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: defaultContent, + royalty: malformedRoyalty + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 100, + subwallet_id + }, bidValue); + + expect(res.transactions).not.toHaveTransaction({ + on: nftItem.address, + from: nftCollection.address, + deploy: true, + aborted: false + }); + //So what? + // Thing is that royalty is only unpacked during the end of the auction + // And this will prevent the owner from changint + }); + it('different key pair should not be able to deploy new items', async () => { + let testKp: KeyPair; + + do { + testKp = keyPairFromSeed(await getSecureRandomBytes(32)); + } while(testKp.secretKey.equals(keyPair.secretKey)); + + for(let testState of [initialState, itemsDeployedState]) { + await blockchain.loadFrom(testState); + + const res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: "Test token", + actuion_config: defaultAuctionConfig, + content: { type: 'offchain', uri: 'my_nft.json' } + }, + { + privateKey: testKp.secretKey, + valid_since: curTime() - 1, + valid_till: curTime() + 3600, + subwallet_id + }, defaultAuctionConfig.min_bid * 2n); + + expect(res.transactions).toHaveTransaction({ + on: nftCollection.address, + from: deployer.address, + op: Op.telemint_msg_deploy_v2, + aborted: true, + exitCode: Errors.invalid_signature + }); + } + }); + it('should reject not yet valid signatures', async () => { + const now = curTime(); + + let res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: defaultContent + }, + { + privateKey: keyPair.secretKey, + valid_since: now, // Will reject because of > instead of >= + valid_till: now + 3600, + subwallet_id + }, defaultAuctionConfig.min_bid * 2n); + + expect(res.transactions).toHaveTransaction({ + on: nftCollection.address, + from: deployer.address, + op: Op.telemint_msg_deploy_v2, + aborted: true, + exitCode: Errors.not_yet_valid_signature + }); + + blockchain.now = now + 1; + res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: defaultContent + }, + { + privateKey: keyPair.secretKey, + valid_since: now, // Will reject because of > instead of >= + valid_till: now + 3600, + subwallet_id + }, defaultAuctionConfig.min_bid * 2n); + + expect(res.transactions).toHaveTransaction({ + on: nftCollection.address, + from: deployer.address, + op: Op.telemint_msg_deploy_v2, + aborted: false + }); + }); + it('should reject expired signatures', async () => { + const valid_since = curTime() - 1; + const valid_time = 3600; + const prevState = blockchain.snapshot(); + + let res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: defaultContent + }, + { + privateKey: keyPair.secretKey, + valid_since, + valid_till: valid_since + valid_time, + subwallet_id + }, defaultAuctionConfig.min_bid * 2n); + + blockchain.now = valid_since + valid_time - 1; + + expect(res.transactions).toHaveTransaction({ + on: nftCollection.address, + from: deployer.address, + op: Op.telemint_msg_deploy_v2, + aborted: false + }); + + await blockchain.loadFrom(prevState); + + blockchain.now = valid_since + valid_time; + + res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: defaultContent + }, + { + privateKey: keyPair.secretKey, + valid_since, + valid_till: valid_since + valid_time, + subwallet_id + }, defaultAuctionConfig.min_bid * 2n); + + expect(res.transactions).toHaveTransaction({ + on: nftCollection.address, + from: deployer.address, + op: Op.telemint_msg_deploy_v2, + aborted: true, + exitCode: Errors.expired_signature + }); + }); + it('should reject signatures for different subwallet_id', async () => { + const randomSubwallet = subwallet_id + getRandomInt(1, 100000); + const valid_since = curTime() - 1; + const valid_till = valid_since + 3600; + + let res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: defaultContent + }, + { + privateKey: keyPair.secretKey, + valid_since, + valid_till, + subwallet_id: randomSubwallet + }, defaultAuctionConfig.min_bid * 2n); + + expect(res.transactions).toHaveTransaction({ + on: nftCollection.address, + from: deployer.address, + op: Op.telemint_msg_deploy_v2, + aborted: true, + exitCode: Errors.wrong_subwallet_id + }); + }); + it('sender restriction should reject other senders', async () => { + const allowedSender = await blockchain.treasury('allowed_sender'); + + const nftItem = await nftItemByName(defaultTestName); + + let forceAllowed: ItemRestrictions = { + force_sender: allowedSender.address, + rewrite_sender: null + }; + + // Make sure this case will be rejected too + let forceAllowedRewrite: ItemRestrictions = { + force_sender: allowedSender.address, + rewrite_sender: deployer.address + } + + for(let restrictions of [ forceAllowed, forceAllowedRewrite ]) { + let res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: defaultContent, + restrictions + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 3600, + subwallet_id + }, defaultAuctionConfig.min_bid * 2n); + + expect(res.transactions).toHaveTransaction({ + on: nftCollection.address, + op: Op.telemint_msg_deploy_v2, + aborted: true, + exitCode: Errors.invalid_sender_address + }); + } + + let res = await nftCollection.sendDeployItem(allowedSender.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: defaultContent, + restrictions: { + force_sender: allowedSender.address, + rewrite_sender: null + } + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 3600, + subwallet_id + }, defaultAuctionConfig.min_bid * 2n); + + const deployTx = findTransactionRequired(res.transactions, { + on: nftCollection.address, + op: Op.telemint_msg_deploy_v2, + aborted: false, + outMessagesCount: 1 + }); + + expect(res.transactions).toHaveTransaction({ + on: nftItem.address, + from: nftCollection.address, + deploy: true, + aborted: false + }); + reportGas("Force sender restrictions deploy", deployTx); + + const auctionState = await nftItem.getAuctionState(); + expect(auctionState.bidder_address).toEqualAddress(allowedSender.address); + }); + it('restrictions sender overwrite should work', async () => { + const allowedSender = await blockchain.treasury('allowed_sender'); + const prevState = blockchain.snapshot(); + + let justRewrite: ItemRestrictions = { + force_sender: null, + rewrite_sender: deployer.address + }; + + // Negative tested in previous case + let forceAllowedRewrite: ItemRestrictions = { + force_sender: allowedSender.address, + rewrite_sender: deployer.address + } + + const bidValue = defaultAuctionConfig.min_bid * 2n; + for(let restrictions of [ justRewrite, forceAllowedRewrite ]) { + let res = await nftCollection.sendDeployItem(allowedSender.getSender(), { + token_name: defaultTestName, + actuion_config: defaultAuctionConfig, + content: { type: 'offchain', uri: 'my_nft.json' }, + restrictions + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 3600, + subwallet_id + }, bidValue); + + const deployTx = findTransactionRequired(res.transactions, { + on: nftCollection.address, + op: Op.telemint_msg_deploy_v2, + aborted: false + }); + + const nftItem = await nftItemByName(defaultTestName); + + expect(res.transactions).toHaveTransaction({ + on: nftItem.address, + from: nftCollection.address, + deploy: true, + aborted: false + }); + + if(restrictions === justRewrite) { + reportGas("Rewrite restrictions deploy", deployTx); + } else { + reportGas("Rewrite and force sender restrictions deploy", deployTx); + } + const auctionState = await nftItem.getAuctionState(); + + expect(auctionState.bidder_address).toEqualAddress(deployer.address); + expect(auctionState.bid).toEqual(bidValue); + expect(auctionState.min_bid).toEqual(computeNextBid(bidValue, defaultAuctionConfig.min_bid_step)); + expect(auctionState.bid_ts).toEqual(deployTx.now); + + await blockchain.loadFrom(prevState); + } + }); + it('should not be able to replay deploy message from address other than collection', async () => { + if(collectionMessage.info.type !== 'internal') { + throw new Error("No way"); + } + + const itemAddr = collectionMessage.info.dest; + + for(let testWallet of [deployer, otherBidder, royaltyWallet]) { + const res = await testWallet.send({ + to: itemAddr, + body: collectionMessage.body, + init: collectionMessage.init, + value: collectionMessage.info.value.coins + }); + + expect(res.transactions).toHaveTransaction({ + on: itemAddr, + op: Op.teleitem_msg_deploy, + aborted: true, + exitCode: Errors.uninited + }); + } + }); + + it.skip('should return joined content', async () => { + const testContent = nftContentToCell({type: 'offchain', 'uri': 'my_nft.json'}); + const resContent = await nftCollection.getNftContent(1, testContent); + expect(resContent).toEqualCell(beginCell() + .storeUint(1, 8) + .storeStringTail(commonContent) + .storeRef(testContent) + .endCell()); + }); + + }); + describe('Auction', () => { + let bidsMade: BlockchainSnapshot; + let ownerBidsMade: BlockchainSnapshot; + let assertAuctionEnded: ( item: SandboxContract,endAuction: (item: SandboxContract, bid: bigint) => Promise, bid?: bigint) => Promise; + + beforeAll(() => { + assertAuctionEnded = async (item, endAuction, bid: bigint = 0n) => { + + const smc = await blockchain.getContract(item.address); + let balanceBefore = smc.balance; + let royaltyAmount = 0n; + const auctionState = await item.getAuctionState(); + const configBefore = await item.getAuctionConfig(); + const stateBefore = await item.getNftData(); + const royaltyParams = await item.getRoyaltyParams(); + + // Was't empty before + expect(configBefore.benificiary).not.toBeNull(); + expect(configBefore.max_bid).not.toBe(0n); + expect(configBefore.initial_bid).not.toBe(0n); + expect(configBefore.duration).not.toBe(0); + expect(configBefore.extend_time).not.toBe(0); + + const royaltyIsBefiniciar = configBefore.benificiary!.equals(royaltyParams.royalty_dst) || royaltyParams.factor == 0n || royaltyParams.base == 0n; + + const res = await endAuction(item, bid) // await item.sendBet(otherBidder.getSender(), defaultAuctionConfig.max_bid); + + const newOwner = (await item.getNftData()).owner; + + const newBid = bid > 0n && auctionState.bid > 0n; + const bidTx = findTransactionRequired(res.transactions, { + on: item.address, + value: bid > 0n ? bid : undefined, // If no bid specify, just don't check value at all + aborted: false, + outMessagesCount: (newBid ? 4 : 3) - Number(royaltyIsBefiniciar) + }); + + const computed = computedGeneric(bidTx); + const storage = storageGeneric(bidTx); + + const configAfter = await item.getAuctionConfig(); + expect(configAfter.benificiary).toBeNull(); + expect(configAfter.max_bid).toEqual(0n); + expect(configAfter.initial_bid).toEqual(0n); + expect(configAfter.duration).toEqual(0); + expect(configAfter.extend_time).toEqual(0); + + let bidTs: number; + let lastBid: bigint; + let inFee = 0n; + + if(bid > 0n) { + if(auctionState.bidder_address !== null) { + expect(res.transactions).toHaveTransaction({ + on: auctionState.bidder_address, + value: auctionState.bid, + op: Op.outbid_notification + }); + } + lastBid = bid; + bidTs = bidTx.now; + } else { + lastBid = auctionState.bid; + bidTs = auctionState.bid_ts; + if(bidTx.inMessage!.info.type == 'external-in') { + inFee = msgPrices.lumpPrice; + } + } + + if(!royaltyIsBefiniciar) { + royaltyAmount = lastBid * royaltyParams.factor / royaltyParams.base; + expect(res.transactions).toHaveTransaction({ + on: royaltyWallet.address, + op: Op.fill_up, + value: royaltyAmount - msgPrices.lumpPrice + }); + } + + const balanceDuring = balanceBefore - storage.storageFeesCollected - inFee + (lastBid - auctionState.bid) - royaltyAmount; + let expTransfer = lastBid - royaltyAmount; + + if(expTransfer > balanceDuring - min_storage) { + expTransfer = balanceDuring - min_storage; + } + + expect(res.transactions).toHaveTransaction({ + from: item.address, + on: newOwner ?? undefined, + body: beginCell() + .storeUint(Op.ownership_assigned, 32) + .storeUint(bidTx.lt, 64) + .storeAddress(stateBefore.owner) + .storeUint(0, 1) + .storeUint(Op.teleitem_bid_info, 32) + .storeCoins(lastBid) + .storeUint(bidTs, 32) + .endCell() + }); + expect(res.transactions).toHaveTransaction({ + on: configBefore.benificiary ?? undefined, + op: Op.fill_up, + value: expTransfer - msgPrices.lumpPrice, + }); + + if(balanceStrict) { + expect(smc.balance).toBeGreaterThanOrEqual(min_storage); + } + return res; + } + }); + beforeEach(async () => await blockchain.loadFrom(itemsDeployedState)); + + it('not accept bid below min_bid', async () => { + const auctionState = await regularItem.getAuctionState(); + const newBet = auctionState.min_bid - 1n; + + for(let testWallet of [otherBidder, deployer]) { + const res = await regularItem.sendBet(testWallet.getSender(), newBet); + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + value: newBet, + aborted: true, + exitCode: Errors.too_small_stake + }); + } + }); + + it('should accept bit higher than min_bid and increase next bid accordingly to bid_step', async () => { + let auctionState = await regularItem.getAuctionState(); + let betBefore = auctionState.min_bid; + let newBet = betBefore + 200n; + const endTimeBefore = auctionState.end_time; + // Min bid step is 10%, meaning that if 10% of new bet <= 1 TON + // next bet should be max(newBet + 1TON, newBet * 1.10) + let nextBet = computeNextBid(newBet, defaultAuctionConfig.min_bid_step); + + blockchain.now = curTime() + getRandomInt(1, 1000); + let res = await regularItem.sendBet(otherBidder.getSender(), newBet, 'empty_body'); + + const smc = await blockchain.getContract(regularItem.address); + + let bidTx = findTransactionRequired(res.transactions, { + on:regularItem.address, + from: otherBidder.address, + value: newBet, + aborted: false, + outMessagesCount: 1 + }); + + reportGas("Outbid bid_step < 1 TON", bidTx); + + expect(res.transactions).toHaveTransaction({ + on: deployer.address, + from: regularItem.address, + value: auctionState.bid + }) + + auctionState = await regularItem.getAuctionState(); + + expect(auctionState.bidder_address).toEqualAddress(otherBidder.address); + expect(auctionState.min_bid).toEqual(nextBet); + expect(nextBet - betBefore).toEqual(toNano('1') + (newBet - betBefore)); + expect(auctionState.bid).toEqual(newBet); + expect(auctionState.bid_ts).toEqual(blockchain.now); + expect(auctionState.end_time).toEqual(endTimeBefore); + + betBefore = auctionState.min_bid; + newBet = betBefore + toNano('11'); + nextBet = computeNextBid(newBet, defaultAuctionConfig.min_bid_step); + + // Now let's test case where nextBet 10% is larger than 1 TON + + res = await regularItem.sendBet(deployer.getSender(), newBet, 'op_zero'); + + bidTx = findTransactionRequired(res.transactions, { + on:regularItem.address, + from: deployer.address, + value: newBet, + aborted: false, + outMessagesCount: 1 + }); + + reportGas("Outbid bid_step > 1 TON", bidTx); + expect(res.transactions).toHaveTransaction({ + on: otherBidder.address, + from: regularItem.address, + value: auctionState.bid + }); + expect(smc.balance).toBeGreaterThanOrEqual(min_storage); + + auctionState = await regularItem.getAuctionState(); + + expect(auctionState.bidder_address).toEqualAddress(deployer.address); + expect(auctionState.min_bid).toEqual(nextBet); + expect(nextBet - betBefore).toBeGreaterThan(toNano('1') + (newBet - betBefore)); + expect(auctionState.bid).toEqual(newBet); + expect(auctionState.end_time).toEqual(endTimeBefore); + + expect(smc.balance).toBeGreaterThanOrEqual(min_storage); + + bidsMade = blockchain.snapshot(); + }); + it('should be able to extend auction with new stake', async () => { + await blockchain.loadFrom(bidsMade); + const stateBefore = await regularItem.getAuctionState(); + + blockchain.now = stateBefore.end_time - getRandomInt(1, defaultAuctionConfig.min_extend_time - 1); + const res = await regularItem.sendBet(otherBidder.getSender(), stateBefore.min_bid + 1n); + + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: otherBidder.address, + value: stateBefore.min_bid + 1n, + aborted: false + }); + + const stateAfter = await regularItem.getAuctionState(); + expect(stateAfter.end_time).toEqual(blockchain.now + defaultAuctionConfig.min_extend_time); + expect(stateAfter.end_time).toBeGreaterThan(stateBefore.end_time); + }); + it('should be able to end initial auction when time expire', async () => { + await blockchain.loadFrom(bidsMade); + const stateBefore = await regularItem.getAuctionState(); + + let reported = false; + + for (let testTime of [stateBefore.end_time, stateBefore.end_time + getRandomInt(1, 360000)]) { + blockchain.now = testTime; + + const res = await assertAuctionEnded(regularItem, async (item, bid) => await item.sendCheckEndExternal(), 0n); + if(!reported) { + reportGas("Auction ended external", res.transactions[0]); + reported = true; + } + initialAuctionDone = blockchain.snapshot(); + await blockchain.loadFrom(itemsDeployedState); + } + // Should not end + blockchain.now = stateBefore.end_time - 1; + expect(assertAuctionEnded(regularItem, async (item, bid) => await item.sendCheckEndExternal(), 0n)).rejects.toThrow(); + }); + + it('should not accept bids after end_time', async () => { + const stateBefore = await regularItem.getAuctionState(); + blockchain.now = stateBefore.end_time + 1; + let testModes: ('empty_body' | 'op_zero')[] = ['empty_body', 'op_zero']; + + for(let mode of testModes) { + const res = await regularItem.sendBet(otherBidder.getSender(), stateBefore.min_bid + 1n, mode); + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: otherBidder.address, + aborted: true, + exitCode: Errors.forbidden_topup + }); + } + + // However from owner this should be accepted as topup + // Looks like a questionable decision for me + for(let mode of testModes) { + const res = await regularItem.sendBet(deployer.getSender(), stateBefore.min_bid + 1n, mode); + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: deployer.address, + aborted: false + }); + } + }); + + it('should be able to end initial auction by reaching max bid', async () => { + let reported = false; + for(let testState of [itemsDeployedState, bidsMade]) { + await blockchain.loadFrom(bidsMade); + for(let testBid of [defaultAuctionConfig.max_bid, defaultAuctionConfig.max_bid * 2n]) { + const res = await assertAuctionEnded(regularItem, async (item, bid) => item.sendBet(otherBidder.getSender(), bid), testBid); + + if(!reported) { + reportGas("Auction ended max_bid", findTransactionRequired(res.transactions, { + on: regularItem.address, + from: otherBidder.address, + aborted: false + })); + reported = true; + } + expect((await regularItem.getNftData()).owner).toEqualAddress(otherBidder.address); + await blockchain.loadFrom(testState); + // Other sender should not matter + await assertAuctionEnded(regularItem, async (item, bid) => item.sendBet(deployer.getSender(), bid), testBid); + expect((await regularItem.getNftData()).owner).toEqualAddress(deployer.address); + await blockchain.loadFrom(testState); + } + // Souldn't end if off by one + await expect(assertAuctionEnded(regularItem, async (item, bid) => item.sendBet(otherBidder.getSender(), bid), defaultAuctionConfig.max_bid - 1n)).rejects.toThrow(); + } + }); + it('when royalty address = benificiary, royalty + auction result should be sent in one go', async () => { + const bidValue = defaultAuctionConfig.min_bid + toNano('1'); + const customTokenName = "Royalty equals benificiary token"; + + const nftItem = await nftItemByName(customTokenName); + const newRoyalty : RoyaltyParameters = { + address: defaultAuctionConfig.benificiary, + royalty_base: royaltyBase, + royalty_factor: royaltyFactor + } + + let res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: customTokenName, + actuion_config: defaultAuctionConfig, + content: defaultContent, + royalty: newRoyalty + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 100, + subwallet_id + }, bidValue); + + const collectionPart = findTransactionRequired(res.transactions,{ + on: nftCollection.address, + from: deployer.address, + aborted: false, + outMessagesCount: 1 + }); + + const royaltyParams = await nftItem.getRoyaltyParams(); + expect(royaltyParams.royalty_dst).toEqualAddress(newRoyalty.address); + expect(royaltyParams.base).toEqual(BigInt(newRoyalty.royalty_base)); + expect(royaltyParams.factor).toEqual(BigInt(newRoyalty.royalty_factor)); + + const auctionState = await nftItem.getAuctionState(); + + const prevState = blockchain.snapshot(); + + // All payouts are checked in assertAuctionEnded + const endMaxBid = async () => await assertAuctionEnded(nftItem, async (item, bid) => item.sendBet(otherBidder.getSender(), bid), defaultAuctionConfig.max_bid); + const endTimeExpire = async () => { + blockchain.now = auctionState.end_time + 1; + await assertAuctionEnded(nftItem, async (item, bid) => item.sendCheckEndExternal(), 0n); + } + + for(let testCase of [endMaxBid, endTimeExpire]) { + await testCase(); + if(testCase === endMaxBid) { + await blockchain.loadFrom(prevState); + } + } + + // Now let's start new owner auction to make sure logic doesn't change + + await nftItem.sendStartAuction(deployer.getSender(), {...defaultAuctionConfig, benificiary: newRoyalty.address}); + + // Otherwise on time expiery no payout will happen, since no bets were made + await nftItem.sendBet(otherBidder.getSender(), toNano('1')); + for(let testCase of [endMaxBid, endTimeExpire]) { + await testCase(); + await blockchain.loadFrom(prevState); + } + }); + it('when royalty factor or base is 0, royalty + auction result should be sent in one go', async () => { + + const bidValue = defaultAuctionConfig.min_bid + toNano('1'); + const zeroFactor = "Zero factor royalty"; + const zeroBase = "Zero base royalty"; + + const zeroFactorRoyalty: RoyaltyParameters = { + address: royaltyWallet.address, + royalty_base: royaltyBase, + royalty_factor: 0n + } + const zeroBaseRoyalty: RoyaltyParameters = { + address: royaltyWallet.address, + royalty_base: 0n, + royalty_factor: royaltyFactor + } + + for(let testRoyalty of [zeroFactorRoyalty, zeroBaseRoyalty]) { + const testName = testRoyalty.royalty_base == 0n ? zeroFactor : zeroBase; + const testItem = await nftItemByName(testName); + + let res = await nftCollection.sendDeployItem(deployer.getSender(), { + token_name: testName, + actuion_config: defaultAuctionConfig, + content: defaultContent, + royalty: testRoyalty + }, + { + privateKey: keyPair.secretKey, + valid_since: blockchain.now! - 1, + valid_till: blockchain.now! + 100, + subwallet_id + }, bidValue); + expect(res.transactions).toHaveTransaction({ + on: testItem.address, + from: nftCollection.address, + deploy: true, + aborted: false + }); + + const royaltyParams = await testItem.getRoyaltyParams(); + expect(royaltyParams.royalty_dst).toEqualAddress(testRoyalty.address); + expect(royaltyParams.base).toEqual(BigInt(testRoyalty.royalty_base)); + expect(royaltyParams.factor).toEqual(BigInt(testRoyalty.royalty_factor)); + + const auctionState = await testItem.getAuctionState(); + + const prevState = blockchain.snapshot(); + + // All payouts are checked in assertAuctionEnded + const endMaxBid = async () => await assertAuctionEnded(testItem, async (item, bid) => item.sendBet(otherBidder.getSender(), bid), defaultAuctionConfig.max_bid); + const endTimeExpire = async () => { + blockchain.now = auctionState.end_time + 1; + await assertAuctionEnded(testItem, async (item, bid) => item.sendCheckEndExternal(), 0n); + } + + for(let testCase of [endMaxBid, endTimeExpire]) { + await testCase(); + if(testCase === endMaxBid) { + await blockchain.loadFrom(prevState); + } + } + + // Now let's start new owner auction to make sure logic doesn't change + + await testItem.sendStartAuction(deployer.getSender(), defaultAuctionConfig); + + // Otherwise on time expiery no payout will happen, since no bets were made + await testItem.sendBet(otherBidder.getSender(), toNano('1')); + for(let testCase of [endMaxBid, endTimeExpire]) { + await testCase(); + await blockchain.loadFrom(prevState); + } + + } + }); + it('non-owner should not be able to start an auction', async () => { + await blockchain.loadFrom(initialAuctionDone); + + const itemData = await regularItem.getNftData(); + expect(itemData.owner).not.toEqualAddress(otherBidder.address); + + await assertAuctionConfigIsEmpty(regularItem, true); + + const minBid = BigInt(getRandomInt(2, 5)) * min_storage; + + const newConfig: AuctionParameters = { + min_bid: minBid, + max_bid: BigInt(getRandomInt(10, 100)) * minBid, + duration: getRandomInt(1, 3600) * 60, + min_extend_time:getRandomInt(10, 1800), + benificiary: randomAddress(0), + min_bid_step: BigInt(getRandomInt(10, 100)) + } + + for(let testConfig of [newConfig, defaultAuctionConfig]) { + const res = await regularItem.sendStartAuction(otherBidder.getSender(), testConfig); + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: otherBidder.address, + op: Op.teleitem_start_auction, + aborted: true, + exitCode: Errors.forbidden_auction + }); + } + }); + it('owner should be able to start an auction', async () => { + await blockchain.loadFrom(initialAuctionDone); + const itemData = await regularItem.getNftData(); + + expect(itemData.owner).toEqualAddress(deployer.address); + const testAddress = randomAddress(0); + + await assertAuctionConfigIsEmpty(regularItem, true); + + const minBid = BigInt(getRandomInt(2, 5)) * min_storage; + + const newConfig: AuctionParameters = { + min_bid: minBid, + max_bid: BigInt(getRandomInt(100, 10000)) * toNano('1'), + duration: getRandomInt(1, 3600) * 60, + min_extend_time:getRandomInt(10, 1800), + benificiary: testAddress, + min_bid_step: defaultAuctionConfig.min_bid_step // BigInt(getRandomInt(1, 100)) + } + + const res = await assertStartAuction(regularItem, deployer, newConfig, 0); + + const startTx = findTransactionRequired(res.transactions, { + on: regularItem.address, + op: Op.teleitem_start_auction, + aborted: false + }); + reportGas("Auction start", startTx); + + const auctionAfter = await regularItem.getAuctionConfig(); + + expect(auctionAfter.duration).toEqual(newConfig.duration); + expect(auctionAfter.max_bid).toEqual(newConfig.max_bid); + expect(auctionAfter.initial_bid).toEqual(newConfig.min_bid); + expect(auctionAfter.min_bid_step).toEqual(newConfig.min_bid_step); + expect(auctionAfter.extend_time).toEqual(newConfig.min_extend_time); + expect(auctionAfter.benificiary).toEqualAddress(newConfig.benificiary); + + ownerStartedAuction = blockchain.snapshot(); + }); + it('owner should get excess if startAuction is passed with queryId != 0', async () => { + await blockchain.loadFrom(initialAuctionDone); + const itemData = await regularItem.getNftData(); + const queryId = BigInt(getRandomInt(1, 100_000)); + + expect(itemData.owner).toEqualAddress(deployer.address); + const testAddress = randomAddress(0); + + await assertAuctionConfigIsEmpty(regularItem, true); + + const minBid = BigInt(getRandomInt(2, 5)) * min_storage; + + const newConfig: AuctionParameters = { + min_bid: minBid, + max_bid: BigInt(getRandomInt(100, 10000)) * toNano('1'), + duration: getRandomInt(1, 3600) * 60, + min_extend_time:getRandomInt(10, 1800), + benificiary: testAddress, + min_bid_step: defaultAuctionConfig.min_bid_step // BigInt(getRandomInt(1, 100)) + } + + const res = await assertStartAuction(regularItem, deployer, newConfig, 0, queryId); + + const startTx = findTransactionRequired(res.transactions, { + on: regularItem.address, + op: Op.teleitem_start_auction, + aborted: false + }); + reportGas("Auction start with excess", startTx); + + const auctionAfter = await regularItem.getAuctionConfig(); + + expect(auctionAfter.duration).toEqual(newConfig.duration); + expect(auctionAfter.max_bid).toEqual(newConfig.max_bid); + expect(auctionAfter.initial_bid).toEqual(newConfig.min_bid); + expect(auctionAfter.min_bid_step).toEqual(newConfig.min_bid_step); + expect(auctionAfter.extend_time).toEqual(newConfig.min_extend_time); + expect(auctionAfter.benificiary).toEqualAddress(newConfig.benificiary); + + ownerStartedAuction = blockchain.snapshot(); + + }); + it('If no bids were made, owner should be able to get item back on time expiration', async () => { + await blockchain.loadFrom(ownerStartedAuction); + const curState = await regularItem.getAuctionState(); + assertAuctionConfigIsEmpty(regularItem, false); + const itemBefore = await regularItem.getNftData(); + + blockchain.now = curState.end_time; + + const res =await regularItem.sendCheckEndExternal(); + assertAuctionConfigIsEmpty(regularItem, true); + reportGas("End auction, no bids", res.transactions[0]); + + const itemAfter = await regularItem.getNftData(); + expect(itemBefore.owner).toEqualAddress(itemAfter.owner!); + + }); + it.skip('should not be able to start auction with non-standard benificiary address', async () => { + await blockchain.loadFrom(initialAuctionDone); + const itemData = await regularItem.getNftData(); + + expect(itemData.owner).toEqualAddress(deployer.address); + await assertAuctionConfigIsEmpty(regularItem, true); + + const ds = auctionConfigToCell(defaultAuctionConfig).beginParse(); + // Skip address + ds.loadAddress(); + + const addrNone = beginCell().storeUint(0, 2).storeSlice(ds).endCell(); + const externalAddr = beginCell().storeAddress(new ExternalAddress(42n, 256)).storeSlice(ds).endCell(); + const varAddress = beginCell().storeUint(0b11, 2).storeBit(false).storeUint(256, 9).storeUint(0, 32).storeUint(42n, 256).storeSlice(ds).endCell(); + + for(let testPayload of [addrNone, varAddress, externalAddr]) { + let res = await regularItem.sendStartAuction(deployer.getSender(), testPayload); + + // Auction should stay empty + await assertAuctionConfigIsEmpty(regularItem, true); + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: deployer.address, + op: Op.teleitem_start_auction, + aborted: true + }); + } + }); + it('should only accept auction with min_bid >= minimal value', async () => { + await blockchain.loadFrom(initialAuctionDone); + + const itemData = await regularItem.getNftData(); + expect(itemData.owner).toEqualAddress(deployer.address); + + // Minimal value from contract + const minValue = min_storage * 2n; + + let res = await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, min_bid: minValue - 1n}, Errors.invalid_auction_config); + + res = await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, min_bid: minValue}, 0); + }); + + it('auction extend time should be limited by 1 week', async () => { + const week = 3600 * 24 * 7; + await blockchain.loadFrom(initialAuctionDone); + await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, min_extend_time: week + 1}, Errors.invalid_auction_config); + + await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, min_extend_time: week}, 0); + }); + it('auction duration time should be limited by year', async () => { + const year = 3600 * 24 * 365; + await blockchain.loadFrom(initialAuctionDone); + await assertAuctionConfigIsEmpty(regularItem, true); + await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, duration: year + 1}, Errors.invalid_auction_config); + await assertAuctionConfigIsEmpty(regularItem, true); + + await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, duration: year}, 0); + await assertAuctionConfigIsEmpty(regularItem, false); + }); + + it('minimal bid value should be enough for auction with expected duration to happen', async () => { + /** + * NOTE + * Nft content cell has, no upper limit, so + * theoretically, one could still + * deploy item with larger that expected content, + * and exceed min_storage over the duration period + **/ + await blockchain.loadFrom(initialAuctionDone); + + let itemData = await regularItem.getNftData(); + expect(itemData.owner).toEqualAddress(deployer.address); + await assertAuctionConfigIsEmpty(regularItem, true); + + const minValue = min_storage * 2n; + // Max duration from contract + const maxDuration = 3600 * 24 * 365; + + let res = await regularItem.sendStartAuction(deployer.getSender(), {...defaultAuctionConfig, min_bid: minValue, duration: maxDuration}); + await assertAuctionConfigIsEmpty(regularItem, false); + + // Sending absolute minimal bid + res = await regularItem.sendBet(otherBidder.getSender(), minValue); + + const auctionAfter = await regularItem.getAuctionState(); + expect(auctionAfter.bidder_address).toEqualAddress(otherBidder.address); + expect(auctionAfter.bid).toEqual(minValue); + + blockchain.now = curTime() + maxDuration; + await assertAuctionEnded(regularItem, async (item, bid) => item.sendCheckEndExternal(), 0n); + }); + it('should only accept auctions with min_bid_step > 0', async () => { + // Actually it can't be < 0, because it is loaded as unsigned (load_uint(8)) + await blockchain.loadFrom(initialAuctionDone); + + let res = await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, min_bid_step: 0n}, Errors.invalid_auction_config); + await assertAuctionConfigIsEmpty(regularItem, true); + + for(let testStep of [1n, 255n, BigInt(getRandomInt(2, 254))]) { + res = await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, min_bid_step: testStep}, 0); + await blockchain.loadFrom(initialAuctionDone); + } + }); + it('should not accept auctions with max_bid < min_bid, unless max_bid = 0', async () => { + await blockchain.loadFrom(initialAuctionDone); + + let minValue = Number(min_storage * 2n) + 1; + let minBid = 0; + let maxBid = 0; + + const itemData = await regularItem.getNftData(); + expect(itemData.owner).toEqualAddress(deployer.address); + + await assertAuctionConfigIsEmpty(regularItem, true); + + for(let i = 0; i < 5; i++) { + do { + let bidA = getRandomInt(minValue, 10 ** 9); + let bidB = getRandomInt(minValue, 10 ** 9); + // Remember, we're flipping how it's supposed to be + if(bidA > bidB) { + minBid = bidA; + maxBid = bidB; + } else { + minBid = bidB; + maxBid = bidA; + } + } while(maxBid == minBid); + + expect(maxBid).toBeLessThan(minBid); + await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, min_bid: BigInt(minBid), max_bid: BigInt(maxBid)}, Errors.invalid_auction_config); + const auctionConfig = await regularItem.getAuctionConfig(); + expect(auctionConfig.max_bid).not.toBeLessThan(auctionConfig.initial_bid); + await assertAuctionConfigIsEmpty(regularItem, true); + } + // But should accept max_bid 0 + await assertStartAuction(regularItem, deployer, {...defaultAuctionConfig, min_bid: BigInt(minBid), max_bid: 0n}, 0); + }); + it('owner should not be able to re-start already started auction', async () => { + await blockchain.loadFrom(ownerStartedAuction); + + assertAuctionConfigIsEmpty(regularItem, false); + const minBid = BigInt(getRandomInt(2, 5)) * min_storage; + + const newConfig: AuctionParameters = { + min_bid: minBid, + max_bid: BigInt(getRandomInt(10, 100)) * minBid, + duration: getRandomInt(1, 3600) * 60, + min_extend_time:getRandomInt(10, 1800), + benificiary: randomAddress(0), + min_bid_step: BigInt(getRandomInt(1, 100)) + } + + for(let testConfig of [newConfig, defaultAuctionConfig]) { + const res = await regularItem.sendStartAuction(deployer.getSender(), testConfig); + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: deployer.address, + op: Op.teleitem_start_auction, + aborted: true, + exitCode: Errors.forbidden_not_stake + }); + } + }); + it('owner should be able to cancel auction if no bids made', async () => { + await blockchain.loadFrom(ownerStartedAuction); + + // Config is not empty + await assertAuctionConfigIsEmpty(regularItem, false); + + const res = await regularItem.sendCancelAuction(deployer.getSender()); + const cancelTx = findTransactionRequired(res.transactions,{ + on: regularItem.address, + from: deployer.address, + op: Op.teleitem_cancel_auction, + aborted: false + }); + // Now should become empty + await assertAuctionConfigIsEmpty(regularItem, true); + reportGas("Cancel auction", cancelTx); + }); + it('owner started auction should handle bids in same way as initial auction', async () => { + await blockchain.loadFrom(ownerStartedAuction); + + let auctionState = await regularItem.getAuctionState(); + const auctionConfig = await regularItem.getAuctionConfig(); + // Litteraly copy case + let betBefore = auctionState.min_bid; + let newBet = betBefore + 1n; + const endTimeBefore = auctionState.end_time; + // Min bid step is 10%, meaning that if 10% of new bet <= 1 TON + // next bet should be max(newBet + 1TON, newBet * 1.10) + let nextBet = computeNextBid(newBet, defaultAuctionConfig.min_bid_step); + + blockchain.now = curTime() + getRandomInt(1, 1000); + let res = await regularItem.sendBet(otherBidder.getSender(), newBet, 'empty_body'); + + const smc = await blockchain.getContract(regularItem.address); + + let msgCount = 0; + if(auctionState.bid > 0n) { + expect(res.transactions).toHaveTransaction({ + on: deployer.address, + from: regularItem.address, + value: auctionState.bid + }) + + msgCount = 1; + } + let bidTx = findTransactionRequired(res.transactions, { + on:regularItem.address, + from: otherBidder.address, + value: newBet, + aborted: false, + outMessagesCount: msgCount + }); + + reportGas("First bid", bidTx); + + auctionState = await regularItem.getAuctionState(); + + expect(auctionState.bidder_address).toEqualAddress(otherBidder.address); + expect(auctionState.min_bid).toEqual(nextBet); + expect(nextBet - betBefore).toEqual(toNano('1') + (newBet - betBefore)); + expect(auctionState.bid).toEqual(newBet); + expect(auctionState.bid_ts).toEqual(blockchain.now); + expect(auctionState.end_time).toEqual(endTimeBefore); + + betBefore = auctionState.min_bid; + newBet = betBefore + toNano('11'); + nextBet = computeNextBid(newBet, auctionConfig.min_bid_step); + + // Now let's test case where nextBet 10% is larger than 1 TON + + res = await regularItem.sendBet(deployer.getSender(), newBet, 'op_zero'); + + bidTx = findTransactionRequired(res.transactions, { + on:regularItem.address, + from: deployer.address, + value: newBet, + aborted: false, + // outMessagesCount: 1 + }); + + reportGas("Next bid", bidTx); + expect(res.transactions).toHaveTransaction({ + on: otherBidder.address, + from: regularItem.address, + value: auctionState.bid + }); + expect(smc.balance).toBeGreaterThanOrEqual(min_storage); + + auctionState = await regularItem.getAuctionState(); + + expect(auctionState.bidder_address).toEqualAddress(deployer.address); + expect(auctionState.min_bid).toEqual(nextBet); + expect(nextBet - betBefore).toBeGreaterThan(toNano('1') + (newBet - betBefore)); + expect(auctionState.bid).toEqual(newBet); + expect(auctionState.end_time).toEqual(endTimeBefore); + + expect(smc.balance).toBeGreaterThanOrEqual(min_storage); + + ownerBidsMade = blockchain.snapshot(); + }); + it('owner should not be able to cancel auction when bids were maid', async () => { + await blockchain.loadFrom(ownerBidsMade); + const itemData = await regularItem.getNftData(); + expect(itemData.owner).toEqualAddress(deployer.address); + await assertAuctionConfigIsEmpty(regularItem, false); + + const res = await regularItem.sendCancelAuction(deployer.getSender()); + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: deployer.address, + op: Op.teleitem_cancel_auction, + aborted: true, + exitCode: Errors.already_has_stakes + }); + }); + it('owner should not be able to transfer item while auction is active', async () => { + await blockchain.loadFrom(ownerBidsMade); + const itemData = await regularItem.getNftData(); + expect(itemData.owner).toEqualAddress(deployer.address); + await assertAuctionConfigIsEmpty(regularItem, false); + const testAddress = randomAddress(0); + + const res = await regularItem.sendTransfer(deployer.getSender(), testAddress, deployer.address); + + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: deployer.address, + op: Op.transfer, + aborted: true, + exitCode: Errors.forbidden_not_stake + }); + }); + it('should be able to end owner auction when time expire', async () => { + let reported = false; + await blockchain.loadFrom(ownerBidsMade); + const stateBefore = await regularItem.getAuctionState(); + + for (let testTime of [stateBefore.end_time, stateBefore.end_time + getRandomInt(1, 360000)]) { + blockchain.now = testTime; + + const res = await assertAuctionEnded(regularItem, async (item, bid) => await item.sendCheckEndExternal(), 0n); + if(!reported) { + reportGas("Owner auction ended ext", findTransactionRequired(res.transactions, { + on: regularItem.address, + outMessagesCount: 3, + aborted: false + })); + reported = true; + } + await blockchain.loadFrom(ownerBidsMade); + } + // Should not end + blockchain.now = stateBefore.end_time - 1; + await expect(assertAuctionEnded(regularItem, async (item, bid) => await item.sendCheckEndExternal(), 0n)).rejects.toThrow(); + }); + it('should be able to end owner auction by reaching max bid', async () => { + for (let testState of [ownerStartedAuction, ownerBidsMade]) { + await blockchain.loadFrom(testState); + const curConfig = await regularItem.getAuctionConfig(); + expect(curConfig.max_bid).toBeGreaterThan(0n); + for(let testBid of [curConfig.max_bid, curConfig.max_bid * 2n]) { + const res = await assertAuctionEnded(regularItem, async (item, bid) => item.sendBet(otherBidder.getSender(), bid), testBid); + const finishTx = findTransactionRequired(res.transactions, { + on: regularItem.address, + from: otherBidder.address, + aborted: false + }); + if(testState === ownerStartedAuction) { + reportGas("Owner first bid -> max_bid",finishTx); + } else { + reportGas("Owner next bid -> max_bid",finishTx); + } + expect((await regularItem.getNftData()).owner).toEqualAddress(otherBidder.address); + await blockchain.loadFrom(testState); + // Other sender should not matter + await assertAuctionEnded(regularItem, async (item, bid) => item.sendBet(deployer.getSender(), bid), testBid); + expect((await regularItem.getNftData()).owner).toEqualAddress(deployer.address); + await blockchain.loadFrom(testState); + } + + // Souldn't end if off by one + await expect(assertAuctionEnded(regularItem, async (item, bid) => item.sendBet(otherBidder.getSender(), bid), curConfig.max_bid - 1n)).rejects.toThrow(); + } + }); + it('malformed topup and arbitrary comment msg should end up as bet during auction', async () => { + const notTopup = beginCell().storeBuffer(Buffer.from("#not_topup")).endCell(); + const substring = beginCell().storeBuffer(Buffer.from("#topup#topup")).endCell(); + const snakeRef = beginCell().storeStringRefTail("#topup").endCell(); + const tailRef = beginCell().storeBuffer(Buffer.from("#topup")).storeRef(beginCell().storeBuffer(Buffer.from("someOtherItem")).endCell()).endCell(); + const snakeTail = beginCell().storeBuffer(Buffer.from("#top")).storeRef(beginCell().storeBuffer(Buffer.from("up")).endCell()).endCell(); + + + // During auction this behaviour will be considered stake + for(let testState of [itemsDeployedState, ownerStartedAuction]) { + await blockchain.loadFrom(testState); + const auctionConfig = await regularItem.getAuctionConfig(); + const auctionState = await regularItem.getAuctionState(); + + for(let testWallet of [deployer, otherBidder, royaltyWallet]) { + for(let testPayload of [notTopup, substring, snakeRef, tailRef, snakeTail]) { + const res = await testWallet.send({ + to: regularItem.address, + value: computeNextBid(auctionState.bid, auctionConfig.min_bid_step) + 1n, + body: beginCell().storeUint(0, 32).storeSlice(testPayload.asSlice()).endCell() + }); + + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + op: 0, + aborted: false + }); + + const stateAfter = await regularItem.getAuctionState(); + expect(stateAfter.bid).toBeGreaterThan(auctionState.bid); + + await blockchain.loadFrom(testState); + } + } + } + }); + }); + describe('Item', () => { + beforeEach(async () => await blockchain.loadFrom(initialAuctionDone)); + it('item owner should be able to transfer item', async () => { + + const deployerItem = regularItem; + const dstAddr = randomAddress(0); + + const forwardAmount = BigInt(getRandomInt(1, 10)) * toNano('1'); + const forwardPayload = beginCell().storeStringTail("Hop hey!").endCell(); + const testQueryId = getRandomInt(42, 142); + const res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount, forwardPayload, forwardAmount + toNano('1'), testQueryId); + + reportGas("Item transfer", findTransactionRequired(res.transactions, { + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + outMessagesCount: 2, + aborted: false + })); + + expect(res.transactions).toHaveTransaction({ + on: dstAddr, + from: deployerItem.address, + value: forwardAmount, + body: beginCell().storeUint(Op.ownership_assigned, 32) + .storeUint(testQueryId, 64) + .storeAddress(deployer.address) + .storeBit(true).storeRef(forwardPayload) + .endCell() + }); + + expect(res.transactions).toHaveTransaction({ + on: royaltyWallet.address, + from: deployerItem.address, + op: Op.excesses + }); + + const dataAfter = await deployerItem.getNftData(); + expect(dataAfter.owner).toEqualAddress(dstAddr); + + const msgPrices = getMsgPrices(blockchain.config, 0); + + const inMsg = res.transactions[1].inMessage!; + + if(inMsg.info.type !== 'internal') { + throw "No way!"; + } + + // Make sure that 3/2 approach is applicable + expect(inMsg.info.forwardFee * 3n / 2n).toBeGreaterThanOrEqual(computeMessageForwardFees(msgPrices, inMsg).fees.total); + }); + it('item should return royalty parameters', async () => { + for(let testState of [itemsDeployedState, ownerStartedAuction, initialAuctionDone]) { + await blockchain.loadFrom(testState); + const msgPrices = getMsgPrices(blockchain.config, 0); + const msgValue = toNano('0.05'); + const queryId = getRandomInt(0, 100); + + const res = await regularItem.sendGetRoyaltyParams(deployer.getSender(), msgValue, queryId); + + const getRoyaltyTx = findTransactionRequired(res.transactions, { + on: regularItem.address, + from: deployer.address, + op: Op.get_royalty_params, + aborted: false, + outMessagesCount: 1 + }); + + const outMsg = getRoyaltyTx.outMessages.get(0)!; + if(outMsg.info.type !== 'internal') { + throw Error("No way!"); + } + + reportGas("Report royalty parameters", getRoyaltyTx); + const fwdFee = computeMessageForwardFees(msgPrices, outMsg); + const computePhase = computedGeneric(getRoyaltyTx); + + expect(res.transactions).toHaveTransaction({ + on: deployer.address, + from: regularItem.address, + value: msgValue - fwdFee.fees.total - computePhase.gasFees, // Should return change + body: beginCell() + .storeUint(Op.report_royalty_params, 32) + .storeUint(queryId, 64) + .storeUint(royaltyFactor, 16) + .storeUint(royaltyBase, 16) + .storeAddress(royaltyWallet.address) + .endCell() + }); + } + }); + it('item should return static data', async () => { + for(let testState of [itemsDeployedState, ownerStartedAuction, initialAuctionDone]) { + await blockchain.loadFrom(testState); + const msgPrices = getMsgPrices(blockchain.config, 0); + const msgValue = toNano('0.05'); + const queryId = getRandomInt(1, 10000); + + const res = await regularItem.sendGetStaticData(deployer.getSender(), msgValue, queryId); + + const itemData = await regularItem.getNftData(); + const getStaticTx = findTransactionRequired(res.transactions, { + on: regularItem.address, + from: deployer.address, + op: Op.get_static_data, + aborted: false, + outMessagesCount: 1 + }); + + const outMsg = getStaticTx.outMessages.get(0)!; + if(outMsg.info.type !== 'internal') { + throw Error("No way!"); + } + + reportGas("Report item get static data", getStaticTx); + const fwdFee = computeMessageForwardFees(msgPrices, outMsg); + const computePhase = computedGeneric(getStaticTx); + + expect(res.transactions).toHaveTransaction({ + on: deployer.address, + from: regularItem.address, + value: msgValue - fwdFee.fees.total - computePhase.gasFees, + body: beginCell() + .storeUint(Op.report_static_data, 32) + .storeUint(queryId, 64) + .storeUint(itemData.index, 256) + .storeAddress(nftCollection.address) + .endCell() + }); + } + }); + it('should top up with specifically crafted message at any state and any src', async () => { + for(let testState of [itemsDeployedState, ownerStartedAuction, initialAuctionDone]) { + await blockchain.loadFrom(testState); + for(let testWallet of [deployer, otherBidder, royaltyWallet]) { + const res = await testWallet.send({ + to: regularItem.address, + value: BigInt(getRandomInt(1, 1337)) * toNano('0.01'), + body: beginCell().storeUint(0, 32).storeBuffer(Buffer.from("#topup")).endCell() + }); + + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: testWallet.address, + aborted: false, + outMessagesCount: 0 + }); + } + } + }); + it('should reject malformed topup or arbitrary comment after auction unless from owner', async () => { + const notTopup = beginCell().storeBuffer(Buffer.from("#not_topup")).endCell(); + const substring = beginCell().storeBuffer(Buffer.from("#topup#topup")).endCell(); + const snakeRef = beginCell().storeStringRefTail("#topup").endCell(); + const tailRef = beginCell().storeBuffer(Buffer.from("#topup")).storeRef(beginCell().storeBuffer(Buffer.from("someOtherItem")).endCell()).endCell(); + const snakeTail = beginCell().storeBuffer(Buffer.from("#top")).storeRef(beginCell().storeBuffer(Buffer.from("up")).endCell()).endCell(); + + await blockchain.loadFrom(initialAuctionDone); + + const itemData = await regularItem.getNftData(); + + for(let testWallet of [deployer, otherBidder, royaltyWallet]) { + const isOwner = itemData.owner?.equals(testWallet.address); + + for(let testPayload of [notTopup, substring, snakeRef, tailRef, snakeTail]) { + const res = await testWallet.send({ + to: regularItem.address, + value: BigInt(getRandomInt(1, 100_000)) * toNano('0.01'), + body: beginCell().storeUint(0, 32).storeSlice(testPayload.asSlice()).endCell() + }); + + if(isOwner) { + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: testWallet.address, + op: 0, + aborted: false, + outMessagesCount: 0 + }); + } else { + expect(res.transactions).toHaveTransaction({ + on: regularItem.address, + from: testWallet.address, + aborted: true, + exitCode: Errors.forbidden_topup + }); + } + } + } + }); + it('non-initialized item should not be able to process any operations besides deploy', async () => { + const prevState = blockchain.snapshot(); + + await blockchain.loadFrom(initialState); + + if(collectionMessage.info.type !== 'internal') { + throw new Error("No way"); + } + + const itemAddr = collectionMessage.info.dest; + const nftItem = blockchain.openContract(NftItem.createFromAddress(itemAddr)); + + await deployer.send({ + to: nftItem.address, + body: collectionMessage.body, + init: collectionMessage.init, + value: collectionMessage.info.value.coins + }); + + const itemData = await nftItem.getNftData(); + expect(itemData.isInit).toBe(false); + + let testBetEmpty = async (wallet: SandboxContract) => await nftItem.sendBet(wallet.getSender(), toNano('1000'), 'empty_body'); + let testBetOpZero = async (wallet: SandboxContract) => await nftItem.sendBet(wallet.getSender(), toNano('1000'), 'op_zero'); + let testGetStaticData = async (wallet: SandboxContract) => await nftItem.sendGetStaticData(wallet.getSender()); + let testGetRoyaltyParams = async (wallet: SandboxContract) => await nftItem.sendGetRoyaltyParams(wallet.getSender()); + let testTransfer = async (wallet: SandboxContract) => await nftItem.sendTransfer(wallet.getSender(), wallet.address, null); + let testStartAuction = async (wallet: SandboxContract) => await nftItem.sendStartAuction(wallet.getSender(), defaultAuctionConfig); + let testCancelAuction = async (wallet: SandboxContract) => await nftItem.sendCancelAuction(wallet.getSender()); + + let testTopUp = async (wallet: SandboxContract) => await wallet.send({ + to: nftItem.address, + body: beginCell().storeUint(0, 32).storeStringTail("#topup").endCell(), + value: toNano('1') + }); + + let testCases = [ + testBetEmpty, + testBetOpZero, + testGetStaticData, + testGetRoyaltyParams, + testTransfer, + testStartAuction, + testStartAuction, + testCancelAuction, + testTopUp]; + + for(let testWallet of [deployer, otherBidder, royaltyWallet]) { + for(let testCase of testCases) { + const res = await testCase(testWallet); + expect(res.transactions).toHaveTransaction({ + on: nftItem.address, + aborted: true, + exitCode: Errors.uninited + }); + } + } + + await blockchain.loadFrom(prevState); + }); + it('non-owner should not be able to transfer item', async () => { + + const deployerItem = regularItem; + + const forwardAmount = BigInt(getRandomInt(1, 10)) * toNano('1'); + const forwardPayload = beginCell().storeStringTail("Hop hey!").endCell(); + + // Make sure transfer mode doesn't impact auth check + for(let testVector of [ + {response: royaltyWallet.address, amount: forwardAmount, payload: forwardPayload}, + {response: royaltyWallet.address, amount: forwardAmount, payload: null}, + {response: royaltyWallet.address, amount: 0n, payload: null}, + {response: null, amount: forwardAmount, payload: forwardPayload}, + {response: null, amount: forwardAmount, payload: null}, + {response: null, amount: 0n, payload: null}, + ]) { + + const res = await deployerItem.sendTransfer(royaltyWallet.getSender(), + royaltyWallet.address, + testVector.response, + testVector.amount, + testVector.payload, + testVector.amount + toNano('1')); + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: royaltyWallet.address, + op: Op.transfer, + aborted: true, + exitCode: Errors.forbidden_transfer + }); + } + + }); + + it('transfer should work with minimal amount, and amount depends on number of outgoing messages', async () => { + + const deployerItem = regularItem; + const dstAddr = randomAddress(0); + + const forwardAmount = BigInt(getRandomInt(1, 10)) * toNano('1'); + const forwardPayload = beginCell().storeStringTail("Hop hey!").endCell(); + const testQueryId = getRandomInt(42, 142); + + let smc = await blockchain.getContract(deployerItem.address); + smc.balance = min_storage; + + let res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount, forwardPayload, forwardAmount + toNano('1'), testQueryId); + + let dataAfter = await deployerItem.getNftData(); + expect(dataAfter.owner).toEqualAddress(dstAddr); + + const transferTx = findTransactionRequired(res.transactions, { + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: false, + outMessagesCount: 2 + }); + + const inMsg = transferTx.inMessage!; + + if(inMsg.info.type !== 'internal') { + throw "No way!"; + } + + // ExpectedFee + const expFee = inMsg.info.forwardFee * 3n / 2n; + + let minFee = forwardAmount + expFee * 2n; + + // Roll back and try again with value below minFee + await blockchain.loadFrom(initialAuctionDone); + + smc = await blockchain.getContract(deployerItem.address); + smc.balance = min_storage + + + res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount, forwardPayload, minFee - 1n, testQueryId); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: true, + exitCode: Errors.not_enough_funds + }); + + // Now with minimalFee but balance below storage value + smc.balance = min_storage - (BigInt(getRandomInt(1, 3)) * toNano('0.01')); + res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount, forwardPayload, minFee, testQueryId); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: true, + exitCode: Errors.not_enough_funds + }); + + res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount, forwardPayload, minFee + (min_storage - smc.balance), testQueryId); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: false, + outMessagesCount: 2 + }); + + // Make sure forwardAmount particpates in fee calculation + await blockchain.loadFrom(initialAuctionDone); + res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount + 1n, forwardPayload, minFee, testQueryId); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: true, + exitCode: Errors.not_enough_funds + }); + + // Dropping outgoing messages should result in lowering minimal fee + for(let testVector of [{refund: null, amount: forwardAmount}, {refund: dstAddr, amount: 0n}]) { + await blockchain.loadFrom(initialAuctionDone); + smc = await blockchain.getContract(deployerItem.address); + smc.balance = min_storage; + + // Accepted minFee should be lowered by 1 expected forward fee + res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, testVector.refund, testVector.amount, forwardPayload, minFee - expFee, testQueryId); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: false, + outMessagesCount: 1 + }); + + dataAfter = await deployerItem.getNftData(); + expect(dataAfter.owner).toEqualAddress(dstAddr); + } + + // console.log(res.transactions[1].description); + if(balanceStrict) { + expect(smc.balance).toBeGreaterThanOrEqual(min_storage); + } + + + // Now try minimal fee + await blockchain.loadFrom(initialAuctionDone); + smc = await blockchain.getContract(deployerItem.address); + smc.balance = min_storage; + + res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount, forwardPayload, minFee, testQueryId); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: false, + outMessagesCount: 2 + }); + + dataAfter = await deployerItem.getNftData(); + expect(dataAfter.owner).toEqualAddress(dstAddr); + + if(balanceStrict) { + // console.log(res.transactions[1].description); + expect(smc.balance).toBeGreaterThanOrEqual(min_storage); // Min storage should be left on contract + } + }); + + it('owner should be able to transfer item without notification', async () => { + + const deployerItem = regularItem; + const dstAddr = randomAddress(0); + + const forwardAmount = 0n; // Forward amount is zero, payload should be ignored + const forwardPayload = beginCell().storeStringTail("Hop hey!").endCell(); + + let res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount, forwardPayload, forwardAmount + toNano('1')); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: false, + outMessagesCount: 1 + }); + + expect(res.transactions).toHaveTransaction({ + on: royaltyWallet.address, + from: deployerItem.address, + op: Op.excesses + }); + + const dataAfter = await deployerItem.getNftData(); + expect(dataAfter.owner).toEqualAddress(dstAddr); + }); + it('owner should be able to attach data directly into ownership_assigned body', async () => { + + const deployerItem = regularItem; + const dstAddr = randomAddress(0); + + const forwardAmount = 1n; + const forwardPayload = beginCell().storeStringTail("Hop hey!").endCell(); + + let res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, royaltyWallet.address, forwardAmount, forwardPayload.asSlice(), forwardAmount + toNano('1'), 42n); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + outMessagesCount: 2, + aborted: false + }); + + expect(res.transactions).toHaveTransaction({ + on: dstAddr, + from: deployerItem.address, + value: forwardAmount, + body: beginCell().storeUint(Op.ownership_assigned, 32) + .storeUint(42n, 64) + .storeAddress(deployer.address) + .storeBit(false) + .storeSlice(forwardPayload.asSlice()) + .endCell() + }); + }); + + it.skip('should validate Either forward_payload', async () => { + + const deployerItem = regularItem; + const dstAddr = randomAddress(0); + + const forwardAmount = 1n; + const forwardPayload = beginCell().storeStringTail("Hop hey!").endCell(); + + const transferMsg = NftItem.transferMessage(dstAddr, deployer.address, forwardAmount, forwardPayload); + // Last indicator bit cut + const truncated = beginCell().storeBits(transferMsg.beginParse().loadBits(transferMsg.bits.length - 1)).endCell(); + // Indicator bit set to true, but ref is absent + const noRef = new Cell({bits: transferMsg.bits, refs: []}); + + for(let testPayload of [truncated, noRef]) { + const res = await deployer.send({ + to: deployerItem.address, + body: testPayload, + value: toNano('1') + }); + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: true, + }); + } + }); + it('owner should be able to transfer item without excess and forward payload', async () => { + const deployerItem = regularItem + const dstAddr = randomAddress(0); + + const forwardAmount = 0n; // Forward amount is zero, payload should be ignored + + let res = await deployerItem.sendTransfer(deployer.getSender(), dstAddr, null, forwardAmount); + + expect(res.transactions).toHaveTransaction({ + on: deployerItem.address, + from: deployer.address, + op: Op.transfer, + aborted: false, + outMessagesCount: 0 + }); + + const dataAfter = await deployerItem.getNftData(); + expect(dataAfter.owner).toEqualAddress(dstAddr); + }); + // TG Collection doesn't return static data + it.skip('should return static data', async () => { + const msgPrices = getMsgPrices(blockchain.config, 0); + const colData = await nftCollection.getCollectionData(); + const lastIdx = colData.nextItemIndex; + + expect(lastIdx).toBeGreaterThan(0); + + const testIdx = getRandomInt(0, lastIdx - 1); + + const testItem = regularItem; + + const msgValue = toNano('0.05'); + const queryId = getRandomInt(0, 100); + const res = await testItem.sendGetStaticData(deployer.getSender(), msgValue, queryId); + + const getDataTx = findTransactionRequired(res.transactions, { + on: testItem.address, + from: deployer.address, + op: Op.get_static_data, + aborted: false, + outMessagesCount: 1 + }); + + reportGas("Get static data", getDataTx); + + const outMsg = getDataTx.outMessages.get(0)!; + if(outMsg.info.type !== 'internal') { + throw Error("No way!"); + } + const fwdFee = computeMessageForwardFees(msgPrices, outMsg); + + const computePhase = computedGeneric(getDataTx); + expect(res.transactions).toHaveTransaction({ + on: deployer.address, + from: testItem.address, + value: msgValue - fwdFee.fees.total - computePhase.gasFees, + body: beginCell() + .storeUint(Op.report_static_data, 32) + .storeUint(queryId, 64) + .storeUint(testIdx, 256) + .storeAddress(nftCollection.address) + .endCell() + }); + }); + }); +}); + diff --git a/tests/gasUtils.ts b/tests/gasUtils.ts new file mode 100644 index 0000000..f27aaf4 --- /dev/null +++ b/tests/gasUtils.ts @@ -0,0 +1,344 @@ +import { Cell, Slice, beginCell, Dictionary, Message, DictionaryValue, Transaction, BitString } from '@ton/core'; + +export type GasPrices = { + flat_gas_limit: bigint, + flat_gas_price: bigint, + gas_price: bigint; +}; +export type StorageValue = { + utime_sice: number, + bit_price_ps: bigint, + cell_price_ps: bigint, + mc_bit_price_ps: bigint, + mc_cell_price_ps: bigint +}; + + +export type MsgPrices = ReturnType; +export type FullFees = ReturnType; + +export class StorageStats { + bits: bigint; + cells: bigint; + + constructor(bits?: number | bigint, cells?: number | bigint) { + this.bits = bits !== undefined ? BigInt(bits) : 0n; + this.cells = cells !== undefined ? BigInt(cells) : 0n; + } + add(...stats: StorageStats[]) { + let cells = this.cells, bits = this.bits; + for (let stat of stats) { + bits += stat.bits; + cells += stat.cells; + } + return new StorageStats(bits, cells); + } + sub(...stats: StorageStats[]) { + let cells = this.cells, bits = this.bits; + for (let stat of stats) { + bits -= stat.bits; + cells -= stat.cells; + } + return new StorageStats(bits, cells); + } + addBits(bits: number | bigint) { + return new StorageStats(this.bits + BigInt(bits), this.cells); + } + subBits(bits: number | bigint) { + return new StorageStats(this.bits - BigInt(bits), this.cells); + } + addCells(cells: number | bigint) { + return new StorageStats(this.bits, this.cells + BigInt(cells)); + } + subCells(cells: number | bigint) { + return new StorageStats(this.bits, this.cells - BigInt(cells)); + } + + toString() : string { + return JSON.stringify({ + bits: this.bits.toString(), + cells: this.cells.toString() + }); + } +} + +export function computedGeneric(transaction: T) { + if(transaction.description.type !== "generic") + throw("Expected generic transactionaction"); + if(transaction.description.computePhase.type !== "vm") + throw("Compute phase expected") + return transaction.description.computePhase; +} + +export function storageGeneric(transaction: T) { + if(transaction.description.type !== "generic") + throw("Expected generic transactionaction"); + const storagePhase = transaction.description.storagePhase; + if(storagePhase === null || storagePhase === undefined) + throw("Storage phase expected") + return storagePhase; +} + +function shr16ceil(src: bigint) { + let rem = src % BigInt(65536); + let res = src / 65536n; // >> BigInt(16); + if (rem != BigInt(0)) { + res += BigInt(1); + } + return res; +} + +export function reportGas(banner: string, tx: Transaction) { + const computed = computedGeneric(tx); + console.log(`${banner} took ${computed.gasUsed} gas and ${computed.vmSteps} instructions`); +}; + + +export function collectCellStats(cell: Cell, visited:Array, skipRoot: boolean = false, ignoreVisited = false): StorageStats { + let bits = skipRoot ? 0n : BigInt(cell.bits.length); + let cells = skipRoot ? 0n : 1n; + let hash = cell.hash().toString(); + if(!ignoreVisited) { + if (visited.includes(hash)) { + // We should not account for current cell data if visited + return new StorageStats(); + } + else { + visited.push(hash); + } + } + for (let ref of cell.refs) { + let r = collectCellStats(ref, visited, false, ignoreVisited); + cells += r.cells; + bits += r.bits; + } + return new StorageStats(bits, cells); +} + +export function getGasPrices(configRaw: Cell, workchain: 0 | -1): GasPrices { + const config = configRaw.beginParse().loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + + const ds = config.get(21 + workchain)!.beginParse(); + if(ds.loadUint(8) !== 0xd1) { + throw new Error("Invalid flat gas prices tag!"); + } + + const flat_gas_limit = ds.loadUintBig(64); + const flat_gas_price = ds.loadUintBig(64); + + if(ds.loadUint(8) !== 0xde) { + throw new Error("Invalid gas prices tag!"); + } + return { + flat_gas_limit, + flat_gas_price, + gas_price: ds.preloadUintBig(64) + }; +} + +export function setGasPrice(configRaw: Cell, prices: GasPrices, workchain: 0 | -1) : Cell { + const config = configRaw.beginParse().loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + const idx = 21 + workchain; + const ds = config.get(idx)!; + const tail = ds.beginParse().skip(8 + 64 + 64 + 8 + 64); + + const newPrices = beginCell().storeUint(0xd1, 8) + .storeUint(prices.flat_gas_limit, 64) + .storeUint(prices.flat_gas_price, 64) + .storeUint(0xde, 8) + .storeUint(prices.gas_price, 64) + .storeSlice(tail) + .endCell(); + config.set(idx, newPrices); + + return beginCell().storeDictDirect(config).endCell(); +} + +export const storageValue : DictionaryValue = { + serialize: (src, builder) => { + builder.storeUint(0xcc, 8) + .storeUint(src.utime_sice, 32) + .storeUint(src.bit_price_ps, 64) + .storeUint(src.cell_price_ps, 64) + .storeUint(src.mc_bit_price_ps, 64) + .storeUint(src.mc_cell_price_ps, 64) + }, + parse: (src) => { + return { + utime_sice: src.skip(8).loadUint(32), + bit_price_ps: src.loadUintBig(64), + cell_price_ps: src.loadUintBig(64), + mc_bit_price_ps: src.loadUintBig(64), + mc_cell_price_ps: src.loadUintBig(64) + }; + } + }; + +export function getStoragePrices(configRaw: Cell) { + const config = configRaw.beginParse().loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + const storageData = Dictionary.loadDirect(Dictionary.Keys.Uint(32),storageValue, config.get(18)!); + const values = storageData.values(); + + return values[values.length - 1]; +} +export function calcStorageFee(prices: StorageValue, stats: StorageStats, duration: bigint) { + return shr16ceil((stats.bits * prices.bit_price_ps + stats.cells * prices.cell_price_ps) * duration) +} +export function setStoragePrices(configRaw: Cell, prices: StorageValue) { + const config = configRaw.beginParse().loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + const storageData = Dictionary.loadDirect(Dictionary.Keys.Uint(32),storageValue, config.get(18)!); + storageData.set(storageData.values().length - 1, prices); + config.set(18, beginCell().storeDictDirect(storageData).endCell()); + return beginCell().storeDictDirect(config).endCell(); +} + +export function computeGasFee(prices: GasPrices, gas: bigint): bigint { + if(gas <= prices.flat_gas_limit) { + return prices.flat_gas_price; + } + return prices.flat_gas_price + prices.gas_price * (gas - prices.flat_gas_limit) / 65536n +} + +export function computeDefaultForwardFee(msgPrices: MsgPrices) { + return msgPrices.lumpPrice - ((msgPrices.lumpPrice * msgPrices.firstFrac) >> BigInt(16)); +} + +export function computeCellForwardFees(msgPrices: MsgPrices, msg: Cell) { + let storageStats = collectCellStats(msg, [], true); + return computeFwdFees(msgPrices, storageStats.cells, storageStats.bits); +} +export function computeMessageForwardFees(msgPrices: MsgPrices, msg: Message) { + // let msg = loadMessageRelaxed(cell.beginParse()); + let storageStats = new StorageStats(); + + if( msg.info.type !== "internal") { + throw Error("Helper intended for internal messages"); + } + const defaultFwd = computeDefaultForwardFee(msgPrices); + // If message forward fee matches default than msg cell is flat + if(msg.info.forwardFee == defaultFwd) { + return {fees: {res : defaultFwd, total: msgPrices.lumpPrice, remaining: msgPrices.lumpPrice - defaultFwd }, stats: storageStats}; + } + let visited : Array = []; + // Init + if (msg.init) { + let addBits = 5n; // Minimal additional bits + let refCount = 0; + if(msg.init.splitDepth) { + addBits += 5n; + } + if(msg.init.libraries) { + refCount++; + storageStats = storageStats.add(collectCellStats(beginCell().storeDictDirect(msg.init.libraries).endCell(), visited, true)); + } + if(msg.init.code) { + refCount++; + storageStats = storageStats.add(collectCellStats(msg.init.code, visited)) + } + if(msg.init.data) { + refCount++; + storageStats = storageStats.add(collectCellStats(msg.init.data, visited)); + } + if(refCount >= 2) { //https://github.com/ton-blockchain/ton/blob/51baec48a02e5ba0106b0565410d2c2fd4665157/crypto/block/transaction.cpp#L2079 + storageStats.cells++; + storageStats.bits += addBits; + } + } + const lumpBits = BigInt(msg.body.bits.length); + const bodyStats = collectCellStats(msg.body,visited, true); + storageStats = storageStats.add(bodyStats); + + // NOTE: Extra currencies are ignored for now + let fees = computeFwdFeesVerbose(msgPrices, BigInt(storageStats.cells), BigInt(storageStats.bits)); + // Meeh + if(fees.remaining < msg.info.forwardFee) { + // console.log(`Remaining ${fees.remaining} < ${msg.info.forwardFee} lump bits:${lumpBits}`); + storageStats = storageStats.addCells(1).addBits(lumpBits); + fees = computeFwdFeesVerbose(msgPrices, storageStats.cells, storageStats.bits); + } + if(fees.remaining != msg.info.forwardFee) { + console.log("Result fees:", fees); + console.log(msg); + console.log(fees.remaining); + throw(new Error("Something went wrong in fee calcuation!")); + } + return {fees, stats: storageStats}; +} + +export const configParseMsgPrices = (sc: Slice) => { + + let magic = sc.loadUint(8); + + if(magic != 0xea) { + throw Error("Invalid message prices magic number!"); + } + return { + lumpPrice:sc.loadUintBig(64), + bitPrice: sc.loadUintBig(64), + cellPrice: sc.loadUintBig(64), + ihrPriceFactor: sc.loadUintBig(32), + firstFrac: sc.loadUintBig(16), + nextFrac: sc.loadUintBig(16) + }; +} + +export const setMsgPrices = (configRaw: Cell, prices: MsgPrices, workchain: 0 | -1) => { + const config = configRaw.beginParse().loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + + const priceCell = beginCell().storeUint(0xea, 8) + .storeUint(prices.lumpPrice, 64) + .storeUint(prices.bitPrice, 64) + .storeUint(prices.cellPrice, 64) + .storeUint(prices.ihrPriceFactor, 32) + .storeUint(prices.firstFrac, 16) + .storeUint(prices.nextFrac, 16) + .endCell(); + config.set(25 + workchain, priceCell); + + return beginCell().storeDictDirect(config).endCell(); +} + +export const getMsgPrices = (configRaw: Cell, workchain: 0 | -1 ) => { + + const config = configRaw.beginParse().loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + + const prices = config.get(25 + workchain); + + if(prices === undefined) { + throw Error("No prices defined in config"); + } + + return configParseMsgPrices(prices.beginParse()); +} + +export function computeFwdFees(msgPrices: MsgPrices, cells: bigint, bits: bigint) { + return msgPrices.lumpPrice + (shr16ceil((msgPrices.bitPrice * bits) + + (msgPrices.cellPrice * cells)) + ); +} + +export function computeFwdFeesVerbose(msgPrices: MsgPrices, cells: bigint | number, bits: bigint | number) { + const fees = computeFwdFees(msgPrices, BigInt(cells), BigInt(bits)); + + const res = (fees * msgPrices.firstFrac) >> 16n; + return { + total: fees, + res, + remaining: fees - res + } +} + +export const setPrecompiledGas = (configRaw: Cell, code_hash: Buffer, gas_usage: number) => { + const config = configRaw.beginParse().loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + + const entry = beginCell().storeUint(0xb0, 8) + .storeUint(gas_usage, 64) + .endCell().beginParse(); + let dict = Dictionary.empty(Dictionary.Keys.Buffer(32), Dictionary.Values.BitString(8 + 64)); + dict.set(code_hash, entry.loadBits(8 + 64)); + const param = beginCell().storeUint(0xc0, 8).storeBit(1).storeRef(beginCell().storeDictDirect(dict).endCell()).endCell(); + + config.set(45, param); + + return beginCell().storeDictDirect(config).endCell(); +}; diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..db6e905 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,159 @@ + +import { Address, toNano, Cell, Builder, beginCell} from "@ton/core"; + +export const randomAddress = (wc: number = 0) => { + const buf = Buffer.alloc(32); + for (let i = 0; i < buf.length; i++) { + buf[i] = Math.floor(Math.random() * 256); + } + return new Address(wc, buf); +}; + +export const differentAddress = (old: Address) => { + let newAddr: Address; + do { + newAddr = randomAddress(old.workChain); + } while(newAddr.equals(old)); + + return newAddr; +} + +const getRandom = (min:number, max:number) => { + return Math.random() * (max - min) + min; +} + +export const getRandomInt = (min: number, max: number) => { + return Math.round(getRandom(min, max)); +} + +export const getRandomTon = (min:number, max:number): bigint => { + return toNano(getRandom(min, max).toFixed(9)); +} + +export type InternalTransfer = { + from: Address | null, + response: Address | null, + amount: bigint, + forwardAmount: bigint, + payload: Cell | null +}; +export type JettonTransfer = { + to: Address, + response_address: Address | null, + amount: bigint, + custom_payload: Cell | null, + forward_amount: bigint, + forward_payload: Cell | null +} + +export const parseTransfer = (body: Cell) => { + const ts = body.beginParse().skip(64 + 32); + return { + amount: ts.loadCoins(), + to: ts.loadAddress(), + response_address: ts.loadAddressAny(), + custom_payload: ts.loadMaybeRef(), + forward_amount: ts.loadCoins(), + forward_payload: ts.loadMaybeRef() + } +} +export const parseInternalTransfer = (body: Cell) => { + + const ts = body.beginParse().skip(64 + 32); + + return { + amount: ts.loadCoins(), + from: ts.loadAddressAny(), + response: ts.loadAddressAny(), + forwardAmount: ts.loadCoins(), + payload: ts.loadMaybeRef() + }; +}; +type JettonTransferNotification = { + amount: bigint, + from: Address | null, + payload: Cell | null +} +export const parseTransferNotification = (body: Cell) => { + const bs = body.beginParse().skip(64 + 32); + return { + amount: bs.loadCoins(), + from: bs.loadAddressAny(), + payload: bs.loadMaybeRef() + } +} + +type JettonBurnNotification = { + amount: bigint, + from: Address, + response_address: Address | null, +} +export const parseBurnNotification = (body: Cell) => { + const ds = body.beginParse().skip(64 + 32); + const res = { + amount: ds.loadCoins(), + from: ds.loadAddress(), + response_address: ds.loadAddressAny(), + }; + + return res; +} + +const testPartial = (cmp: any, match: any) => { + for (let key in match) { + if(!(key in cmp)) { + throw Error(`Unknown key ${key} in ${cmp}`); + } + + if(match[key] instanceof Address) { + if(!(cmp[key] instanceof Address)) { + return false + } + if(!(match[key] as Address).equals(cmp[key])) { + return false + } + } + else if(match[key] instanceof Cell) { + if(!(cmp[key] instanceof Cell)) { + return false; + } + if(!(match[key] as Cell).equals(cmp[key])) { + return false; + } + } + else if(match[key] !== cmp[key]){ + return false; + } + } + return true; +} +export const testJettonBurnNotification = (body: Cell, match: Partial) => { + const res= parseBurnNotification(body); + return testPartial(res, match); +} + +export const testJettonTransfer = (body: Cell, match: Partial) => { + const res = parseTransfer(body); + return testPartial(res, match); +} +export const testJettonInternalTransfer = (body: Cell, match: Partial) => { + const res = parseInternalTransfer(body); + return testPartial(res, match); +}; +export const testJettonNotification = (body: Cell, match: Partial) => { + const res = parseTransferNotification(body); + return testPartial(res, match); +} + +export const storeText = (text: string) =>{ + return (builder: Builder) => { + builder.storeUint(text.length, 8) + builder.storeBuffer(Buffer.from(text)) + } +} + +export const storeTextRef = (text: string) => { + return (builder: Builder) => { + builder.storeRef(beginCell().store(storeText(text)).endCell()); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d483d67 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, +} diff --git a/wrappers/NftCollection.compile.ts b/wrappers/NftCollection.compile.ts new file mode 100644 index 0000000..fbe1e30 --- /dev/null +++ b/wrappers/NftCollection.compile.ts @@ -0,0 +1,6 @@ +import { CompilerConfig } from '@ton/blueprint'; + +export const compile: CompilerConfig = { + lang: 'func', + targets: ['func/stdlib.fc', 'func/common.fc', 'func/nft-collection-no-dns.fc'] +} diff --git a/wrappers/NftCollection.ts b/wrappers/NftCollection.ts new file mode 100644 index 0000000..38c5c78 --- /dev/null +++ b/wrappers/NftCollection.ts @@ -0,0 +1,313 @@ +import { sha256_sync } from '@ton/crypto'; +import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, Dictionary, DictionaryValue, Sender, SendMode, toNano, internal as internal_relaxed, storeMessageRelaxed } from '@ton/core'; +import { sign } from '@ton/crypto'; +import { Op } from './NftConstants'; +import { storeTextRef, storeText } from '../tests/utils'; + +type NftContentOffchain = { + type: 'offchain', + uri: string +} +type OnChainContentData = 'uri' | 'name' | 'description' | 'image' | 'image_data' | 'symbol' | 'decimals' | 'amount_style' | 'render_type' | 'currency' | 'game'; + +type NftContentOnchain = { + type: 'onchain', + data: Partial> +} + +export type NftContent = NftContentOnchain | NftContentOffchain; + +export type AuctionParameters = { + benificiary: Address, + min_bid: bigint, + max_bid: bigint, + min_bid_step: bigint, + min_extend_time: number, + duration: number +} + +export type RoyaltyParameters = { + address: Address, + royalty_factor: number | bigint, + royalty_base: number | bigint +}; + +export type NftCollectionConfig = { + public_key: Buffer, + subwallet_id: number, + content: NftContent | Cell, + full_domain: string, + item_code: Cell, + royalty: RoyaltyParameters +}; + +export type SignatureParams = { + subwallet_id: number, + valid_since: number, + valid_till: number, + privateKey: Buffer +} + +export type ItemRestrictions = { + force_sender: Address | null, + rewrite_sender: Address | null, +} + +export type NewNftItem = { + token_name: string, + content: NftContent | Cell, + actuion_config: AuctionParameters, + royalty?: RoyaltyParameters | Cell, + restrictions?: ItemRestrictions +} + +type BatchDeployValue = NewNftItem & { + forwardAmount: bigint +} + +function signDataHash(data: Cell, priv:Buffer) { + const hash = data instanceof Cell ? data.hash() : data; + const signature = sign(hash, priv); + return beginCell().storeBuffer(signature).storeSlice(data.asSlice()).endCell(); +} + +function OnChainString(): DictionaryValue { + return { + serialize(src, builder) { + builder.storeRef(beginCell().storeUint(0, 8).storeStringTail(src)); + }, + parse(src) { + const sc = src.loadRef().beginParse(); + const tag = sc.loadUint(8); + if(tag == 0) { + return sc.loadStringTail(); + } else if(tag == 1) { + // Not really tested, but feels like it should work + const chunkDict = Dictionary.loadDirect(Dictionary.Keys.Uint(32), Dictionary.Values.Cell(), sc); + return chunkDict.values().map(x => x.beginParse().loadStringTail()).join(''); + + } else { + throw Error(`Prefix ${tag} is not supported yet!`); + } + } + } +} + +export function nftContentToCell(content: NftContent) { + if(content.type == 'offchain') { + return beginCell() + .storeUint(1, 8) + .storeStringRefTail(content.uri) //Snake logic under the hood + .endCell(); + } + let keySet = new Set(['uri' , 'name' , 'description' , 'image' , 'image_data' , 'symbol' , 'decimals' , 'amount_style' , 'render_type' , 'currency' , 'game']); + let contentDict = Dictionary.empty(Dictionary.Keys.Buffer(32), OnChainString()); + + for (let contentKey in content.data) { + if(keySet.has(contentKey)) { + contentDict.set( + sha256_sync(contentKey), + content.data[contentKey as OnChainContentData]! + ); + } + } + return beginCell().storeUint(0, 8).storeDict(contentDict).endCell(); +} + +export function royaltyParamsToCell(royalty: RoyaltyParameters): Cell { + return beginCell() + .storeUint(royalty.royalty_factor, 16) + .storeUint(royalty.royalty_base, 16) + .storeAddress(royalty.address) + .endCell(); +} + +export function auctionConfigToCell(config: AuctionParameters) { + return beginCell() + .storeAddress(config.benificiary) + .storeCoins(config.min_bid) + .storeCoins(config.max_bid) + .storeUint(config.min_bid_step, 8) + .storeUint(config.min_extend_time, 32) + .storeUint(config.duration, 32) + .endCell(); +} + +export function itemRestrictionsToCell(restrictions: ItemRestrictions) { + const forceSender = Boolean(restrictions.force_sender); + const rewriteSender = Boolean(restrictions.rewrite_sender); + + const restBuilder = beginCell().storeBit(forceSender); + + if(forceSender) { + restBuilder.storeAddress(restrictions.force_sender); + } + + restBuilder.storeBit(rewriteSender); + + if(rewriteSender) { + restBuilder.storeAddress(restrictions.rewrite_sender); + } + + return restBuilder.endCell(); +} + +export function collectionConfigToCell(config: NftCollectionConfig): Cell { + return beginCell() + .storeUint(0, 1) + .storeUint(config.subwallet_id, 32) + .storeBuffer(config.public_key, 32) + .storeRef(config.content instanceof Cell ? config.content : nftContentToCell(config.content)) + .storeRef(config.item_code) + .store(storeTextRef(config.full_domain)) + .storeRef(royaltyParamsToCell(config.royalty)) + .endCell(); +} + +/* +export function BathDeployValue() : DictionaryValue { + return { + parse: (src) => { + const nftContent = src.loadRef().beginParse(); + return { + forwardAmount: src.loadCoins(), + owner: nftContent.loadAddress(), + content: nftContent.loadRef() + } + }, + serialize: (src, builder) => { + builder.storeCoins(src.forwardAmount) + builder.storeRef(beginCell().storeAddress(src.owner).storeRef(src.content instanceof Cell ? src.content : nftContentToCell(src.content)).endCell()) + } + } +} +*/ + +export class NftCollection implements Contract { + + constructor(readonly address: Address, readonly init?: { code: Cell; data: Cell }) {} + + static createFromAddress(address: Address) { + return new NftCollection(address); + } + + static createFromConfig(config: NftCollectionConfig, code: Cell, workchain = 0) { + const data = collectionConfigToCell(config); + const init = { code, data }; + return new NftCollection(contractAddress(workchain, init), init); + } + + async sendDeploy(provider: ContractProvider, via: Sender, value: bigint) { + await provider.external( + beginCell().endCell() + ); + } + + static newItemMessage(item: NewNftItem, signature_params: SignatureParams, queryId: number | bigint = 0) { + const dataCell = beginCell() + .storeUint(signature_params.subwallet_id, 32) + .storeUint(signature_params.valid_since, 32) + .storeUint(signature_params.valid_till, 32) + .store(storeText(item.token_name)) + .storeRef(item.content instanceof Cell ? item.content : nftContentToCell(item.content)) + .storeRef(auctionConfigToCell(item.actuion_config)) + .storeMaybeRef(item.royalty instanceof Cell ? item.royalty : item.royalty ? royaltyParamsToCell(item.royalty) : null) + .storeMaybeRef(item.restrictions ? itemRestrictionsToCell(item.restrictions) : null) + .endCell(); + + const signedCell = signDataHash(dataCell, signature_params.privateKey); + + return beginCell() + .storeUint(Op.telemint_msg_deploy_v2, 32) + .storeSlice(signedCell.beginParse()) + .endCell(); + } + + async sendDeployItem(provider: ContractProvider, via: Sender, item: NewNftItem, signature_params: SignatureParams, value: bigint = toNano('0.1'), queryId: number | bigint = 0) { + await provider.internal(via,{ + value, + body: NftCollection.newItemMessage(item, signature_params), + sendMode: SendMode.PAY_GAS_SEPARATELY + }); + } + + static changeOwnerMessage(newOwner: Address, queryId: number | bigint = 0) { + return beginCell() + .storeUint(Op.change_owner, 32) + .storeUint(queryId, 64) + .storeAddress(newOwner) + .endCell(); + } + async sendChangeOwner(provider: ContractProvider, via: Sender, newOwner: Address, value: bigint = toNano('0.05'), queryId: number | bigint = 0) { + await provider.internal(via,{ + value, + body: NftCollection.changeOwnerMessage(newOwner, queryId), + sendMode: SendMode.PAY_GAS_SEPARATELY + }); + } + + /* + static batchDeployMessage(batchItems: Dictionary, queryId: bigint | number = 0) { + return beginCell() + .storeUint(Op.batch_deploy_item, 32) + .storeUint(queryId, 64) + .storeDict(batchItems) + .endCell(); + } + async sendDeployBatch(provider: ContractProvider, via: Sender, items: {item: NewNftItem, index: number | bigint, forwardAmount: bigint}[], value: bigint, queryId: bigint | number = 0) { + let batchDictionary = Dictionary.empty(Dictionary.Keys.BigUint(64), BathDeployValue()); + for(let nftItem of items) { + batchDictionary.set(BigInt(nftItem.index), {forwardAmount: nftItem.forwardAmount, ...nftItem.item}); + } + + await provider.internal(via,{ + value, + body: NftCollection.batchDeployMessage(batchDictionary, queryId), + sendMode: SendMode.PAY_GAS_SEPARATELY + }); + } + */ + + static royaltyParamsMessage(queryId: bigint | number = 0) { + return beginCell() + .storeUint(Op.get_royalty_params, 32) + .storeUint(queryId, 64) + .endCell(); + } + async sendGetRoyaltyParams(provider: ContractProvider, via: Sender, value: bigint = toNano('0.05'), queryId: bigint | number = 0) { + await provider.internal(via, { + value, + body: NftCollection.royaltyParamsMessage(queryId), + sendMode: SendMode.PAY_GAS_SEPARATELY, + }); + } + + async getNftAddressByIndex(provider: ContractProvider, idx: number | bigint) { + const { stack } = await provider.get('get_nft_address_by_index', [{type: 'int', value: BigInt(idx)}]); + return stack.readAddress(); + } + + async getCollectionData(provider: ContractProvider) { + const { stack } = await provider.get('get_collection_data', []); + + return { + nextItemIndex : stack.readNumber(), + collectionContent: stack.readCell(), + owner: stack.readAddressOpt() + }; + } + + async getNftContent(provider: ContractProvider, index: number | bigint, content: Cell) { + + const { stack } = await provider.get('get_nft_content', [{ + type: 'int', + value: BigInt(index) + }, + { + type: 'cell', + cell: content + }]); + + return stack.readCell(); + } +} diff --git a/wrappers/NftConstants.ts b/wrappers/NftConstants.ts new file mode 100644 index 0000000..184e54c --- /dev/null +++ b/wrappers/NftConstants.ts @@ -0,0 +1,64 @@ +export abstract class Op { + static fill_up = 0x370fec51; + static outbid_notification = 0x557cea20; + static change_dns_record = 0x4eb1f0f9; + static dns_balance_release = 0x4ed14b65; + + static telemint_msg_deploy = 0x4637289a; + static telemint_msg_deploy_v2 = 0x4637289b; + + static teleitem_msg_deploy = 0x299a3e15; + static teleitem_start_auction = 0x487a8e81; + static teleitem_cancel_auction = 0x371638ae; + static teleitem_bid_info = 0x38127de1; + static teleitem_return_bid = 0xa43227e1; + static teleitem_ok = 0xa37a0983; + + static nft_cmd_transfer = 0x5fcc3d14; + static nft_cmd_get_static_data = 0x2fcb26a2; + static nft_cmd_edit_content = 0x1a0b9d51; + static nft_answer_ownership_assigned = 0x05138d91; + static nft_answer_excesses = 0xd53276db; + + + static transfer = 0x5fcc3d14; + static ownership_assigned = 0x05138d91; + static excesses = 0xd53276db; + static get_static_data = 0x2fcb26a2; + static report_static_data = 0x8b771735; + static get_royalty_params = 0x693d3950; + static report_royalty_params = 0xa8cb00ad; + + static deploy_item = 1; + static batch_deploy_item = 2; + static change_owner = 3; +} + +export abstract class Errors { + static invalid_length = 201; + static invalid_signature = 202; + static wrong_subwallet_id = 203; + static not_yet_valid_signature = 204; + static expired_signature = 205; + static not_enough_funds = 206; + static wrong_topup_comment = 207; + static unknown_op = 208; + static uninited = 210; + static too_small_stake = 211; + static expected_onchain_content = 212; + static forbidden_not_deploy = 213; + static forbidden_not_stake = 214; + static forbidden_topup = 215; + static forbidden_transfer = 216; + static forbidden_change_dns = 217; + static forbidden_touch = 218; + static no_auction = 219; + static forbidden_auction = 220; + static already_has_stakes = 221; + static auction_already_started = 222; + static invalid_auction_config = 223; + static invalid_sender_address = 224; + static incorrect_workchain = 333; + static no_first_zero_byte = 413; + static bad_subdomain_length = 70; +} diff --git a/wrappers/NftItem.compile.ts b/wrappers/NftItem.compile.ts new file mode 100644 index 0000000..c6c559e --- /dev/null +++ b/wrappers/NftItem.compile.ts @@ -0,0 +1,6 @@ +import { CompilerConfig } from '@ton/blueprint'; + +export const compile: CompilerConfig = { + lang: 'func', + targets: ['func/stdlib.fc', 'func/common.fc', 'func/nft-item-no-dns-cheap.fc'] +} diff --git a/wrappers/NftItem.ts b/wrappers/NftItem.ts new file mode 100644 index 0000000..67b182a --- /dev/null +++ b/wrappers/NftItem.ts @@ -0,0 +1,161 @@ +import { auctionConfigToCell, AuctionParameters } from './NftCollection'; +import { Op } from './NftConstants'; +import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, Dictionary, DictionaryValue, Sender, SendMode, toNano, internal as internal_relaxed, storeMessageRelaxed, Slice } from '@ton/core'; + + +export class NftItem implements Contract { + constructor(readonly address: Address, readonly init?: { code: Cell; data: Cell }) {} + + static createFromAddress(address: Address) { + return new NftItem(address); + } + + static transferMessage(to: Address, response: Address | null, forwardAmount: bigint = 1n, forwardPayload?: Cell | Slice | null, queryId: bigint | number = 0) { + const byRef = forwardPayload instanceof Cell + const body = beginCell() + .storeUint(Op.transfer, 32) + .storeUint(queryId, 64) + .storeAddress(to) + .storeAddress(response) + .storeBit(false) // No custom payload + .storeCoins(forwardAmount) + .storeBit(byRef) + if(byRef) { + body.storeRef(forwardPayload) + } else if(forwardPayload) { + body.storeSlice(forwardPayload) + } + return body.endCell(); + } + + static royaltyParamsMessage(queryId: bigint | number = 0) { + return beginCell() + .storeUint(Op.get_royalty_params, 32) + .storeUint(queryId, 64) + .endCell(); + } + + async sendGetRoyaltyParams(provider: ContractProvider, via: Sender, value: bigint = toNano('0.05'), queryId: bigint | number = 0) { + await provider.internal(via, { + value, + body: NftItem.royaltyParamsMessage(queryId), + sendMode: SendMode.PAY_GAS_SEPARATELY, + }); + } + + async sendTransfer(provider: ContractProvider, via: Sender, to: Address, response: Address | null, forwardAmount: bigint = 1n, forwardPayload?: Cell | Slice | null, value: bigint = toNano('0.05'), queryId: bigint | number = 0) { + if(value <= forwardAmount) { + throw Error("Value has to exceed forwardAmount"); + } + await provider.internal(via, { + value, + body: NftItem.transferMessage(to, response, forwardAmount, forwardPayload, queryId), + sendMode: SendMode.PAY_GAS_SEPARATELY + }); + } + + async sendBet(provider: ContractProvider, via: Sender, value: bigint, mode: 'empty_body' | 'op_zero' = 'empty_body') { + await provider.internal(via, { + value, + body: mode == 'empty_body' ? undefined : beginCell().storeUint(0, 32).endCell(), + sendMode: SendMode.PAY_GAS_SEPARATELY + }); + } + + static startAuctionMsg( auctionConfig: AuctionParameters | Cell, queryId: bigint | number = 0) { + return beginCell() + .storeUint(Op.teleitem_start_auction, 32) + .storeUint(queryId, 64) + .storeRef(auctionConfig instanceof Cell ? auctionConfig : auctionConfigToCell(auctionConfig)) + .endCell(); + } + + async sendStartAuction(provider: ContractProvider, via: Sender, config: AuctionParameters | Cell, value: bigint = toNano('0.05'), queryId: bigint | number = 0) { + await provider.internal(via, { + value, + body: NftItem.startAuctionMsg(config, queryId), + sendMode: SendMode.PAY_GAS_SEPARATELY + }); + } + static cancelAuctionMsg(queryId: bigint | number = 0) { + return beginCell() + .storeUint(Op.teleitem_cancel_auction, 32) + .storeUint(queryId, 64) + .endCell(); + } + + async sendCancelAuction(provider: ContractProvider, via: Sender, value: bigint = toNano('0.05'), queryId: bigint | number = 0) { + await provider.internal(via, { + value, + body: NftItem.cancelAuctionMsg(queryId), + sendMode: SendMode.PAY_GAS_SEPARATELY + }); + } + async sendCheckEndExternal(provider: ContractProvider) { + await provider.external(beginCell().endCell()); + } + + static staticDataMessage(queryId: bigint | number = 0) { + return beginCell() + .storeUint(Op.get_static_data, 32) + .storeUint(queryId, 64) + .endCell(); + } + + async sendGetStaticData(provider: ContractProvider, via: Sender, value: bigint = toNano('0.05'), queryId: bigint | number = 0) { + await provider.internal(via, { + value, + body: NftItem.staticDataMessage(queryId), + sendMode: SendMode.PAY_GAS_SEPARATELY, + }); + } + + async getNftData(provider: ContractProvider) { + const { stack } = await provider.get('get_nft_data', []); + + return { + isInit: stack.readBoolean(), + index: stack.readBigNumber(), + collection: stack.readAddress(), + owner: stack.readAddressOpt(), + content: stack.readCellOpt() + } + } + + async getTokenName(provider: ContractProvider) { + const { stack } = await provider.get('get_telemint_token_name', []); + return stack.readString(); + } + async getAuctionState(provider: ContractProvider) { + const { stack } = await provider.get('get_telemint_auction_state', []); + + return { + bidder_address: stack.readAddressOpt(), + bid: stack.readBigNumber(), + bid_ts: stack.readNumber(), + min_bid: stack.readBigNumber(), + end_time: stack.readNumber() + } + } + async getAuctionConfig(provider: ContractProvider) { + const { stack } = await provider.get('get_telemint_auction_config', []); + + return { + benificiary: stack.readAddressOpt(), + initial_bid: stack.readBigNumber(), + max_bid: stack.readBigNumber(), + min_bid_step: stack.readBigNumber(), + extend_time: stack.readNumber(), + duration: stack.readNumber() + } + } + async getRoyaltyParams(provider: ContractProvider) { + const { stack } = await provider.get('royalty_params', []); + + return { + factor: stack.readBigNumber(), + base: stack.readBigNumber(), + royalty_dst: stack.readAddress() + } + } +} diff --git a/wrappers/ui-utils.ts b/wrappers/ui-utils.ts new file mode 100644 index 0000000..0f26b7a --- /dev/null +++ b/wrappers/ui-utils.ts @@ -0,0 +1,163 @@ +import { sleep, NetworkProvider, UIProvider} from '@ton/blueprint'; +import { Address, beginCell, Builder, Cell, Dictionary, DictionaryValue, Slice } from "@ton/core"; +import { sha256 } from 'ton-crypto'; + +export const defaultJettonKeys = ["uri", "name", "description", "image", "image_data", "symbol", "decimals", "amount_style"]; +export const defaultNftKeys = ["uri", "name", "description", "image", "image_data"]; + +export const promptBool = async (prompt:string, options:[string, string], ui:UIProvider, choice: boolean = false) => { + let yes = false; + let no = false; + let opts = options.map(o => o.toLowerCase()); + + do { + let res = (choice ? await ui.choose(prompt, options, (c: string) => c) : await ui.input(`${prompt}(${options[0]}/${options[1]})`)).toLowerCase(); + yes = res == opts[0] + if(!yes) + no = res == opts[1]; + } while(!(yes || no)); + + return yes; +} + +export const promptAddress = async (prompt:string, provider:UIProvider, fallback?:Address) => { + let promptFinal = fallback ? prompt.replace(/:$/,'') + `(default:${fallback}):` : prompt ; + do { + let testAddr = (await provider.input(promptFinal)).replace(/^\s+|\s+$/g,''); + try{ + return testAddr == "" && fallback ? fallback : Address.parse(testAddr); + } + catch(e) { + provider.write(testAddr + " is not valid!\n"); + prompt = "Please try again:"; + } + } while(true); + +}; + +export const promptAmount = async (prompt:string, provider:UIProvider) => { + let resAmount:number; + do { + let inputAmount = await provider.input(prompt); + resAmount = Number(inputAmount); + if(isNaN(resAmount)) { + provider.write("Failed to convert " + inputAmount + " to float number"); + } + else { + return resAmount.toFixed(9); + } + } while(true); +} + +export const getLastBlock = async (provider: NetworkProvider) => { + return (await provider.api().getLastBlock()).last.seqno; +} +export const getAccountLastTx = async (provider: NetworkProvider, address: Address) => { + const res = await provider.api().getAccountLite(await getLastBlock(provider), address); + if(res.account.last == null) + throw(Error("Contract is not active")); + return res.account.last.lt; +} +export const waitForTransaction = async (provider:NetworkProvider, address:Address, curTx:string | null, maxRetry:number, interval:number=1000) => { + let done = false; + let count = 0; + const ui = provider.ui(); + + do { + const lastBlock = await getLastBlock(provider); + ui.write(`Awaiting transaction completion (${++count}/${maxRetry})`); + await sleep(interval); + const curState = await provider.api().getAccountLite(lastBlock, address); + if(curState.account.last !== null){ + done = curState.account.last.lt !== curTx; + } + } while(!done && count < maxRetry); + return done; +} + +const keysToHashMap = async (keys: string[]) => { + let keyMap: {[key: string]: bigint} = {}; + for (let i = 0; i < keys.length; i++) { + keyMap[keys[i]] = BigInt("0x" + (await sha256(keys[i])).toString('hex')); + } +} + +const contentValue: DictionaryValue = { + serialize: (src: string, builder:Builder) => { + builder.storeRef(beginCell().storeUint(0, 8).storeStringTail(src).endCell()); + }, + parse: (src: Slice) => { + const sc = src.loadRef().beginParse(); + const prefix = sc.loadUint(8); + if(prefix == 0) { + return sc.loadStringTail(); + } + else if(prefix == 1) { + // Not really tested, but feels like it should work + const chunkDict = Dictionary.loadDirect(Dictionary.Keys.Uint(32), Dictionary.Values.Cell(), sc); + return chunkDict.values().map(x => x.beginParse().loadStringTail()).join(''); + } + else { + throw(Error(`Prefix ${prefix} is not supported yet`)); + } + } +}; +export const displayContentCell = async (content:Cell, ui:UIProvider, jetton:boolean = true, additional?: string[]) => { + const cs = content.beginParse(); + const contentType = cs.loadUint(8); + if(contentType == 1) { + const noData = cs.remainingBits == 0; + if(noData && cs.remainingRefs == 0) { + ui.write("No data in content cell!\n"); + } + else { + const contentUrl = noData ? cs.loadStringRefTail() : cs.loadStringTail(); + ui.write(`Content metadata url:${contentUrl}\n`); + } + } + else if(contentType == 0) { + let contentKeys: string[]; + const hasAdditional = additional !== undefined && additional.length > 0; + const contentDict = Dictionary.load(Dictionary.Keys.BigUint(256), contentValue, cs); + const contentMap : {[key: string]: string} = {}; + + if(jetton) { + contentKeys = hasAdditional ? [...defaultJettonKeys, ...additional] : defaultJettonKeys; + } + else { + contentKeys = hasAdditional ? [...defaultNftKeys, ...additional] : defaultNftKeys; + } + for (const name of contentKeys) { + // I know we should pre-compute hashed keys for known values... just not today. + const dictKey = BigInt("0x" + (await sha256(name)).toString('hex')) + const dictValue = contentDict.get(dictKey); + if(dictValue !== undefined) { + contentMap[name] = dictValue; + } + } + ui.write(`Content:${JSON.stringify(contentMap,null, 2)}`); + } + else { + ui.write(`Unknown content format indicator:${contentType}\n`); + } +} + +export const promptUrl = async(prompt:string, ui:UIProvider) => { + let retry = false; + let input = ""; + let res = ""; + + do { + input = await ui.input(prompt); + try{ + let testUrl = new URL(input); + res = testUrl.toString(); + retry = false; + } + catch(e) { + ui.write(input + " doesn't look like a valid url:\n" + e); + retry = !(await promptBool('Use anyway?(y/n)', ['y', 'n'], ui)); + } + } while(retry); + return input; +}