diff --git a/integration_test/chain/tezos/TezosNodeReader.spec.ts b/integration_test/chain/tezos/TezosNodeReader.spec.ts index 8e537825..4c9fcc35 100644 --- a/integration_test/chain/tezos/TezosNodeReader.spec.ts +++ b/integration_test/chain/tezos/TezosNodeReader.spec.ts @@ -60,4 +60,30 @@ describe('TezosNodeReader integration test suite', () => { expect(result.header.level).to.be.greaterThan(1); }); + + it('Gets delegate for a delegated implicit account', async () => { + const result = await TezosNodeReader.getDelegate(tezosServer, "tz1PnUd6R31MnjEE8VhfZhZdbGc1hrWQvjnK"); + expect(result).to.not.be.undefined + }); + + it('Gets delegate for a delegated smart contract', async () => { + const result = await TezosNodeReader.getDelegate(tezosServer, "KT1DRJPyaDTgeXrM2cgQdp5siNF8PP5RLS7T"); + expect(result).to.not.be.undefined + }); + + it('Gets delegate for a baker as itself', async () => { + const baker = "tz1Na5QB98cDA3BC1SQU4w3iiWGVGktU14LE" + const result = await TezosNodeReader.getDelegate(tezosServer, baker); + expect(result).to.be.equal(baker) + }); + + it('Returns undefined for undelegated implicit account', async () => { + const result = await TezosNodeReader.getDelegate(tezosServer, "tz1fzHtv2UqtXzFUBHuBPh2xXVv5Pv5MTh5Z"); + expect(result).to.be.undefined + }); + + it('Returns undefined for undelegated smart contract', async () => { + const result = await TezosNodeReader.getDelegate(tezosServer, "KT1BipUDR93YFCJjVpghzVFS8N45Lkgigfqs"); + expect(result).to.be.undefined + }); }); diff --git a/package-lock.json b/package-lock.json index aac67cba..b0dac061 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "conseiljs", - "version": "5.0.5", + "version": "5.0.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 2bd31875..d6d085f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "conseiljs", - "version": "5.0.5", + "version": "5.0.6", "description": "Client-side library for Tezos dApp development.", "browser": "dist/index-web.js", "main": "dist/index.js", diff --git a/src/chain/tezos/TezosMessageUtil.ts b/src/chain/tezos/TezosMessageUtil.ts index 3acbcc74..6ac3d10a 100644 --- a/src/chain/tezos/TezosMessageUtil.ts +++ b/src/chain/tezos/TezosMessageUtil.ts @@ -552,4 +552,48 @@ export namespace TezosMessageUtils { return n.toJSNumber(); } + + /** + * Calculate the address of a contract that was originated. + * + * @param operationHash The operation group hash. + * @param index The index of the origination operation in the operation group. + */ + export function calculateContractAddress(operationHash: string, index: number): string { + // Decode and slice two byte prefix off operation hash. + const decoded: Uint8Array = base58check.decode(operationHash).slice(2) + + // Merge the decoded buffer with the operation prefix. + let decodedAndOperationPrefix: Array = [] + for (let i = 0; i < decoded.length; i++) { + decodedAndOperationPrefix.push(decoded[i]) + } + decodedAndOperationPrefix = decodedAndOperationPrefix.concat([ + (index & 0xff000000) >> 24, + (index & 0x00ff0000) >> 16, + (index & 0x0000ff00) >> 8, + index & 0x000000ff, + ]) + + // Hash and encode. + const hash = blakejs.blake2b(new Uint8Array(decodedAndOperationPrefix), null, 20) + const smartContractAddressPrefix = new Uint8Array([2, 90, 121]) // KT1 + const prefixedBytes = mergeBytes(smartContractAddressPrefix, hash) + return base58check.encode(prefixedBytes) + } + + /** + * Helper to merge two Uint8Arrays. + * + * @param a The first array. + * @param b The second array. + * @returns A new array that contains b appended to the end of a. + */ + function mergeBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const merged = new Uint8Array(a.length + b.length) + merged.set(a) + merged.set(b, a.length) + + return merged + } } diff --git a/src/chain/tezos/TezosNodeReader.ts b/src/chain/tezos/TezosNodeReader.ts index 0e929f08..140d5c9e 100644 --- a/src/chain/tezos/TezosNodeReader.ts +++ b/src/chain/tezos/TezosNodeReader.ts @@ -37,6 +37,18 @@ export namespace TezosNodeReader { }); } + /** + * Gets the delegate for a smart contract or an implicit account. + * + * @param {string} server Tezos node to query + * @param {string} accountHash The smart contract address or implicit account to query. + * @returns The address of the delegate, or undefined if there was no delegate set. + */ + export async function getDelegate(server: string, accountHash: string): Promise { + const contractData = await getAccountForBlock(server, 'head', accountHash) + return contractData.delegate; + } + /** * Gets a block for a given hash. * @@ -46,7 +58,7 @@ export namespace TezosNodeReader { * @returns {Promise} Block */ export function getBlock(server: string, hash: string = 'head', chainid: string = 'main'): Promise { - return performGetRequest(server, `chains/${chainid}/blocks/${hash}`).then(json => { return json }); + return performGetRequest(server, `chains/${chainid}/blocks/${hash}`).then(json => { return json }); } /** @@ -70,7 +82,7 @@ export namespace TezosNodeReader { if (offset <= 0) { return getBlock(server); } const head = await getBlock(server); - return performGetRequest(server, `chains/${chainid}/blocks/${Number(head['header']['level']) - offset}`).then(json => { return json }); + return performGetRequest(server, `chains/${chainid}/blocks/${Number(head['header']['level']) - offset}`).then(json => { return json }); } /** @@ -84,7 +96,7 @@ export namespace TezosNodeReader { */ export function getAccountForBlock(server: string, blockHash: string, accountHash: string, chainid: string = 'main'): Promise { return performGetRequest(server, `chains/${chainid}/blocks/${blockHash}/context/contracts/${accountHash}`) - .then(json => json); + .then(json => json); } /** @@ -111,7 +123,7 @@ export namespace TezosNodeReader { */ export async function getSpendableBalanceForAccount(server: string, accountHash: string, chainid: string = 'main'): Promise { const account = await performGetRequest(server, `chains/${chainid}/blocks/head/context/contracts/${accountHash}`) // TODO: get /balance - .then(json => json); + .then(json => json); return parseInt(account.balance.toString(), 10); } @@ -130,7 +142,7 @@ export namespace TezosNodeReader { } /** - * Indicates whether an account is implicit and empty. If true, transaction will burn 0.257tz. + * Indicates whether an account is implicit and empty. If true, transaction will burn 0.06425tz. * * @param {string} server Tezos node to connect to * @param {string} accountHash Account address diff --git a/src/chain/tezos/TezosNodeWriter.ts b/src/chain/tezos/TezosNodeWriter.ts index 872681e1..e3b22920 100644 --- a/src/chain/tezos/TezosNodeWriter.ts +++ b/src/chain/tezos/TezosNodeWriter.ts @@ -50,11 +50,10 @@ export namespace TezosNodeWriter { */ // TODO: move to an appropriate place export function forgeOperations(branch: string, operations: TezosP2PMessageTypes.Operation[]): string { - log.debug('TezosNodeWriter.forgeOperations:'); - log.debug(JSON.stringify(operations)); + log.debug(`TezosNodeWriter.forgeOperations: ${JSON.stringify(operations)}`); let encoded = TezosMessageUtils.writeBranch(branch); operations.forEach(m => encoded += TezosMessageCodec.encodeOperation(m)); - + log.debug(`TezosNodeWriter.forgeOperations: ${encoded}`); return encoded; } @@ -171,14 +170,16 @@ export namespace TezosNodeWriter { */ export async function sendOperation(server: string, operations: TezosP2PMessageTypes.Operation[], signer: Signer, offset: number = 54): Promise { const blockHead = await TezosNodeReader.getBlockAtOffset(server, offset); - const forgedOperationGroup = forgeOperations(blockHead.hash, operations); + const blockHash = blockHead.hash.slice(0, 51); // consider throwing an error instead + + const forgedOperationGroup = forgeOperations(blockHash, operations); const opSignature = await signer.signOperation(Buffer.from(TezosConstants.OperationGroupWatermark + forgedOperationGroup, 'hex')); const signedOpGroup = Buffer.concat([Buffer.from(forgedOperationGroup, 'hex'), opSignature]); const base58signature = TezosMessageUtils.readSignatureWithHint(opSignature, signer.getSignerCurve()); const opPair = { bytes: signedOpGroup, signature: base58signature }; - const appliedOp = await preapplyOperation(server, blockHead.hash, blockHead.protocol, operations, opPair); + const appliedOp = await preapplyOperation(server, blockHash, blockHead.protocol, operations, opPair); const injectedOperation = await injectOperation(server, opPair); return { results: appliedOp[0], operationGroupID: injectedOperation }; // TODO @@ -649,12 +650,8 @@ export namespace TezosNodeWriter { storageCost: resources.storageCost + fixedOriginationStorageCost } } - /** - * Dry run the given operation and return consumed resources. - * - * Note: Estimating an operation on an unrevealed account is not supported and will fail. Remember to prepend - * the Reveal operation if required. + * Dry run the given operation * * @param {string} server Tezos node to connect to * @param {string} chainid The chain ID to apply the operation on. @@ -666,16 +663,7 @@ export namespace TezosNodeWriter { chainid: string, ...operations: TezosP2PMessageTypes.Operation[] ): Promise<{ gas: number, storageCost: number }> { - const fake_signature = 'edsigu6xFLH2NpJ1VcYshpjW99Yc1TAL1m2XBqJyXrxcZQgBMo8sszw2zm626yjpA3pWMhjpsahLrWdmvX9cqhd4ZEUchuBuFYy'; - const fake_chainid = 'NetXdQprcVkpaWU'; - const fake_branch = 'BL94i2ShahPx3BoNs6tJdXDdGeoJ9ukwujUA2P8WJwULYNdimmq'; - - const response = await performPostRequest(server, `chains/${chainid}/blocks/head/helpers/scripts/run_operation`, { chain_id: fake_chainid, operation: { branch: fake_branch, contents: operations, signature: fake_signature } }); - const responseText = await response.text(); - - parseRPCError(responseText); - - const responseJSON = JSON.parse(responseText); + const responseJSON = dryRunOperation(server, chainid, ...operations); let gas = 0; let storageCost = 0; @@ -701,6 +689,36 @@ export namespace TezosNodeWriter { return { gas, storageCost }; } + /** + * Dry run the given operation and return consumed resources. + * + * Note: Estimating an operation on an unrevealed account is not supported and will fail. Remember to prepend + * the Reveal operation if required. + + * @param {string} server Tezos node to connect to + * @param {string} chainid The chain ID to apply the operation on. + * @param {TezosP2PMessageTypes.Operation} operations A set of operations to update. + * @returns {Promise} JSON-encoded response + */ + export async function dryRunOperation( + server: string, + chainid: string, + ...operations: TezosP2PMessageTypes.Operation[] + ): Promise { + const fake_signature = 'edsigu6xFLH2NpJ1VcYshpjW99Yc1TAL1m2XBqJyXrxcZQgBMo8sszw2zm626yjpA3pWMhjpsahLrWdmvX9cqhd4ZEUchuBuFYy'; + const fake_chainid = 'NetXdQprcVkpaWU'; + const fake_branch = 'BL94i2ShahPx3BoNs6tJdXDdGeoJ9ukwujUA2P8WJwULYNdimmq'; + + const response = await performPostRequest(server, `chains/${chainid}/blocks/head/helpers/scripts/run_operation`, { chain_id: fake_chainid, operation: { branch: fake_branch, contents: operations, signature: fake_signature } }); + const responseText = await response.text(); + + parseRPCError(responseText); + + const responseJSON = JSON.parse(responseText); + + return responseJSON; + } + /** * This function checks if the server response contains an error. There are multiple formats for errors coming * back from the server, this method attempts to normalized them for downstream parsing. diff --git a/src/chain/tezos/contracts/DexterPoolHelper.ts b/src/chain/tezos/contracts/DexterPoolHelper.ts new file mode 100644 index 00000000..4152710f --- /dev/null +++ b/src/chain/tezos/contracts/DexterPoolHelper.ts @@ -0,0 +1,330 @@ +import bigInt from 'big-integer'; +import { JSONPath } from 'jsonpath-plus'; + +import { KeyStore, Signer } from '../../../types/ExternalInterfaces'; +import * as TezosTypes from '../../../types/tezos/TezosChainTypes'; +import { TezosNodeReader } from '../TezosNodeReader'; +import { TezosNodeWriter } from '../TezosNodeWriter'; +import { TezosContractUtils } from './TezosContractUtils'; +import { TezosMessageUtils } from '../TezosMessageUtil'; + +interface DexterPoolSimpleStorage { + balanceMap: number; + administrator: string; + token: string; + tokenBalance: number; + xtzBalance: number; + selfIsUpdatingTokenPool: boolean; + freeze_baker: boolean; + lqt_total: number; +} + +/** + * Contract wrapper for http://dexter.exchange/ pool contracts, intended for use on mainnet with KT1Puc9St8wdNoGtLiD2WXaHbWU7styaxYhD (usdtz) and KT1DrJV8vhkdLEj76h1H9Q4irZDqAkMPo1Qf (tzbtc). + * + * Tested on carthagenet with KT1RtNatBzmk2AvJKm9Mx6b55GcQejJneK7t. + * + * Based on integration documentation provided at https://gitlab.com/camlcase-dev/dexter-integration. + */ +export namespace DexterPoolHelper { + const DexterPoolLiquidityOperationGasLimit = 500_000; + const DexterPoolLiquidityOperationStorageLimit = 5_000; + const DexterPoolExchangeOperationGasLimit = 500_000; + const DexterPoolExchangeOperationStorageLimit = 5_000; + const ExchangeMultiplier = 997; + + /** + * Gets the contract code at the specified address at the head block and compares it to the known hash of the code. This function processes Micheline format contracts. + * + * @param server Destination Tezos node. + * @param address Contract address to query. + */ + export async function verifyDestination(server: string, address: string): Promise { + return TezosContractUtils.verifyDestination(server, address, 'a72954311c48dcc28279590d82870611'); + } + + /** + * In contrast to verifyDestination, this function uses compares Michelson hashes. + * + * @param script + */ + export function verifyScript(script: string): boolean { + return TezosContractUtils.verifyScript(script, 'yyy'); + } + + /** + * Queries the tezos node directly for basic contract storage. + * + * @param server Destination Tezos node. + * @param address Contract address to query. + * @returns + * - balanceMap: bigmap index of the liquidity balance map + * - administrator: Contract administrator + * - token: Token address this pool services + * - tokenBalance: Total token balance held in the pool + * - xtzBalance: Total XTZ balance held in the pool + * - selfIsUpdatingTokenPool + * - freeze_baker + * - lqt_total Liquidity token balance + */ + export async function getSimpleStorage(server: string, address: string): Promise { + const storageResult = await TezosNodeReader.getContractStorage(server, address); + + return { + balanceMap: Number(JSONPath({ path: '$.args[0].int', json: storageResult })[0]), + administrator: JSONPath({ path: '$.args[1].args[1].args[0].args[0].string', json: storageResult })[0], + token: JSONPath({ path: '$.args[1].args[1].args[0].args[1].string', json: storageResult })[0], + tokenBalance: Number(JSONPath({ path: '$.args[1].args[1].args[1].args[1].int', json: storageResult })[0]), + xtzBalance: Number(JSONPath({ path: '$.args[1].args[1].args[1].args[0].int', json: storageResult })[0]), + selfIsUpdatingTokenPool: (JSONPath({ path: '$.args[1].args[0].args[0].prim', json: storageResult })[0]).toString().toLowerCase().startsWith('t'), + freeze_baker: (JSONPath({ path: '$.args[1].args[0].args[1].args[0].prim', json: storageResult })[0]).toString().toLowerCase().startsWith('t'), + lqt_total: Number(JSONPath({ path: '$.args[1].args[0].args[1].args[1].int', json: storageResult })[0]) + }; + } + + /** + * Queries the Tezos node for the liquidity balance of the requested account. + * + * @param server Destination Tezos node. + * @param mapid bigmap id of the pool to query. + * @param account Account to query. + */ + export async function getAccountBalance(server: string, mapid: number, account: string): Promise { + try { + const packedKey = TezosMessageUtils.encodeBigMapKey(Buffer.from(TezosMessageUtils.writePackedData(account, 'address'), 'hex')); + const mapResult = await TezosNodeReader.getValueForBigMapKey(server, mapid, packedKey); + + if (mapResult === undefined) { throw new Error(`Map ${mapid} does not contain a record for ${account}`); } + + const jsonresult = JSONPath({ path: '$.args[0].int', json: mapResult }); + return Number(jsonresult[0]); + } catch { + return 0; + } + } + + /** + * Queries the Tezos node for the liquidity balance approval for a given spender on the requested account. + * + * @param server Destination Tezos node. + * @param mapid bigmap id of the pool to query. + * @param account Account to query. + * @param spender Spender to get balance for. + */ + export async function getAccountAllowance(server: string, mapid: number, account: string, spender: string) { + const packedKey = TezosMessageUtils.encodeBigMapKey(Buffer.from(TezosMessageUtils.writePackedData(account, 'address'), 'hex')); + const mapResult = await TezosNodeReader.getValueForBigMapKey(server, mapid, packedKey); + + if (mapResult === undefined) { throw new Error(`Map ${mapid} does not contain a record for ${account}/${spender}`); } + + let allowances = new Map(); + JSONPath({ path: '$.args[1][*].args', json: mapResult }).forEach(v => allowances[v[0]['string']] = Number(v[1]['int'])); + + return allowances[spender] || 0; + } + + /** + * Adds liquidity to the pool using the `addLiquidity` entry point of the contract pool. Deposits both XTZ and a matching token balance. + * + * @param server Destination Tezos node. + * @param signer + * @param keyStore + * @param contract + * @param fee + * @param liquidityAmount Expected liquidity amount + * @param xtzAmount + * @param tokenAmount + * @param expiration Request expiration date. + */ + export async function addLiquidity(server: string, signer: Signer, keyStore: KeyStore, contract: string, fee: number, liquidityAmount: number, xtzAmount: number, tokenAmount: number, expiration: Date): Promise { + //(pair (address :owner) (nat :minLqtMinted)) (pair (nat :maxTokensDeposited) (timestamp :deadline))) + const parameters = `(Pair (Pair "${keyStore.publicKeyHash}" ${liquidityAmount}) (Pair ${tokenAmount} "${expiration.toISOString()}"))`; + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation(server, signer, keyStore, contract, xtzAmount, fee, DexterPoolLiquidityOperationStorageLimit, DexterPoolLiquidityOperationGasLimit, 'addLiquidity', parameters, TezosTypes.TezosParameterFormat.Michelson); + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * Removes liquidity from the pool using the `removeLiquidity` entry point. + * + * @param server Destination Tezos node. + * @param signer + * @param keyStore + * @param contract + * @param fee + * @param balance Liquidity amount to withdraw. + * @param xtzBalance Expected XTZ balance + * @param tokenBalance Expected token balance + * @param expiration Request expiration date. + */ + export async function removeLiquidity(server: string, signer: Signer, keyStore: KeyStore, contract: string, fee: number, balance: number, xtzBalance: number, tokenBalance: number, expiration: Date): Promise { + //(pair (address :owner) (pair (address :to) (nat :lqtBurned))) (pair (mutez :minXtzWithdrawn) (pair (nat :minTokensWithdrawn) (timestamp :deadline))) + const parameters = `(Pair (Pair "${keyStore.publicKeyHash}" (Pair "${keyStore.publicKeyHash}" ${balance})) (Pair ${xtzBalance} (Pair ${tokenBalance} "${expiration.toISOString()}")))`; + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation(server, signer, keyStore, contract, 0, fee, DexterPoolLiquidityOperationStorageLimit, DexterPoolLiquidityOperationGasLimit, 'removeLiquidity', parameters, TezosTypes.TezosParameterFormat.Michelson); + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * + * Convert an XTZz balance into an token balance. + * @param server + * @param signer + * @param keyStore + * @param contract + * @param fee + * @param xtzAmount + * @param tokenAmount + * @param expiration Request expiration date. + */ + export async function xtzToToken(server: string, signer: Signer, keyStore: KeyStore, contract: string, fee: number, xtzAmount: number, tokenAmount: number, expiration: Date): Promise { + //(pair %xtzToToken (address :to) (pair (nat :minTokensBought) (timestamp :deadline))) + const parameters = `(Pair "${keyStore.publicKeyHash}" (Pair ${tokenAmount} "${expiration.toISOString()}"))`; + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation(server, signer, keyStore, contract, xtzAmount, fee, DexterPoolExchangeOperationStorageLimit, DexterPoolExchangeOperationGasLimit, 'xtzToToken', parameters, TezosTypes.TezosParameterFormat.Michelson); + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * Convert a token balance into an XTZ balance. + * + * @param server Destination Tezos node. + * @param signer + * @param keyStore + * @param contract + * @param fee + * @param xtzAmount + * @param tokenAmount + * @param expiration Request expiration date. + */ + export async function tokenToXtz(server: string, signer: Signer, keyStore: KeyStore, contract: string, fee: number, xtzAmount: number, tokenAmount: number, expiration: Date): Promise { + //(pair %tokenToXtz (pair (address :owner) (address :to)) (pair (nat :tokensSold) (pair (mutez :minXtzBought) (timestamp :deadline)))) + const parameters = `(Pair (Pair "${keyStore.publicKeyHash}" "${keyStore.publicKeyHash}") (Pair ${tokenAmount} (Pair ${xtzAmount} "${expiration.toISOString()}")))`; + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation(server, signer, keyStore, contract, 0, fee, DexterPoolExchangeOperationStorageLimit, DexterPoolExchangeOperationGasLimit, 'tokenToXtz', parameters, TezosTypes.TezosParameterFormat.Michelson); + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * Untested function that should allow exchange of a token for a different token instead of xtz. + * + * @param server Destination Tezos node. + * @param signer + * @param keyStore + * @param contract + * @param fee + * @param otherPoolContract + * @param sellAmount + * @param buyAmount + * @param expiration Request expiration date. + */ + export async function tokenToToken(server: string, signer: Signer, keyStore: KeyStore, contract: string, fee: number, otherPoolContract: string, sellAmount: number, buyAmount: number, expiration: Date): Promise { + //(pair %tokenToToken (pair (address :outputDexterContract) (pair (nat :minTokensBought) (address :owner))) (pair (address :to) (pair (nat :tokensSold) (timestamp :deadline)))) + const parameters = `(Pair (Pair "${otherPoolContract}" (Pair ${buyAmount} "${keyStore.publicKeyHash}")) (Pair "${keyStore.publicKeyHash}" (Pair ${sellAmount} "${expiration.toISOString()}")))`; + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation(server, signer, keyStore, contract, 0, fee, DexterPoolExchangeOperationStorageLimit, 1_000_000, 'tokenToToken', parameters, TezosTypes.TezosParameterFormat.Michelson); + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * Approves a spender for a share of the liquidity balance on the given account. + * + * @param server Destination Tezos node. + * @param signer + * @param keyStore + * @param contract + * @param fee + * @param spender + * @param newAllowance + * @param currentAllowance + */ + export async function approve(server: string, signer: Signer, keyStore: KeyStore, contract: string, fee: number, spender: string, newAllowance: number, currentAllowance: number): Promise { + //(pair %approve (address :spender) (pair (nat :allowance) (nat :currentAllowance))) + const parameters = `(Pair "${spender}" (Pair ${newAllowance} ${currentAllowance}))`; + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation(server, signer, keyStore, contract, 0, fee, DexterPoolExchangeOperationStorageLimit, DexterPoolExchangeOperationGasLimit, 'approve', parameters, TezosTypes.TezosParameterFormat.Michelson); + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * Show pending applied operations against this contract in the mempool + */ + export async function previewTransactions() { + // TODO + } + + /** + * Calculate the token requirement for the proposed XTZ deposit. + * + * @param xtzDeposit XTZ amount of the proposed transaction + * @param tokenBalance Pool token balance + * @param xtzBalance Pool XTZ balance + * @return {number} Matching token balance for the proposed deposit + */ + export function calcTokenLiquidityRequirement(xtzDeposit: number, tokenBalance: number, xtzBalance: number): number { + return bigInt(xtzDeposit).multiply(bigInt(tokenBalance)).divide(bigInt(xtzBalance)).toJSNumber(); + } + + /** + * XTZ/Token exchange rate for a given XTZ trade. + * + * @param xtzAmount Proposed XTZ deposit + * @param tokenBalance Current token balance in the pool + * @param xtzBalance Current XTZ balance in the pool + */ + export function getTokenExchangeRate(xtzAmount: number, tokenBalance: number, xtzBalance: number, xtzDecimals: number = 6) { + const n = bigInt(xtzAmount).multiply(bigInt(tokenBalance)).multiply(bigInt(ExchangeMultiplier)); + const d = bigInt(xtzBalance).multiply(bigInt(1000)).add(bigInt(xtzAmount).multiply(bigInt(ExchangeMultiplier))); + + const tokenAmount = n.divide(d); + const dm = tokenAmount.divmod(bigInt(xtzAmount)); + const f = dm.remainder.multiply(bigInt(10 ** xtzDecimals)).divide(bigInt(xtzAmount)); + + return { tokenAmount: tokenAmount.toJSNumber(), rate: parseFloat(`${dm.quotient.toJSNumber()}.${f.toJSNumber()}`) }; + } + + /** + * Token/XTZ exchange rate for a given token trade. + * + * @param tokenAmount Proposed token deposit + * @param tokenBalance Current token balance in the pool + * @param xtzBalance Current XTZ balance in the pool + */ + export function getXTZExchangeRate(tokenAmount: number, tokenBalance: number, xtzBalance: number, tokenDecimals: number = 6) { + const n = bigInt(tokenAmount).multiply(bigInt(xtzBalance)).multiply(bigInt(ExchangeMultiplier)); + const d = bigInt(tokenBalance).multiply(bigInt(1000)).add(bigInt(tokenAmount).multiply(bigInt(ExchangeMultiplier))) + + const xtzAmount = n.divide(d); + const dm = xtzAmount.divmod(bigInt(tokenAmount)); + const f = dm.remainder.multiply(bigInt(10 ** tokenDecimals)).divide(bigInt(tokenAmount)); + + return { xtzAmount: xtzAmount.toJSNumber(), rate: parseFloat(`${dm.quotient.toJSNumber()}.${f.toJSNumber()}`) }; + } + + /** + * Returns the amount of liquidity tokens a particular XTZ deposit would receive. + * @param xtzDeposit + * @param liquidityBalance + * @param xtzBalance + */ + export function estimateLiquidityAmount(xtzDeposit: number, liquidityBalance: number, xtzBalance: number) { + return bigInt(xtzDeposit).multiply(bigInt(liquidityBalance)).divide(bigInt(xtzBalance)).toJSNumber(); + } + + /** + * Estimates the cost of buying 1% share of the pool in terms of XTZ + */ + export function estimateShareCost(xtzBalance: number, tokenBalance: number, liquidityBalance: number): { xtzCost: number, tokenCost: number } { + const xtzShare = bigInt(xtzBalance).divide(bigInt(99)).toJSNumber(); + const tokenShare = calcTokenLiquidityRequirement(xtzShare, tokenBalance, xtzBalance); + + // TODO: use estimateLiquidityAmount + + return { xtzCost: xtzShare, tokenCost: tokenShare }; + } +} diff --git a/src/chain/tezos/contracts/DexterTokenHelper.ts b/src/chain/tezos/contracts/DexterTokenHelper.ts deleted file mode 100644 index 7cc05c1f..00000000 --- a/src/chain/tezos/contracts/DexterTokenHelper.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as blakejs from 'blakejs'; -import { JSONPath } from 'jsonpath-plus'; - -import { TezosMessageUtils } from '../TezosMessageUtil'; -import { TezosNodeReader } from '../TezosNodeReader'; - -/** - * Awaiting contracts at https://gitlab.com/camlcase-dev/dexter - */ -export namespace DexterTokenHelper { - /** - * Gets the contract code at the specified address at the head block and compares it to the known hash of the code. - * - * @param server Destination Tezos node. - * @param address Contract address to query. - */ - export async function verifyDestination(server: string, address: string): Promise { - const contract = await TezosNodeReader.getAccountForBlock(server, 'head', address); - - if (!!!contract.script) { throw new Error(`No code found at ${address}`); } - - const k = Buffer.from(blakejs.blake2s(contract['script'].toString(), null, 16)).toString('hex'); - - if (k !== '1234') { throw new Error(`Contract at ${address} does not match the expected code hash`); } - - return true; - } - - /** - * Retrieves high level storage, including map id and token supply cap. - * - * @param server Destination Tezos node. - * @param address Contract address to query. - */ - export async function getBasicStorage(server: string, address: string) { - const storageResult = await TezosNodeReader.getContractStorage(server, address); - console.log('-----') - console.log(storageResult); - console.log('-----') - - return { - mapid: Number(JSONPath({ path: '$.args[0].int', json: storageResult })[0]), - totalSupply: Number(JSONPath({ path: '$.args[1].int', json: storageResult })[0]) - }; - } - - /** - * - * - * @param server Destination Tezos node. - * @param mapid bigmap reference to query. - * @param account Account address to query. - */ - export async function getAddressRecord(server: string, mapid: number, account: string) { - const key = TezosMessageUtils.encodeBigMapKey(Buffer.from(TezosMessageUtils.writePackedData(account, 'address'), 'hex')); - const mapResult = await TezosNodeReader.getValueForBigMapKey(server, mapid, key); - - if (!!!mapResult) { return undefined; } - - return { - allowances: JSONPath({ path: '$.args[0]', json: mapResult })[0], - balance: Number(JSONPath({ path: '$.args[1].int', json: mapResult })[0]) - }; - } - - /** - * - * @param server Destination Tezos node. - * @param manager Token manager address - * @param supply Initial token supply - */ - export async function deployContract(server: string, manager: string, supply: number) { - - } -} diff --git a/src/chain/tezos/contracts/StakerDaoTzip7.ts b/src/chain/tezos/contracts/StakerDaoTzip7.ts new file mode 100644 index 00000000..78619437 --- /dev/null +++ b/src/chain/tezos/contracts/StakerDaoTzip7.ts @@ -0,0 +1,157 @@ +import { JSONPath } from 'jsonpath-plus'; + +import { KeyStore, Signer } from '../../../types/ExternalInterfaces'; +import * as TezosTypes from '../../../types/tezos/TezosChainTypes'; +import { TezosMessageUtils } from '../TezosMessageUtil'; +import { TezosNodeReader } from '../TezosNodeReader'; +import { TezosNodeWriter } from '../TezosNodeWriter'; +import { TezosContractUtils } from './TezosContractUtils'; + +/** The expected checksum for the StakerDao Tzip 7 contract. */ +const CONTRACT_CHECKSUMS = { + token: 'd48b45bd77d2300026fe617c5ba7670e', +} + +/** The expected checksum for the StakerDao Tzip 7 script. */ +const SCRIPT_CHECKSUMS = { + // TODO(keefertaylor): Compute this checksum correctly. + token: '', +} + +export interface StakerDaoTzip7Storage { + balanceMap: number; + approvalsMap: number; + supply: number; + administrator: string; + paused: boolean; + pauseGuardian: string; + outcomeMap: number; + swapMap: number; +} + +export interface StakerDaoTzip7BalanceRecord { } +export interface StakerDaoTzip7ApprovalRecord { } +export interface StakerDaoTzip7OutcomeRecord { } +export interface StakerDaoTzip7SwapRecord { } + +/** + * Interface for a StakerDAO implementation of TZIP-7, AKA FA 1.2. + * + * @author Keefer Taylor, Staker Services Ltd + */ +export const StakerDaoTzip7 = { + /** + * Verifies that contract code for StakerDao's Tzip7 contract matches the expected code. + * + * Note: This function processes contracts in the Micheline format. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param tokenContractAddress The address of the token contract. + * @returns A boolean indicating if the code was the expected sum. + */ + verifyDestination: async function ( + nodeUrl: string, + tokenContractAddress: string + ): Promise { + return await TezosContractUtils.verifyDestination(nodeUrl, tokenContractAddress, CONTRACT_CHECKSUMS.token) + }, + + /** + * Verifies that Michelson script for StakerDao's Tzip7 contract matches the expected code. + * + * Note: This function processes scrips in Michelson format. + * + * @param tokenScript The script of the token contract. + * @returns A boolean indicating if the code was the expected sum. + */ + verifyScript: function ( + tokenScript: string, + ): boolean { + return TezosContractUtils.verifyScript(tokenScript, SCRIPT_CHECKSUMS.token) + }, + + /** + * @param server + * @param address + */ + getSimpleStorage: async function (server: string, address: string): Promise { + const storageResult = await TezosNodeReader.getContractStorage(server, address); + + console.log(JSON.stringify(storageResult)); + + return { + balanceMap: Number(JSONPath({ path: '$.args[1].args[0].args[1].args[0].int', json: storageResult })[0]), + approvalsMap: Number(JSONPath({ path: '$.args[1].args[0].args[0].args[1].int', json: storageResult })[0]), + supply: Number(JSONPath({ path: '$.args[1].args[1].args[1].int', json: storageResult })[0]), + administrator: JSONPath({ path: '$.args[1].args[0].args[0].args[0].string', json: storageResult })[0], + paused: (JSONPath({ path: '$.args[1].args[1].args[0].prim', json: storageResult })[0]).toString().toLowerCase().startsWith('t'), + pauseGuardian: JSONPath({ path: '$.args[1].args[0].args[1].args[1].string', json: storageResult })[0], + outcomeMap: Number(JSONPath({ path: '$.args[0].args[0].int', json: storageResult })[0]), + swapMap: Number(JSONPath({ path: '$.args[0].args[1].int', json: storageResult })[0]) + }; + }, + + /** + * Get the balance of tokens for an address. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param mapId The ID of the BigMap which contains balances. + * @param account The account to fetch the token balance for. + * @returns The balance of the account. + */ + getAccountBalance: async function (server: string, mapid: number, account: string): Promise { + const packedKey = TezosMessageUtils.encodeBigMapKey(Buffer.from(TezosMessageUtils.writePackedData(account, 'address'), 'hex')); + const mapResult = await TezosNodeReader.getValueForBigMapKey(server, mapid, packedKey); + + if (mapResult === undefined) { throw new Error(`Map ${mapid} does not contain a record for ${account}`); } + + const numberString = JSONPath({ path: '$.int', json: mapResult }); + return Number(numberString); + }, + + /** + * Transfer some WXTZ between addresses. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param signer A Signer for the sourceAddress. + * @param keystore A Keystore for the sourceAddress. + * @param tokenContractAddress The address of the token contract. + * @param fee The fee to use. + * @param sourceAddress The address which will send tokens. + * @param destinationAddress The address which will receive tokens. + * @param amount The amount of tokens to send. + * @param gasLimit The gas limit to use. + * @param storageLimit The storage limit to use. + * @returns A string representing the operation hash. + */ + transferBalance: async function ( + nodeUrl: string, + signer: Signer, + keystore: KeyStore, + tokenContractAddress: string, + fee: number, + sourceAddress: string, + destinationAddress: string, + amount: number, + gasLimit: number = 51_300, + storageLimit: number = 70 + ): Promise { + const parameters = `Pair "${sourceAddress}" (Pair "${destinationAddress}" ${amount})`; + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation( + nodeUrl, + signer, + keystore, + tokenContractAddress, + 0, + fee, + storageLimit, + gasLimit, + 'transfer', + parameters, + TezosTypes.TezosParameterFormat.Michelson + ); + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } +} diff --git a/src/chain/tezos/contracts/WrappedTezosHelper.ts b/src/chain/tezos/contracts/WrappedTezosHelper.ts new file mode 100644 index 00000000..387d4d54 --- /dev/null +++ b/src/chain/tezos/contracts/WrappedTezosHelper.ts @@ -0,0 +1,502 @@ +import { JSONPath } from 'jsonpath-plus'; + +import { KeyStore, Signer } from '../../../types/ExternalInterfaces'; +import * as TezosTypes from '../../../types/tezos/TezosChainTypes'; +import { TezosMessageUtils } from '../TezosMessageUtil'; +import { TezosNodeReader } from '../TezosNodeReader'; +import { TezosNodeWriter } from '../TezosNodeWriter'; +import { TezosContractUtils } from './TezosContractUtils'; +import { TezosConseilClient } from '../../../reporting/tezos/TezosConseilClient' +import { ConseilServerInfo } from '../../../types/conseil/QueryTypes'; +import { ContractMapDetailsItem } from '../../../types/conseil/ConseilTezosTypes'; +import { TezosParameterFormat } from '../../../types/tezos/TezosChainTypes'; + +/** The expected checksum for the Wrapped Tezos contracts. */ +const CONTRACT_CHECKSUMS = { + token: 'd48b45bd77d2300026fe617c5ba7670e', + oven: '5e3c30607da21a0fc30f7be61afb15c7', + core: '7b9b5b7e7f0283ff6388eb783e23c452' +} + +/** The expected checksum for the Wrapped Tezos scripts. */ +const SCRIPT_CHECKSUMS = { + // TODO(keefertaylor): Compute this checksum correctly. + token: '', + // TODO(keefertaylor): Compute this checksum correctly. + oven: '', + // TODO(keefertaylor): Compute this checksum correctly. + core: '' +} + +/** + * Property bag containing the results of opening an oven. + */ +export type OpenOvenResult = { + // The operation hash of the request to open an oven. + operationHash: string + + // The address of the new oven contract. + ovenAddress: string +} + +export interface WrappedTezosStorage { + balanceMap: number; + approvalsMap: number; + supply: number; + administrator: string; + paused: boolean; + pauseGuardian: string; + outcomeMap: number; + swapMap: number; +} + +export interface WrappedTezosBalanceRecord { } +export interface WrappedTezosApprovalRecord { } +export interface WrappedTezosOutcomeRecord { } +export interface WrappedTezosSwapRecord { } + +/** + * Types for the Oven Map . + * + * Key: The oven's address. + * Value: The oven owner's address. + */ +export type OvenMapSchema = { key: string, value: string } + +/** + * Interface for the Wrapped XTZ Token and Oven implementation. + * + * @see {@link https://forum.tezosagora.org/t/wrapped-tezos/2195|wXTZ on Tezos Agora} + * + * The token represented by these contracts trades with symbol 'WXTZ' and is specified with 10^-6 precision. Put + * simply, 1 XTZ = 1 WXTZ, and 0.000_001 XTZ = 1 Mutez = 0.000_001 WXTZ. + * + * Canonical Data: + * - Delphinet: + * - Token Contract: KT1JYf7xjCJAqFDfNpuump9woSMaapy1WcMY + * - Core Contract: KT1S98ELFTo6mdMBqhAVbGgKAVgLbdPP3AX8 + * - Token Balances Map ID: 14566 + * - Oven List Map ID: 14569 + * TODO(keefertaylor): Add additional data for mainnet here. + * + * @author Keefer Taylor, Staker Services Ltd + */ +export namespace WrappedTezosHelper { + /** + * Verifies that contract code for Wrapped Tezos matches the expected code. + * + * Note: This function processes contracts in the Micheline format. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param tokenContractAddress The address of the token contract. + * @param ovenContractAddress The address of an oven contract. + * @param coreContractAddress The address of the core contract. + * @returns A boolean indicating if the code was the expected sum. + */ + export async function verifyDestination( + nodeUrl: string, + tokenContractAddress: string, + ovenContractAddress: string, + coreContractAddress: string + ): Promise { + const tokenMatched = TezosContractUtils.verifyDestination(nodeUrl, tokenContractAddress, CONTRACT_CHECKSUMS.token) + const ovenMatched = TezosContractUtils.verifyDestination(nodeUrl, ovenContractAddress, CONTRACT_CHECKSUMS.oven) + const coreMatched = TezosContractUtils.verifyDestination(nodeUrl, coreContractAddress, CONTRACT_CHECKSUMS.core) + + return tokenMatched && ovenMatched && coreMatched + } + + /** + * Verifies that Michelson script for Wrapped Tezos contracts matches the expected code. + * + * Note: This function processes scrips in Michelson format. + * + * @param tokenScript The script of the token contract. + * @param ovenScript The script of an oven contract. + * @param coreScript The script of the core contract. + * @returns A boolean indicating if the code was the expected sum. + */ + export function verifyScript( + tokenScript: string, + ovenScript: string, + coreScript: string + ): boolean { + const tokenMatched = TezosContractUtils.verifyScript(tokenScript, SCRIPT_CHECKSUMS.token) + const ovenMatched = TezosContractUtils.verifyScript(ovenScript, SCRIPT_CHECKSUMS.oven) + const coreMatched = TezosContractUtils.verifyScript(coreScript, SCRIPT_CHECKSUMS.core) + + return tokenMatched && ovenMatched && coreMatched + } + + /** + * + * @param server + * @param address + */ + export async function getSimpleStorage(server: string, address: string): Promise { + const storageResult = await TezosNodeReader.getContractStorage(server, address); + + console.log(JSON.stringify(storageResult)); + + return { + balanceMap: Number(JSONPath({ path: '$.args[1].args[0].args[1].args[0].int', json: storageResult })[0]), + approvalsMap: Number(JSONPath({ path: '$.args[1].args[0].args[0].args[1].int', json: storageResult })[0]), + supply: Number(JSONPath({ path: '$.args[1].args[1].args[1].int', json: storageResult })[0]), + administrator: JSONPath({ path: '$.args[1].args[0].args[0].args[0].string', json: storageResult })[0], + paused: (JSONPath({ path: '$.args[1].args[1].args[0].prim', json: storageResult })[0]).toString().toLowerCase().startsWith('t'), + pauseGuardian: JSONPath({ path: '$.args[1].args[0].args[1].args[1].string', json: storageResult })[0], + outcomeMap: Number(JSONPath({ path: '$.args[0].args[0].int', json: storageResult })[0]), + swapMap: Number(JSONPath({ path: '$.args[0].args[1].int', json: storageResult })[0]) + }; + } + + /** + * Get the balance of WXTZ tokens for an address. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param mapId The ID of the BigMap which contains balances. + * @param account The account to fetch the token balance for. + * @returns The balance of the account. + */ + export async function getAccountBalance(server: string, mapid: number, account: string): Promise { + const packedKey = TezosMessageUtils.encodeBigMapKey(Buffer.from(TezosMessageUtils.writePackedData(account, 'address'), 'hex')); + const mapResult = await TezosNodeReader.getValueForBigMapKey(server, mapid, packedKey); + + if (mapResult === undefined) { throw new Error(`Map ${mapid} does not contain a record for ${account}`); } + + const numberString = JSONPath({ path: '$.int', json: mapResult }); + return Number(numberString); + } + + /** + * Transfer some WXTZ between addresses. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param signer A Signer for the sourceAddress. + * @param keystore A Keystore for the sourceAddress. + * @param tokenContractAddress The address of the token contract. + * @param fee The fee to use. + * @param sourceAddress The address which will send tokens. + * @param destinationAddress The address which will receive tokens. + * @param amount The amount of tokens to send. + * @param gasLimit The gas limit to use. + * @param storageLimit The storage limit to use. + * @returns A string representing the operation hash. + */ + export async function transferBalance( + nodeUrl: string, + signer: Signer, + keystore: KeyStore, + tokenContractAddress: string, + fee: number, + sourceAddress: string, + destinationAddress: string, + amount: number, + gasLimit: number = 51_300, + storageLimit: number = 70 + ): Promise { + const parameters = `Pair "${sourceAddress}" (Pair "${destinationAddress}" ${amount})`; + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation( + nodeUrl, + signer, + keystore, + tokenContractAddress, + 0, + fee, + storageLimit, + gasLimit, + 'transfer', + parameters, + TezosTypes.TezosParameterFormat.Michelson + ); + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * Deposit XTZ into an oven to mint WXTZ. + * + * WXTZ will be minted for the owner of the oven, *not* the source address. This allows bakers + * to payout delegated ovens. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param signer A Signer for the sourceAddress. + * @param keystore A Keystore for the sourceAddress. + * @param ovenAddress The address of the oven contract. + * @param fee The fee to use. + * @param amountMutez The amount of XTZ to deposit, specified in mutez. + * @param gasLimit The gas limit to use. + * @param storageLimit The storage limit to use. + * @returns A string representing the operation hash. + */ + export async function depositToOven( + nodeUrl: string, + signer: Signer, + keystore: KeyStore, + ovenAddress: string, + fee: number, + amountMutez: number, + gasLimit: number = 150_000, + storageLimit: number = 10 + ): Promise { + const parameters = 'Unit' + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation( + nodeUrl, + signer, + keystore, + ovenAddress, + amountMutez, + fee, + storageLimit, + gasLimit, + '', + parameters, + TezosTypes.TezosParameterFormat.Michelson + ) + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * Withdraw XTZ from an oven by repaying WXTZ. + * + * This operation will fail if: + * - The sending account is not the oven owner. + * - The sending account does not possess the equivalent amount of WXTZ to the withdrawal amount + * - The oven has less XTZ in it than is requested to be withdrawn. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param signer A Signer for the sourceAddress. + * @param keystore A Keystore for the sourceAddress. + * @param ovenAddress The address of the oven contract. + * @param fee The fee to use. + * @param amountMutez The amount of XTZ to withdraw, specified in mutez. + * @param gasLimit The gas limit to use. + * @param storageLimit The storage limit to use. + * @returns A string representing the operation hash. + */ + export async function withdrawFromOven( + nodeUrl: string, + signer: Signer, + keystore: KeyStore, + ovenAddress: string, + fee: number, + amountMutez: number, + gasLimit: number = 121_000, + storageLimit: number = 0 + ): Promise { + const parameters = `${amountMutez}` + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation( + nodeUrl, + signer, + keystore, + ovenAddress, + 0, + fee, + storageLimit, + gasLimit, + 'withdraw', + parameters, + TezosTypes.TezosParameterFormat.Michelson + ) + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + /** + * Retrieve a list of all oven addresses a user owns. + * + * @param serverInfo Connection info for Conseil. + * @param coreContractAddress The core contract address + * @param ovenOwner The oven owner to search for + * @param ovenListBigMapId The BigMap ID of the oven list. + */ + export async function listOvens( + serverInfo: ConseilServerInfo, + coreContractAddress: string, + ovenOwner: string, + ovenListBigMapId: number + ): Promise> { + // Fetch map data. + const mapData = await TezosConseilClient.getBigMapData(serverInfo, coreContractAddress) + if (mapData === undefined) { + throw new Error("Could not fetch map data!") + } + + // Find the Map that contains the oven list. + const { maps } = mapData + let ovenListMap: ContractMapDetailsItem | undefined = undefined + for (let i = 0; i < maps.length; i++) { + if (maps[i].definition.index === ovenListBigMapId) { + ovenListMap = maps[i] + break + } + } + if (ovenListMap === undefined) { + throw new Error("Could not find specified map ID!") + } + + // Conseil reports addresses as quoted michelson encoded hex prefixed + // with '0x'. Normalize these to base58check encoded addresses. + const { content } = ovenListMap + const normalizedOvenList: Array = content.map((oven: OvenMapSchema) => { + return { + key: TezosMessageUtils.readAddress(oven.key.replace(/\"/g, '').replace(/\n/, '').replace("0x", "")), + value: TezosMessageUtils.readAddress(oven.value.replace(/\"/g, '').replace(/\n/, '').replace("0x", "")) + } + }) + + // Filter oven list for ovens belonging to the owner. + const ownedOvens = normalizedOvenList.filter((oven: OvenMapSchema): boolean => { + return ovenOwner === oven.value + }) + + // Map filtered array to only contain oven addresses. + return ownedOvens.map((oven: OvenMapSchema) => { + return oven.key + }) + } + + /** + * Deploy a new oven contract. + * + * The oven's owner is assigned to the sender's address. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param signer A Signer for the sourceAddress. + * @param keystore A Keystore for the sourceAddress. + * @param fee The fee to use. + * @param coreAddress The address of the core contract. + * @param baker The inital baker for the Oven. If `undefined` the oven will not have an initial baker. Defaults to `undefined`. + * @param gasLimit The gas limit to use. + * @param storageLimit The storage limit to use. + * @returns A property bag of data about the operation. + */ + export async function deployOven( + nodeUrl: string, + signer: Signer, + keystore: KeyStore, + fee: number, + coreAddress: string, + baker: string | undefined = undefined, + gasLimit: number = 115_000, + storageLimit: number = 1100 + ): Promise { + const entryPoint = 'runEntrypointLambda' + const lambdaName = 'createOven' + const bakerParam = baker !== undefined ? `Some "${baker}"` : 'None' + const bytes = TezosMessageUtils.writePackedData(`Pair ${bakerParam} "${keystore.publicKeyHash}"`, 'pair (option key_hash) address', TezosParameterFormat.Michelson) + const parameters = `Pair "${lambdaName}" 0x${bytes}` + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation( + nodeUrl, + signer, + keystore, + coreAddress, + 0, + fee, + storageLimit, + gasLimit, + entryPoint, + parameters, + TezosTypes.TezosParameterFormat.Michelson + ) + + const operationHash = TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + const ovenAddress = TezosMessageUtils.calculateContractAddress(operationHash, 0) + return { + operationHash, + ovenAddress + } + } + + /** + * Set the baker for an oven. + * + * This operation will fail if the sender is not the oven owner. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param signer A Signer for the sourceAddress. + * @param keystore A Keystore for the sourceAddress. + * @param fee The fee to use. + * @param ovenAddress The address of the oven contract. + * @param bakerAddress The address of the baker for the oven. + * @param gasLimit The gas limit to use. + * @param storageLimit The storage limit to use. + * @returns A string representing the operation hash. + */ + export async function setOvenBaker( + nodeUrl: string, + signer: Signer, + keystore: KeyStore, + fee: number, + ovenAddress: string, + bakerAddress: string, + gasLimit: number = 23_500, + storageLimit: number = 0, + ): Promise { + const parameters = `Some "${bakerAddress}"` + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation( + nodeUrl, + signer, + keystore, + ovenAddress, + 0, + fee, + storageLimit, + gasLimit, + 'setDelegate', + parameters, + TezosTypes.TezosParameterFormat.Michelson + ) + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } + + + /** + * Clear the baker for an oven. + * + * This operation will fail if the sender is not the oven owner. + * + * @param nodeUrl The URL of the Tezos node which serves data. + * @param signer A Signer for the sourceAddress. + * @param keystore A Keystore for the sourceAddress. + * @param fee The fee to use. + * @param ovenAddress The address of the oven contract. + * @param gasLimit The gas limit to use. + * @param storageLimit The storage limit to use. + * @returns A string representing the operation hash. + */ + export async function clearOvenBaker( + nodeUrl: string, + signer: Signer, + keystore: KeyStore, + fee: number, + ovenAddress: string, + gasLimit: number = 23_500, + storageLimit: number = 0, + ): Promise { + const parameters = `None` + + const nodeResult = await TezosNodeWriter.sendContractInvocationOperation( + nodeUrl, + signer, + keystore, + ovenAddress, + 0, + fee, + storageLimit, + gasLimit, + 'setDelegate', + parameters, + TezosTypes.TezosParameterFormat.Michelson + ) + + return TezosContractUtils.clearRPCOperationGroupHash(nodeResult.operationGroupID); + } +} \ No newline at end of file diff --git a/src/index-web.ts b/src/index-web.ts index f370935c..4f0660a6 100644 --- a/src/index-web.ts +++ b/src/index-web.ts @@ -11,7 +11,7 @@ export * from './chain/tezos/TezosNodeReader'; export * from './chain/tezos/TezosNodeWriter'; export * from './chain/tezos/contracts/BabylonDelegationHelper'; export * from './chain/tezos/contracts/CryptonomicNameServiceHelper'; -export * from './chain/tezos/contracts/DexterTokenHelper'; +export * from './chain/tezos/contracts/DexterPoolHelper'; export * from './chain/tezos/contracts/MurbardMultisigHelper'; export * from './chain/tezos/contracts/StakerDAOTokenHelper'; export * from './chain/tezos/contracts/TCFBakerRegistryHelper'; @@ -20,6 +20,7 @@ export * from './chain/tezos/contracts/tzip12/ChainlinkTokenHelper'; export * from './chain/tezos/contracts/tzip12/MultiAssetTokenHelper'; export * from './chain/tezos/contracts/tzip12/SingleAssetTokenHelper'; export * from './chain/tezos/contracts/TzbtcTokenHelper'; +export * from './chain/tezos/contracts/WrappedTezosHelper'; export * from './reporting/tezos/TezosConseilClient'; export * from './reporting/ConseilDataClient'; diff --git a/src/index.ts b/src/index.ts index 2fd2c535..4db46337 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ export * from "./chain/tezos/TezosNodeReader"; export * from "./chain/tezos/TezosNodeWriter"; export * from './chain/tezos/contracts/BabylonDelegationHelper'; export * from './chain/tezos/contracts/CryptonomicNameServiceHelper'; -export * from './chain/tezos/contracts/DexterTokenHelper'; +export * from './chain/tezos/contracts/DexterPoolHelper'; export * from './chain/tezos/contracts/MurbardMultisigHelper'; export * from './chain/tezos/contracts/StakerDAOTokenHelper'; export * from './chain/tezos/contracts/TCFBakerRegistryHelper'; @@ -25,6 +25,7 @@ export * from './chain/tezos/contracts/tzip12/ChainlinkTokenHelper'; export * from './chain/tezos/contracts/tzip12/MultiAssetTokenHelper'; export * from './chain/tezos/contracts/tzip12/SingleAssetTokenHelper'; export * from './chain/tezos/contracts/TzbtcTokenHelper'; +export * from './chain/tezos/contracts/WrappedTezosHelper'; export * from "./reporting/tezos/TezosConseilClient"; diff --git a/src/reporting/ConseilDataClient.ts b/src/reporting/ConseilDataClient.ts index 278f1c03..755a017c 100644 --- a/src/reporting/ConseilDataClient.ts +++ b/src/reporting/ConseilDataClient.ts @@ -26,6 +26,7 @@ export namespace ConseilDataClient { return fetch(url, { method: 'post', headers: { 'apiKey': serverInfo.apiKey, 'Content-Type': 'application/json' }, + cache: 'no-store', body: JSON.stringify(query) }) .then(r => { diff --git a/src/reporting/tezos/TezosConseilClient.ts b/src/reporting/tezos/TezosConseilClient.ts index f1829ae9..6a963595 100644 --- a/src/reporting/tezos/TezosConseilClient.ts +++ b/src/reporting/tezos/TezosConseilClient.ts @@ -1,8 +1,12 @@ -import { ConseilQueryBuilder } from "../ConseilQueryBuilder"; import { ConseilQuery, ConseilOperator, ConseilServerInfo, ConseilSortDirection } from "../../types/conseil/QueryTypes" -import { ConseilDataClient } from "../ConseilDataClient"; import { OperationKindType } from "../../types/tezos/TezosChainTypes"; import { ContractMapDetails, ContractMapDetailsItem } from '../../types/conseil/ConseilTezosTypes'; +import LogSelector from '../../utils/LoggerSelector'; + +import { ConseilDataClient } from "../ConseilDataClient"; +import { ConseilQueryBuilder } from "../ConseilQueryBuilder"; + +const log = LogSelector.log; /** * Functions for querying the Conseil backend REST API v2 for Tezos chain data. @@ -207,18 +211,29 @@ export namespace TezosConseilClient { */ export async function awaitOperationConfirmation(serverInfo: ConseilServerInfo, network: string, hash: string, duration: number, blocktime: number = 60): Promise { if (duration <= 0) { throw new Error('Invalid duration'); } + const initialLevel = (await getBlockHead(serverInfo, network))['level']; + const timeOffset = 180000; + const startTime = (new Date).getTime() - timeOffset; + const estimatedEndTime = startTime + timeOffset + duration * blocktime * 1000; + + log.debug(`TezosConseilClient.awaitOperationConfirmation looking for ${hash} since ${initialLevel} at ${(new Date(startTime).toUTCString())}, +${duration}`); + let currentLevel = initialLevel; let operationQuery = ConseilQueryBuilder.blankQuery(); operationQuery = ConseilQueryBuilder.addPredicate(operationQuery , 'operation_group_hash', ConseilOperator.EQ, [hash], false); - operationQuery = ConseilQueryBuilder.addPredicate(operationQuery , 'timestamp', ConseilOperator.AFTER, [(new Date).getTime() - 60000], false); + operationQuery = ConseilQueryBuilder.addPredicate(operationQuery , 'timestamp', ConseilOperator.AFTER, [startTime], false); operationQuery = ConseilQueryBuilder.setLimit(operationQuery, 1); while (initialLevel + duration > currentLevel) { const group = await getOperations(serverInfo, network, operationQuery); if (group.length > 0) { return group[0]; } + currentLevel = (await getBlockHead(serverInfo, network))['level']; + if (initialLevel + duration < currentLevel) { break; } + if ((new Date).getTime() > estimatedEndTime) { break; } + await new Promise(resolve => setTimeout(resolve, blocktime * 1000)); } diff --git a/src/types/ExternalInterfaces.ts b/src/types/ExternalInterfaces.ts index 8f4c650d..4f0ea7fc 100644 --- a/src/types/ExternalInterfaces.ts +++ b/src/types/ExternalInterfaces.ts @@ -6,9 +6,9 @@ export enum SignerCurve { export interface Signer { getSignerCurve: () => SignerCurve; - signOperation: (bytes: Buffer) => Promise; - signText: (message: string) => Promise; - signTextHash: (message: string) => Promise; + signOperation: (bytes: Buffer, password?: string) => Promise; + signText: (message: string, password?: string) => Promise; + signTextHash: (message: string, password?: string) => Promise; } export interface KeyStore { diff --git a/src/types/tezos/TezosRPCResponseTypes.ts b/src/types/tezos/TezosRPCResponseTypes.ts index 9e01872a..f0a82b58 100644 --- a/src/types/tezos/TezosRPCResponseTypes.ts +++ b/src/types/tezos/TezosRPCResponseTypes.ts @@ -122,12 +122,7 @@ export interface TezosBlockOperationContentMetadataOperationResult { */ export interface Contract { balance: string; - delegate?: ContractDelegate; + delegate?: string; script?: any; counter: string; } - -export interface ContractDelegate { - setable: boolean, - value: string -} diff --git a/test/chain/tezos/contracts/DexterPoolHelper.spec.ts b/test/chain/tezos/contracts/DexterPoolHelper.spec.ts new file mode 100644 index 00000000..332bf176 --- /dev/null +++ b/test/chain/tezos/contracts/DexterPoolHelper.spec.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import { DexterPoolHelper } from '../../../../src/chain/tezos/contracts/DexterPoolHelper'; + +describe('DexterPoolHelper test suite', () => { + it('calcTokenLiquidityRequirement tests', () => { + // TODO + //let result = DexterPoolHelper.calcTokenLiquidityRequirement(xtzDeposit: number, tokenBalance: number, xtzBalance: number) + }); + + /** + * Test samples retrieved from https://gitlab.com/camlcase-dev/dexter-integration/-/raw/master/xtz_to_token.json on 2020/Oct/21. + */ + it('getTokenExchangeRate tests', () => { + const camlCaseSamples = [ + { "xtz_pool": "1000000000", "token_pool": "250000", "xtz_in": "1000000", "token_out": "249", "slippage": "0.0040" }, + { "xtz_pool": "1000000000", "token_pool": "250000", "xtz_in": "2000000", "token_out": "497", "slippage": "0.0059" }, + { "xtz_pool": "1000000000", "token_pool": "250000", "xtz_in": "5000000", "token_out": "1240", "slippage": "0.0079" }, + { "xtz_pool": "1000000000", "token_pool": "250000", "xtz_in": "10000000", "token_out": "2467", "slippage": "0.0132" }, + { "xtz_pool": "1000000000", "token_pool": "250000", "xtz_in": "100000000", "token_out": "22665", "slippage": "0.0934" }, + { "xtz_pool": "355200000", "token_pool": "30000", "xtz_in": "1000000", "token_out": "83", "slippage": "0.0172" }, + { "xtz_pool": "355200000", "token_pool": "30000", "xtz_in": "34020000", "token_out": "2614", "slippage": "0.0902" }, + { "xtz_pool": "10000000000", "token_pool": "15200", "xtz_in": "10000000", "token_out": "15", "slippage": "0.0131" }, + { "xtz_pool": "10000000000", "token_pool": "15200", "xtz_in": "23100000", "token_out": "34", "slippage": "0.0316" }, + { "xtz_pool": "10000000000", "token_pool": "15200", "xtz_in": "67000000", "token_out": "100", "slippage": "0.01806" }, + { "xtz_pool": "10000000000", "token_pool": "15200", "xtz_in": "1050000", "token_out": "1", "slippage": "0.3734" }, + { "xtz_pool": "10000000000", "token_pool": "15200", "xtz_in": "4423000", "token_out": "6", "slippage": "0.1075" }, + { "xtz_pool": "103000000", "token_pool": "101000", "xtz_in": "1000000", "token_out": "968", "slippage": "0.0128" }, + { "xtz_pool": "10000000", "token_pool": "1000", "xtz_in": "2000000", "token_out": "166", "slippage": "0.1700" }, + { "xtz_pool": "12000000", "token_pool": "834", "xtz_in": "2500000", "token_out": "143", "slippage": "0.1769" }, + { "xtz_pool": "14500000", "token_pool": "691", "xtz_in": "6125000", "token_out": "204", "slippage": "0.3011" }, + { "xtz_pool": "14500000", "token_pool": "1234", "xtz_in": "5000000", "token_out": "315", "slippage": "0.2597" } ]; + + for (const sample of camlCaseSamples) { + const result = DexterPoolHelper.getTokenExchangeRate(Number(sample.xtz_in), Number(sample.token_pool), Number(sample.xtz_pool)); + + expect(result.tokenAmount).to.equal(Number(sample.token_out)); + } + }); + + /** + * * Test samples retrieved from https://gitlab.com/camlcase-dev/dexter-integration/-/raw/master/token_to_xtz.json on 2020/Oct/21. + */ + it('getXTZExchangeRate tests', () => { + const camlCaseSamples = [ + { "xtz_pool": "20000000", "token_pool": "1000", "token_in": "1000", "xtz_out": "9984977", "slippage": "0.5007" }, + { "xtz_pool": "20000000", "token_pool": "1000", "token_in": "100", "xtz_out": "1813221", "slippage": "0.0933" }, + { "xtz_pool": "20000000", "token_pool": "1000", "token_in": "10", "xtz_out": "197431", "slippage": "0.0128" }, + { "xtz_pool": "20000000", "token_pool": "1000", "token_in": "1", "xtz_out": "19920", "slippage": "0.0040" }, + { "xtz_pool": "20000000", "token_pool": "1000", "token_in": "240", "xtz_out": "3861597", "slippage": "0.1955" }, + { "xtz_pool": "20000000", "token_pool": "1000", "token_in": "116", "xtz_out": "2073262", "slippage": "0.1063" }, + { "xtz_pool": "20000000", "token_pool": "1000", "token_in": "923", "xtz_out": "9584586", "slippage": "0.4807" }, + { "xtz_pool": "19500000", "token_pool": "919", "token_in": "100", "xtz_out": "1908461", "slippage": "0.1005" }, + { "xtz_pool": "17591539", "token_pool": "1019", "token_in": "81", "xtz_out": "1291776", "slippage": "0.0762" } ]; + + for (const sample of camlCaseSamples) { + const result = DexterPoolHelper.getXTZExchangeRate(Number(sample.token_in), Number(sample.token_pool), Number(sample.xtz_pool)); + + expect(result.xtzAmount).to.equal(Number(sample.xtz_out)); + } + }); + + it('estimateLiquidityAmount tests', () => { + //estimateLiquidityAmount(xtzDeposit: number, liquidityBalance: number, xtzBalance: number) + }); + + it('estimateShareCost tests', () => { + //estimateShareCost(xtzBalance: number, tokenBalance: number): { xtzCost: number, tokenCost: number } + }); +});