From dd7b0bc24ce31615c04b4fa922b3b52572045693 Mon Sep 17 00:00:00 2001 From: c8e4 Date: Tue, 23 Jul 2024 20:20:17 +0200 Subject: [PATCH] limit order dex --- tests/dex/buy.spec.ts | 253 +++++++++++++++++++++++++++++++++++++ tests/dex/helper.ts | 91 ++++++++++++++ tests/dex/sell.spec.ts | 277 +++++++++++++++++++++++++++++++++++++++++ tests/dex/swap.spec.ts | 173 +++++++++++++++++++++++++ 4 files changed, 794 insertions(+) create mode 100644 tests/dex/buy.spec.ts create mode 100644 tests/dex/helper.ts create mode 100644 tests/dex/sell.spec.ts create mode 100644 tests/dex/swap.spec.ts diff --git a/tests/dex/buy.spec.ts b/tests/dex/buy.spec.ts new file mode 100644 index 0000000..d679035 --- /dev/null +++ b/tests/dex/buy.spec.ts @@ -0,0 +1,253 @@ +import { compile } from "@fleet-sdk/compiler"; +import { + SByte, + SColl, + SGroupElement, + SLong, + SSigmaProp, + TransactionBuilder +} from "@fleet-sdk/core"; +import { KeyedMockChainParty, MockChain } from "@fleet-sdk/mock-chain"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ergOutput, rsBTC, rsBtcId } from "./helper"; + +describe("Timed fund contract", () => { + const ergoTree = compile(`{ + def getMakerPk(box: Box) = box.R4[SigmaProp].getOrElse(sigmaProp(false)) + def getPaymentAddress(box: Box) = box.R5[Coll[Byte]].getOrElse(Coll[Byte]()) + def getTokenId(box: Box) = box.R6[Coll[Byte]].getOrElse(Coll[Byte]()) + def getRate(box: Box) = box.R7[Coll[Long]].getOrElse(Coll[SigmaProp](0L,0L))(0) + def getDenom(box: Box) = box.R7[Coll[Long]].getOrElse(Coll[SigmaProp](0L,0L))(1) + + def tokenAmount(box: Box) = { + if(box.tokens.size > 0) + { + box.tokens(0)._2 + } else{ + 0L + } + } + + def isSameContract(box: Box) = + box.propositionBytes == SELF.propositionBytes + + def isGreaterZeroRate(box:Box) = + getRate(box) > 0 && + getDenom(box) > 0 + + def isSameMaker(box: Box) = + getMakerPk(SELF) == getMakerPk(box) && + getPaymentAddress(SELF) == getPaymentAddress(box) + + def isLegitInput(b: Box) = { + isSameContract(b) && + isSameMaker(b) && + getTokenId(SELF) == getTokenId(b) && + isGreaterZeroRate(b) + } + + def isPaymentBox(box:Box) = { + isSameMaker(box) && + getTokenId(SELF) == getTokenId(box) && + getPaymentAddress(SELF) == box.propositionBytes + } + + val maxDenom: Long = INPUTS + .filter(isLegitInput) + .fold(0L, {(r:Long, box:Box) => { + if(r > getDenom(box)) r else getDenom(box) + }}) + + def getRateInMaxDenom(box:Box) = getRate(box)*maxDenom/getDenom(box) + + val filteredInputs = INPUTS.filter(isLegitInput) + val minBuyRate: Long = filteredInputs + .fold(getRateInMaxDenom(filteredInputs(0)), {(r:Long, box:Box) => { + if(r < getRateInMaxDenom(box)) r else getRateInMaxDenom(box) + }}) + + def hasMinBuyRate(box: Box) = + getRate(box) * maxDenom == getDenom(box) * minBuyRate + + def isChangeBox(box: Box) = + isLegitInput(box) && + hasMinBuyRate(box) + + def sumTokenAmount(a:Long, b: Box) = a + tokenAmount(b) + def sumErgXMinRate(a:Long, b: Box) = a + b.value * minBuyRate + def sumErgXRate(a:Long, b: Box) = a + b.value * getRateInMaxDenom(b) + def sumTokenAmountXRate(a:Long, b: Box) = a + tokenAmount(b) * getRateInMaxDenom(b) + + val tokensPaid = OUTPUTS.filter(isPaymentBox).fold(0L, sumTokenAmount).toBigInt + val expectedErgXRate = { + val in = INPUTS.filter(isLegitInput).fold(0L, sumErgXRate) + val out = OUTPUTS.filter(isChangeBox).fold(0L, sumErgXMinRate).toBigInt + + OUTPUTS.filter(isPaymentBox).fold(0L, sumErgXMinRate).toBigInt + + in - out + } + + val isPaidAtFairRate = tokensPaid * maxDenom >= expectedErgXRate + + getMakerPk(SELF) || sigmaProp(isPaidAtFairRate) +}`); + const mockChain = new MockChain({ height: 1_052_944 }); + const buyer = mockChain.newParty("Seller"); + const executor = mockChain.newParty("Bob"); + mockChain.parties; + + const wtb = mockChain.addParty(ergoTree.toHex(), "Token Buy Contract"); + + const buyBtcUsdRegs = (pk: KeyedMockChainParty, rate: bigint = 1n, denom: bigint = 1n) => ({ + R4: SSigmaProp(SGroupElement(pk.key.publicKey)).toHex(), + R5: SColl(SByte, pk.ergoTree).toHex(), + R6: SColl(SByte, rsBtcId).toHex(), + R7: SColl(SLong, [rate, denom]).toHex() + }); + + afterEach(() => { + mockChain.reset(); + }); + + describe("Buy", () => { + beforeEach(() => { + executor.addBalance({ nanoergs: 100_000n, tokens: [rsBTC(1000)] }); + }); + it("wtb 100 rsBTC with 100 nanoErg", () => { + wtb.addBalance({ nanoergs: 1_000n + 100n }, buyBtcUsdRegs(buyer, 1n, 1n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ergOutput(buyer, 1_000n, [rsBTC(100)], buyBtcUsdRegs(buyer))]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor] })).to.be.true; + }); + + it("can't underpay nanoErg", () => { + wtb.addBalance({ nanoergs: 1_000n + 100n }, buyBtcUsdRegs(buyer, 1n, 1n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ergOutput(buyer, 1_000n - 1n, [rsBTC(100)], buyBtcUsdRegs(buyer))]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor], throw: false })).to.be + .false; + }); + + it("can't underpay tokens", () => { + wtb.addBalance({ nanoergs: 1_000n + 100n }, buyBtcUsdRegs(buyer, 1n, 1n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ergOutput(buyer, 1_000n, [rsBTC(100 - 1)], buyBtcUsdRegs(buyer))]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor], throw: false })).to.be + .false; + }); + + it("wtb [100BTC, 100E], [100BTC, 200E]", () => { + wtb.addBalance({ nanoergs: 100n }, buyBtcUsdRegs(buyer, 1n, 1n)); + wtb.addBalance({ nanoergs: 200n }, buyBtcUsdRegs(buyer, 2n, 1n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ergOutput(buyer, 1n, [rsBTC(500)], buyBtcUsdRegs(buyer))]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor] })).to.be.true; + }); + + it("change can be sent to buyer address", () => { + wtb.addBalance({ nanoergs: 2_000n }, buyBtcUsdRegs(buyer, 1n, 10n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ergOutput(buyer, 2_000n - 10n, [rsBTC(1)], buyBtcUsdRegs(buyer))]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor] })).to.be.true; + }); + it("change can be sent to contract change box", () => { + wtb.addBalance({ nanoergs: 1_000n + 1_000n }, buyBtcUsdRegs(buyer, 1n, 10n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ergOutput(buyer, 10n, [rsBTC(1)], buyBtcUsdRegs(buyer))]) + .to([ergOutput(wtb, 2_000n - 20n, [], buyBtcUsdRegs(buyer, 1n, 10n))]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor] })).to.be.true; + }); + it("can't manipulate rate in contract change box", () => { + wtb.addBalance({ nanoergs: 1_000n + 1_000n }, buyBtcUsdRegs(buyer, 1n, 10n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ergOutput(buyer, 10n, [rsBTC(1)], buyBtcUsdRegs(buyer))]) + .to([ergOutput(wtb, 2_000n - 20n, [], buyBtcUsdRegs(buyer, 1n, 100n))]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor], throw: false })).to.be + .false; + }); + + it(`wtb + 20ERG/BTC for 1000ERG (50BTC max), + 1ERG/BTC for 100ERG (100BTC max)`, () => { + // rate = token/ERG + wtb.addBalance({ nanoergs: 1000n }, buyBtcUsdRegs(buyer, 1n, 20n)); + wtb.addBalance({ nanoergs: 100n }, buyBtcUsdRegs(buyer, 1n, 1n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ergOutput(buyer, 1n, [rsBTC(150)], buyBtcUsdRegs(buyer))]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor] })).to.be.true; + }); + + it(`can't steal value with change erg + 20ERG/BTC for 1000ERG (50BTC max), + 1ERG/BTC for 100ERG (100BTC max)`, () => { + const stealNanoErg = 1n; + wtb.addBalance({ nanoergs: 1000n }, buyBtcUsdRegs(buyer, 1n, 20n)); + wtb.addBalance({ nanoergs: 100n }, buyBtcUsdRegs(buyer, 1n, 1n)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === wtb.ergoTree)) + .from([...wtb.utxos, ...executor.utxos]) + .to([ + ergOutput( + buyer, + 1000n + 20n - stealNanoErg, + [rsBTC(100 - 1)], + buyBtcUsdRegs(buyer) + ) + ]) + .sendChangeTo(executor.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [executor], throw: false })).to.be + .false; + }); + }); +}); diff --git a/tests/dex/helper.ts b/tests/dex/helper.ts new file mode 100644 index 0000000..56ac512 --- /dev/null +++ b/tests/dex/helper.ts @@ -0,0 +1,91 @@ +import { Amount, NonMandatoryRegisters, OneOrMore, TokenAmount } from "@fleet-sdk/common"; +import { OutputBuilder } from "@fleet-sdk/core"; +import { KeyedMockChainParty, NonKeyedMockChainParty } from "@fleet-sdk/mock-chain"; +import { expect } from "vitest"; + +// these are fake token ids!!! +export const TOKENS = { + rsBTC: { + tokenId: "5bf691fbf0c4b17f8f8cece83fa947f62f480bfbd242bd58946f85535125db4d", + name: "rsBTC", + decimals: 9, + type: "EIP-004" + }, + sigUSD: { + tokenId: "f60bff91f7ae3f3a5f0c2d35b46ef8991f213a61d7f7e453d344fa52a42d9f9a", + name: "sigUSD", + decimals: 2, + type: "EIP-004" + }, + comet: { + tokenId: "0cd8c9f416e5b1ca9f986a7f10a84191dfb85941619e49e53c0dc30ebf83324b", + decimals: 0, + name: "Comet", + type: "EIP-004" + } +}; + +export const rsBtcId = TOKENS.rsBTC.tokenId; +export const sigUsdId = TOKENS.sigUSD.tokenId; + +export function rsBTC(amount: number) { + return { + tokenId: TOKENS.rsBTC.tokenId, + amount: BigInt(amount) + }; +} +export function sigUSD(amount: number) { + return { + tokenId: TOKENS.sigUSD.tokenId, + amount: BigInt(amount) + }; +} +export function comet(amount: number) { + return { + tokenId: TOKENS.comet.tokenId, + amount: BigInt(amount) + }; +} + +export function output( + pk: KeyedMockChainParty | NonKeyedMockChainParty, + tokens: OneOrMore>, + regs: NonMandatoryRegisters | undefined = undefined +) { + let output = new OutputBuilder(1_000_000n, pk.address).addTokens(tokens); + if (regs) { + return output.setAdditionalRegisters(regs); + } else { + return output; + } +} + +export function ergOutput( + pk: KeyedMockChainParty | NonKeyedMockChainParty, + nanoErg: bigint, + tokens: OneOrMore>, + regs: NonMandatoryRegisters | undefined = undefined +) { + let output = new OutputBuilder(nanoErg, pk.address).addTokens(tokens); + if (regs) { + return output.setAdditionalRegisters(regs); + } else { + return output; + } +} + +export function expectTokens( + pk: KeyedMockChainParty | NonKeyedMockChainParty, + tokens: TokenAmount[] +) { + function sortTokens(tokens: TokenAmount[]) { + return tokens.slice().sort((a, b) => { + if (a.tokenId < b.tokenId) return -1; + if (a.tokenId > b.tokenId) return 1; + return 0; + }); + } + const sortedPkTokens = sortTokens(pk.balance.tokens); + const sortedTokens = sortTokens(tokens); + expect(sortedPkTokens).toEqual(sortedTokens); +} diff --git a/tests/dex/sell.spec.ts b/tests/dex/sell.spec.ts new file mode 100644 index 0000000..459dbe1 --- /dev/null +++ b/tests/dex/sell.spec.ts @@ -0,0 +1,277 @@ +import { compile } from "@fleet-sdk/compiler"; +import { + SByte, + SColl, + SGroupElement, + SLong, + SSigmaProp, + TransactionBuilder +} from "@fleet-sdk/core"; +import { KeyedMockChainParty, MockChain } from "@fleet-sdk/mock-chain"; +import { afterEach, describe, expect, it } from "vitest"; +import { ergOutput, rsBTC, rsBtcId } from "./helper"; + +describe("Timed fund contract", () => { + const ergoTree = compile(`{ + def getMakerPk(box: Box) = box.R4[SigmaProp].getOrElse(sigmaProp(false)) + def getPaymentAddress(box: Box) = box.R5[Coll[Byte]].getOrElse(Coll[Byte]()) + def getTokenId(box: Box) = box.R6[Coll[Byte]].getOrElse(Coll[Byte]()) + def getRate(box: Box) = box.R7[Coll[Long]].getOrElse(Coll[SigmaProp](0L,0L))(0) + def getDenom(box: Box) = box.R7[Coll[Long]].getOrElse(Coll[SigmaProp](0L,0L))(1) + + + def tokenId(box: Box) = box.tokens(0)._1 + def tokenAmount(box: Box) = box.tokens(0)._2 + + def isSameContract(box: Box) = + box.propositionBytes == SELF.propositionBytes + + def isSameToken(box: Box) = + getTokenId(SELF) == getTokenId(box) && + box.tokens.size > 0 && + getTokenId(SELF) == tokenId(box) + + def isGreaterZeroRate(box:Box) = + getRate(box) > 0 && + getDenom(box) > 0 + + def isSameMaker(box: Box) = + getMakerPk(SELF) == getMakerPk(box) && + getPaymentAddress(SELF) == getPaymentAddress(box) + + + def isLegitInput(b: Box) = { + isSameContract(b) && + isSameToken(b) && + isSameMaker(b) && + isGreaterZeroRate(b) + } + + val maxDenom: Long = INPUTS + .filter(isLegitInput) + .fold(0L, {(r:Long, box:Box) => { + if(r > getDenom(box)) r else getDenom(box) + }}) + + def getRateInMaxDenom(box:Box) = getRate(box)*maxDenom/getDenom(box) + + def sumValue(a:Long, b: Box) = a + b.value + def sumTokenAmountXRate(a:Long, b: Box) = a + tokenAmount(b) * getRateInMaxDenom(b) + + val maxSellRate: Long = INPUTS + .filter(isLegitInput) + .fold(0L, {(r:Long, box:Box) => { + if(r > getRateInMaxDenom(box)) r else getRateInMaxDenom(box) + }}) + + def hasMaxSellRate(box: Box) = + getRate(box) * maxDenom == getDenom(box) * maxSellRate + + def isLegitSellOrderOutput(box: Box) = + isLegitInput(box)&& + hasMaxSellRate(box) + + def isPaymentBox(box:Box) = { + isSameMaker(box) && + getTokenId(SELF) == getTokenId(box) && + getPaymentAddress(SELF) == box.propositionBytes + } + + val nanoErgsPaid: Long = OUTPUTS + .filter(isPaymentBox) + .fold(0L, sumValue) - INPUTS + .filter(isLegitInput) + .fold(0L, sumValue) + + val inSellTokensXRate = INPUTS + .filter(isLegitInput) + .fold(0L, sumTokenAmountXRate) + + val outSellTokensXRate = OUTPUTS + .filter(isLegitSellOrderOutput) + .fold(0L, sumTokenAmountXRate) + + val expectedRate = inSellTokensXRate.toBigInt - outSellTokensXRate.toBigInt + + val isPaidAtFairRate = maxDenom.toBigInt * nanoErgsPaid.toBigInt >= expectedRate.toBigInt + + getMakerPk(SELF) || sigmaProp(isPaidAtFairRate) +}`); + const mockChain = new MockChain({ height: 1_052_944 }); + const maker = mockChain.newParty("Seller"); + const maker2 = mockChain.newParty("Seller2"); + const taker = mockChain.newParty("Bob"); + mockChain.parties; + + const sell = mockChain.addParty(ergoTree.toHex(), "Token Sell Contract"); + + const sellBtcUsdRegs = (pk: KeyedMockChainParty, rate: bigint = 1n, denom: bigint = 1n) => ({ + R4: SSigmaProp(SGroupElement(pk.key.publicKey)).toHex(), + R5: SColl(SByte, pk.ergoTree).toHex(), + R6: SColl(SByte, rsBtcId).toHex(), + R7: SColl(SLong, [rate, denom]).toHex() + }); + + afterEach(() => { + mockChain.reset(); + }); + + describe("Sell", () => { + it("sell 100 rsBTC for 100 nanoErg", () => { + sell.addBalance({ nanoergs: 1_000_000n, tokens: [rsBTC(100)] }, sellBtcUsdRegs(maker)); + taker.addBalance({ nanoergs: 1_000_000n + 100n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 100n, [], sellBtcUsdRegs(maker))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker] })).to.be.true; + }); + + it("sell 1_000 rsBTC for 1 nanoErg", () => { + sell.addBalance( + { nanoergs: 1_000_000n, tokens: [rsBTC(1000)] }, + sellBtcUsdRegs(maker, 1n, 1000n) + ); + taker.addBalance({ nanoergs: 1_000_000n + 1n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 1n, [], sellBtcUsdRegs(maker))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker] })).to.be.true; + }); + + it("can't underpay", () => { + sell.addBalance( + { nanoergs: 1_000_000n, tokens: [rsBTC(1000)] }, + sellBtcUsdRegs(maker, 1n, 1000n) + ); + taker.addBalance({ nanoergs: 1_000_000n + 1n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 0n, [], sellBtcUsdRegs(maker))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker], throw: false })).to.be.false; + }); + + it("sell 2x 100 rsBTC for 200 nanoErg", () => { + const box = { nanoergs: 1_000_000n, tokens: [rsBTC(100)] }; + sell.addBalance(box, sellBtcUsdRegs(maker)); + sell.addBalance(box, sellBtcUsdRegs(maker2)); + taker.addBalance({ nanoergs: 1_000_000n + 200n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 100n, [], sellBtcUsdRegs(maker))]) + .to([ergOutput(maker2, 1_000_000n + 100n, [], sellBtcUsdRegs(maker2))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker] })).to.be.true; + }); + + it("partial sell 2x 100 rsBTC", () => { + const box = { nanoergs: 1_000_000n, tokens: [rsBTC(100)] }; + sell.addBalance(box, sellBtcUsdRegs(maker)); + sell.addBalance(box, sellBtcUsdRegs(maker2)); + taker.addBalance({ nanoergs: 2_000_000n + 200n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 100n, [], sellBtcUsdRegs(maker))]) + .to([ergOutput(maker2, 1_000_000n + 50n, [rsBTC(50)], sellBtcUsdRegs(maker2))]) + .to([ergOutput(sell, 1_000_000n, [rsBTC(50)], sellBtcUsdRegs(maker2))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker] })).to.be.true; + }); + + it("partial sell 2x 100 rsBTC", () => { + const box = { nanoergs: 1_000_000n, tokens: [rsBTC(100)] }; + sell.addBalance(box, sellBtcUsdRegs(maker)); + sell.addBalance(box, sellBtcUsdRegs(maker2)); + taker.addBalance({ nanoergs: 2_000_000n + 200n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 50n, [], sellBtcUsdRegs(maker))]) + .to([ergOutput(maker2, 1_000_000n + 100n, [rsBTC(50)], sellBtcUsdRegs(maker2))]) + .to([ergOutput(sell, 1_000_000n, [rsBTC(50)], sellBtcUsdRegs(maker))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker] })).to.be.true; + }); + + it("partial sell 2x 100 rsBTC", () => { + const box = { nanoergs: 1_000_000n, tokens: [rsBTC(100)] }; + sell.addBalance(box, sellBtcUsdRegs(maker)); + sell.addBalance(box, sellBtcUsdRegs(maker2)); + taker.addBalance({ nanoergs: 3_000_000n + 200n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 50n, [], sellBtcUsdRegs(maker))]) + .to([ergOutput(maker2, 1_000_000n + 50n, [rsBTC(50)], sellBtcUsdRegs(maker2))]) + .to([ergOutput(sell, 1_000_000n, [rsBTC(50)], sellBtcUsdRegs(maker))]) + .to([ergOutput(sell, 1_000_000n, [rsBTC(50)], sellBtcUsdRegs(maker2))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker] })).to.be.true; + }); + + it("fail partial sell of lower rate box in 2x 100 rsBTC", () => { + const box = { nanoergs: 1_000_000n, tokens: [rsBTC(100)] }; + sell.addBalance(box, sellBtcUsdRegs(maker)); + sell.addBalance(box, sellBtcUsdRegs(maker2, 1n, 2n)); + taker.addBalance({ nanoergs: 3_000_000n + 200n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 50n, [], sellBtcUsdRegs(maker))]) + .to([ergOutput(maker2, 1_000_000n + 25n, [rsBTC(50)], sellBtcUsdRegs(maker2))]) + .to([ergOutput(sell, 1_000_000n, [rsBTC(50)], sellBtcUsdRegs(maker))]) + .to([ergOutput(sell, 1_000_000n, [rsBTC(50)], sellBtcUsdRegs(maker2))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker], throw: false })).to.be.false; + }); + + it("success partial sell of higher rate box in 2x 100 rsBTC", () => { + const box = { nanoergs: 1_000_000n, tokens: [rsBTC(100)] }; + sell.addBalance(box, sellBtcUsdRegs(maker)); + sell.addBalance(box, sellBtcUsdRegs(maker2, 1n, 2n)); + taker.addBalance({ nanoergs: 3_000_000n + 200n }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === sell.ergoTree)) + .from([...sell.utxos, ...taker.utxos]) + .to([ergOutput(maker, 1_000_000n + 50n, [], sellBtcUsdRegs(maker))]) + .to([ergOutput(maker2, 1_000_000n + 50n, [rsBTC(50)], sellBtcUsdRegs(maker2))]) + .to([ergOutput(sell, 1_000_000n, [rsBTC(50)], sellBtcUsdRegs(maker))]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker] })).to.be.true; + }); + }); +}); diff --git a/tests/dex/swap.spec.ts b/tests/dex/swap.spec.ts new file mode 100644 index 0000000..c6b93e3 --- /dev/null +++ b/tests/dex/swap.spec.ts @@ -0,0 +1,173 @@ +import { compile } from "@fleet-sdk/compiler"; +import { + SByte, + SColl, + SGroupElement, + SLong, + SSigmaProp, + TransactionBuilder +} from "@fleet-sdk/core"; +import { KeyedMockChainParty, MockChain } from "@fleet-sdk/mock-chain"; +import { SPair } from "@fleet-sdk/serializer"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { expectTokens, output, rsBTC, rsBtcId, sigUSD, sigUsdId } from "./helper"; + +describe("Timed fund contract", () => { + const ergoTree = compile( + `{ + def getMakerPk(box: Box) = box.R4[SigmaProp].getOrElse(sigmaProp(false)) + def getPaymentAddress(box: Box) = box.R5[Coll[Byte]].getOrElse(Coll[Byte]()) + def getSellingTokenId(box: Box) = box.R6[(Coll[Byte],Coll[Byte])].getOrElse((Coll[Byte](),Coll[Byte]()))._1 + def getBuyingTokenId(box: Box) = box.R6[(Coll[Byte],Coll[Byte])].getOrElse((Coll[Byte](),Coll[Byte]()))._2 + def getRate(box: Box) = box.R7[Coll[Long]].getOrElse(Coll[SigmaProp](0L,0L))(0) + def getDenom(box: Box) = box.R7[Coll[Long]].getOrElse(Coll[SigmaProp](0L,0L))(1) + + def tokenId(box: Box) = box.tokens(0)._1 + def tokenAmount(box: Box) = box.tokens(0)._2 + + def isSameContract(box: Box) = + box.propositionBytes == SELF.propositionBytes + + def isSameTokenPair (box: Box) = + getSellingTokenId(SELF) == getSellingTokenId(box) && + getBuyingTokenId(SELF) == getBuyingTokenId(box) + + + def hasSellingToken(box: Box) = + getSellingTokenId(SELF) == getSellingTokenId(box) && + box.tokens.size > 0 && + getSellingTokenId(SELF) == tokenId(box) + + def hasBuyingToken(box: Box) = + getBuyingTokenId(SELF) == getBuyingTokenId(box) && + box.tokens.size > 0 && + getBuyingTokenId(SELF) == tokenId(box) + + def isGreaterZeroRate(box:Box) = + getRate(box) > 0 + + def isSameMaker(box: Box) = + getMakerPk(SELF) == getMakerPk(box) && + getPaymentAddress(SELF) == getPaymentAddress(box) + + def isLegitInput(box: Box) = + isSameContract(box) && + isSameMaker(box) && + isSameTokenPair(box) && + hasSellingToken(box) && + isGreaterZeroRate(box) + + val maxDenom: Long = INPUTS + .filter(isLegitInput) + .fold(0L, {(r:Long, box:Box) => { + if(r > getDenom(box)) r else getDenom(box) + }}) + + def getRateInMaxDenom(box:Box) = getRate(box)*maxDenom/getDenom(box) + + def sumTokenAmount(a:Long, b: Box) = a + tokenAmount(b) + def sumTokenAmountXRate(a:Long, b: Box) = a + tokenAmount(b) * getRateInMaxDenom(b) + + val maxSellRate: Long = INPUTS + .filter(isLegitInput) + .fold(0L, {(r:Long, box:Box) => { + if(r > getRateInMaxDenom(box)) r else getRateInMaxDenom(box) + }}) + + def hasMaxSellRate(box: Box) = + getRate(box) * maxDenom == maxSellRate * getDenom(box) + + def isLegitSellOrderOutput(box: Box) = + isLegitInput(box)&& + hasMaxSellRate(box) + + def isPaymentBox(box:Box) = + isSameMaker(box) && + hasBuyingToken(box) && + getPaymentAddress(SELF) == box.propositionBytes + + def sumSellTokensIn(boxes: Coll[Box]): Long = boxes + .filter(isLegitInput) + .fold(0L, sumTokenAmount) + + + def sumBuyTokensPaid(boxes: Coll[Box]): Long = boxes + .filter(isPaymentBox) + .fold(0L, sumTokenAmount) + val tokensPaid = sumBuyTokensPaid(OUTPUTS).toBigInt + + val inSellTokensXRate = INPUTS + .filter(isLegitInput) + .fold(0L, sumTokenAmountXRate) + + val outSellTokensXRate = OUTPUTS + .filter(isLegitSellOrderOutput) + .fold(0L, sumTokenAmountXRate) + + val sellTokensXRate = inSellTokensXRate.toBigInt - outSellTokensXRate.toBigInt + val expectedRate = sellTokensXRate.toBigInt + + val isPaidAtFairRate = maxDenom.toBigInt*tokensPaid.toBigInt >= expectedRate.toBigInt + + getMakerPk(SELF) || sigmaProp(isPaidAtFairRate) +}` + ); + const mockChain = new MockChain({ height: 1_052_944 }); + const unlockHeight = mockChain.height + 500; + const maker = mockChain.newParty("Seller"); + const maker2 = mockChain.newParty("Seller2"); + const taker = mockChain.newParty("Bob"); + mockChain.parties; + + const swap = mockChain.addParty(ergoTree.toHex(), "Token Swap Contract"); + + const swapBtcUsdRegs = (pk: KeyedMockChainParty, rate: bigint = 1n, denom: bigint = 1n) => ({ + R4: SSigmaProp(SGroupElement(pk.key.publicKey)).toHex(), + R5: SColl(SByte, pk.ergoTree).toHex(), + R6: SPair(SColl(SByte, rsBtcId), SColl(SByte, sigUsdId)).toHex(), + R7: SColl(SLong, [rate, denom]).toHex() + }); + + afterEach(() => { + mockChain.reset(); + }); + + describe("Swap", () => { + beforeEach(() => { + mockChain.jumpTo(unlockHeight + 1); + expect(mockChain.height).to.be.above(unlockHeight); + }); + + it("can be canceled by maker to any address", () => { + swap.addBalance({ nanoergs: 1_000_000n, tokens: [rsBTC(100)] }, swapBtcUsdRegs(maker)); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === swap.ergoTree)) + .from([...swap.utxos]) + .to([output(maker2, [rsBTC(100)])]) + .build(); + + expect(mockChain.execute(transaction, { signers: [maker] })).to.be.true; + expectTokens(maker2, [rsBTC(100)]); + }); + + it("basic: 100 rsBTC -> 100 SigUSD", () => { + swap.addBalance({ nanoergs: 1_000_000n, tokens: [rsBTC(100)] }, swapBtcUsdRegs(maker)); + taker.addBalance({ nanoergs: 10_000_000n, tokens: [sigUSD(200)] }); + + const transaction = new TransactionBuilder(mockChain.height) + .configureSelector((s) => s.ensureInclusion((b) => b.ergoTree === swap.ergoTree)) + .from([...swap.utxos, ...taker.utxos]) + .to([ + output(maker, [sigUSD(100)], swapBtcUsdRegs(maker)), + output(taker, rsBTC(100)) + ]) + .sendChangeTo(taker.address) + .build(); + + expect(mockChain.execute(transaction, { signers: [taker] })).to.be.true; + expectTokens(maker, [sigUSD(100)]); + expectTokens(taker, [rsBTC(100), sigUSD(100)]); + }); + }); +});