diff --git a/docs/wxdao_funding/wxdao_funding.md b/docs/wxdao_funding/wxdao_funding.md new file mode 100644 index 00000000..f80af0cd --- /dev/null +++ b/docs/wxdao_funding/wxdao_funding.md @@ -0,0 +1,26 @@ +# WavesDAO -> WXDAO Funding + +## Keys +| key | type | value | +| :---------------------------------- | --------: | :----------------------------------------------------------- | +| `%s__mainTreasuryAddress` | `String` | `'3PEwRcYNAUtoFvKpBhKoiwajnZfdoDR6h4h'` | +| `%s__WavesUSDTPoolAddress` | `String` | `'3PKfrupEydU2nZAghVjZAfvCwMBkzuR1F52'` | +| `%s__WXDAOcontractAddress` | `String` | `'3PEhMFF2mfSWVxWqbW8guXVYcNeoW77ga7T'` | +| `%s__WXDAOassetId` | `String` | `'BE4VVq1VsrwGyUWpUkNjVFR5j9vzioiRhrUT52p8RW2m'` | +| `%s__USDTassetId` | `String` | `'G5WWWzzVsWRyzGf32xojbnfp7gXbWrgqJT8RcVWEfLmC'` | +| `%s__minClaimAmount` | `Integer` | `30000000000` | +| `%s__processFeeAmount` | `Integer` | `500000` | +| `%s__WXDAOpriceCoeff` | `Integer` | `110000000` (SCALE8) | +| `%s%s%s__history____` | `String` | `'%d%d%d______'` | + + +# Functions + + +## Process +- Waves amount claimed from WavesDAO should be exceed `minClaimAmount` (*default: 300.0*) +- `processFee` (*default: 0.005*) Waves amount is sent back to caller +``` +@Callable(i) +func process() +``` diff --git a/migrations/2024_03_29_wxdao_funding/01_wxdao_funding_data_tx.json b/migrations/2024_03_29_wxdao_funding/01_wxdao_funding_data_tx.json new file mode 100644 index 00000000..8e673940 --- /dev/null +++ b/migrations/2024_03_29_wxdao_funding/01_wxdao_funding_data_tx.json @@ -0,0 +1,48 @@ +{ + "type": 12, + "fee": 900000, + "version": 2, + "senderPublicKey": "5WG53hB4ZK2TWX7UnLeTU4ieDp8Fy4VtaYx6gKFYxGHu", + "data": [ + { + "key": "%s__mainTreasuryAddress", + "type": "string", + "value": "3PEwRcYNAUtoFvKpBhKoiwajnZfdoDR6h4h" + }, + { + "key": "%s__WavesUSDTPoolAddress", + "type": "string", + "value": "3PKfrupEydU2nZAghVjZAfvCwMBkzuR1F52" + }, + { + "key": "%s__WXDAOcontractAddress", + "type": "string", + "value": "3PEhMFF2mfSWVxWqbW8guXVYcNeoW77ga7T" + }, + { + "key": "%s__WXDAOassetId", + "type": "string", + "value": "BE4VVq1VsrwGyUWpUkNjVFR5j9vzioiRhrUT52p8RW2m" + }, + { + "key": "%s__USDTassetId", + "type": "string", + "value": "G5WWWzzVsWRyzGf32xojbnfp7gXbWrgqJT8RcVWEfLmC" + }, + { + "key": "%s__minClaimAmount", + "type": "integer", + "value": 30000000000 + }, + { + "key": "%s__processFeeAmount", + "type": "integer", + "value": 500000 + }, + { + "key": "%s__WXDAOpriceCoeff", + "type": "integer", + "value": 110000000 + } + ] +} \ No newline at end of file diff --git a/ride/wxdao_funding.ride b/ride/wxdao_funding.ride new file mode 100644 index 00000000..32b487d0 --- /dev/null +++ b/ride/wxdao_funding.ride @@ -0,0 +1,195 @@ +{-# STDLIB_VERSION 6 #-} +{-# CONTENT_TYPE DAPP #-} +{-# SCRIPT_TYPE ACCOUNT #-} + +let SEP = "__" +let CONTRACT_NAME = "wxdao_funding.ride" +let SCALE8 = 100_000_000 +let WAVES = "WAVES" + +func wrapErr(s: String) = { + CONTRACT_NAME + ": " + s +} + +func throwErr(s: String) = { + throw(wrapErr(s)) +} + +func keyMainTreasuryAddress() = ["%s", "mainTreasuryAddress"].makeString(SEP) +func keyWavesUSDTPoolAddress() = ["%s", "WavesUSDTPoolAddress"].makeString(SEP) +func keyWXDAOAddress() = ["%s", "WXDAOcontractAddress"].makeString(SEP) +func keyWXDAOassetId() = ["%s", "WXDAOassetId"].makeString(SEP) +func keyUSDTassetId() = ["%s", "USDTassetId"].makeString(SEP) +func keyProcessFeeAmount() = ["%s", "processFeeAmount"].makeString(SEP) +func keyMinClaimAmount() = ["%s", "minClaimAmount"].makeString(SEP) +func keyWXDAOpriceCoeff() = ["%s", "WXDAOpriceCoeff"].makeString(SEP) + +func keyHistory(action: String, txId: String) = ["%s%s%s", "history", action, txId].makeString(SEP) + +func formatProcessHistory(claimedWavesAmount: Int, sendWXDAOamount: Int, processFeeAmount: Int) = { + [ + "%d%d%d", + claimedWavesAmount.toString(), + sendWXDAOamount.toString(), + processFeeAmount.toString() + ].makeString(SEP) +} + +let mainTreasuryAddressString = this.getStringValue(keyMainTreasuryAddress()) +let mainTreasuryAddressOrFail = mainTreasuryAddressString.addressFromStringValue() +let wavesUSDTpoolAddressString = this.getStringValue(keyWavesUSDTPoolAddress()) +let wavesUSDTpoolAddressOrFail = wavesUSDTpoolAddressString.addressFromStringValue() +let wxdaoAddressString = this.getStringValue(keyWXDAOAddress()) +let wxdaoAddressOrFail = wxdaoAddressString.addressFromStringValue() + +func stringToAsset(assetIdString: String) = { + if (assetIdString == WAVES) then unit else assetIdString.fromBase58String() +} + +func assetToString(assetId: ByteVector|Unit) = { + match (assetId) { + case b:ByteVector => b.toBase58String() + case _ => WAVES + } +} + +func getBalance(address: Address, assetIdString: String) = { + let assetId = stringToAsset(assetIdString) + + match (assetId) { + case b:ByteVector => address.assetBalance(b) + case _ => address.wavesBalance().available + } +} + +let processFeeAmount = this.getInteger(keyProcessFeeAmount()).valueOrElse(500000) +let minClaimAmount = this.getInteger(keyMinClaimAmount()).valueOrElse(0) +let wxdaoPriceCoeff = this.getInteger(keyWXDAOpriceCoeff()).valueOrElse(SCALE8) +let usdtAssetIdString = this.getStringValue(keyUSDTassetId()) +let wxdaoAssetIdString = this.getStringValue(keyWXDAOassetId()) +let wxdaoAssetId = stringToAsset(wxdaoAssetIdString) + + +##### PROPOSAL VERIFIER ##### +func keyVotingResultAddress() = "contract_voting_result" +func keyProposalAllowBroadcast(address: Address, txId: ByteVector) = + ((("proposal_allow_broadcast_" + toString(address)) + "_") + toBase58String(txId)) + +let votingResultAddress = match getString(this, keyVotingResultAddress()) { + case s: String => + addressFromString(s) + case _: Unit => + unit + case _ => + throw("Match error") +} +##### PROPOSAL VERIFIER ##### + +func claimWavesFromTreasury() = { + strict oldWavesBalance = this.getBalance(WAVES) + strict getWavesInvoke = mainTreasuryAddressOrFail.invoke("Claim", [], []) + strict newWavesBalance = this.getBalance(WAVES) + + newWavesBalance - oldWavesBalance +} + +func getPoolBalance(poolAddress: Address, assetIdString: String) = { + let invokeResult = poolAddress.invoke("getAccBalanceWrapperREADONLY", [assetIdString], []) + match (invokeResult) { + case balance:Int => balance + case _ => "getAccBalanceWrapperREADONLY unexpected value".throwErr() + } +} + +func getWavesUSDTPrice() = { + let poolWavesBalance = wavesUSDTpoolAddressOrFail.getPoolBalance(WAVES) + let poolUsdtBalance = wavesUSDTpoolAddressOrFail.getPoolBalance(usdtAssetIdString) + + strict ch = [ + poolWavesBalance > 0 || "WAVES/USDT pool Waves balance should be greater that 0".throwErr(), + poolUsdtBalance > 0 || "WAVES/USDT pool USDT balance should be greater that 0".throwErr() + ] + + fraction(poolUsdtBalance, SCALE8, poolWavesBalance) +} + +func getWXDAOUsdtPrice() = { + let priceInvoke = wxdaoAddressOrFail.invoke("call", ["price", []], []) + let invokeResult = match (priceInvoke) { + case r:List[Any] => { + match(r[0]) { + case i:Int => fraction(i, wxdaoPriceCoeff, SCALE8) + case _ => unit + } + } + case _ => unit + } + let wxDAOprice = invokeResult.valueOrErrorMessage("Unexpected WXDAO Price invoke result".wrapErr()) + + strict ch = [ + wxDAOprice > 0 || "WXDAO price should be greater than 0".throwErr() + ] + + wxDAOprice +} + +func getWavesWXDAOPrice() = { + let wavesUsdtPrice = getWavesUSDTPrice() + let wxdaoUsdtPrice = getWXDAOUsdtPrice() + + fraction(wavesUsdtPrice, SCALE8, wxdaoUsdtPrice) +} + +func calcWXDAOamount(wavesAmount: Int) = { + let price = getWavesWXDAOPrice() + let wxDAOamount = fraction(wavesAmount, price, SCALE8) + + strict ch =[ + wavesAmount > 0 || "wavesAmount should be greater than 0".throwErr(), + price > 0 || "price should be greater than 0".throwErr(), + wxDAOamount > 0 || "wxDAO swap amount is 0".throwErr() + ] + + wxDAOamount +} + +@Callable(i) +func process() = { + strict claimedWavesAmount = claimWavesFromTreasury() + strict swapWavesAmount = claimedWavesAmount - processFeeAmount + strict wxDAOsendAmount = calcWXDAOamount(swapWavesAmount) + + strict ch = [ + claimedWavesAmount >= minClaimAmount || + ["not enough claim amount (", claimedWavesAmount.toString(), " < ", minClaimAmount.toString(), ")"].makeString("").throwErr(), + swapWavesAmount > 0 || "claimed waves amount should be greater than processing fee".throwErr(), + wxDAOsendAmount > 0 || "WXDAO send amount should be greater than 0".throwErr() + ] + + let processingCaller = i.originCaller + + [ + ScriptTransfer(processingCaller, processFeeAmount, stringToAsset(WAVES)), + ScriptTransfer(mainTreasuryAddressOrFail, wxDAOsendAmount, wxdaoAssetId), + StringEntry( + keyHistory("process", i.transactionId.toBase58String()), + formatProcessHistory(claimedWavesAmount, wxDAOsendAmount, processFeeAmount) + ) + ] +} + + +@Verifier(tx) +func verify () = { + let byProposal = match votingResultAddress { + case proposalAddress: Address => + valueOrElse(getBoolean(proposalAddress, keyProposalAllowBroadcast(this, tx.id)), false) + case _ => + false + } + + let byOwner = sigVerify(tx.bodyBytes, tx.proofs[0], tx.senderPublicKey) + if (byProposal) + then true + else byOwner +} diff --git a/test/components/wxdao_funding/_hooks.mjs b/test/components/wxdao_funding/_hooks.mjs new file mode 100644 index 00000000..39fbcadc --- /dev/null +++ b/test/components/wxdao_funding/_hooks.mjs @@ -0,0 +1,101 @@ +import { address, randomSeed, publicKey } from '@waves/ts-lib-crypto'; +import { + data, + massTransfer, + issue, +} from '@waves/waves-transactions'; +import { format } from 'path'; +import { setScriptFromFile } from '../../utils/utils.mjs'; + +import { + broadcastAndWait, chainId, baseSeed, +} from '../../utils/api.mjs'; + +const seedWordsCount = 5; +const ridePath = '../ride'; +const mockPath = 'components/wxdao_funding/mock'; +const wxdaoFundingPath = format({ dir: ridePath, base: 'wxdao_funding.ride' }); +const wxdaoMockPath = format({ dir: mockPath, base: 'wxdao.mock.ride' }); +const poolMockPath = format({ dir: mockPath, base: 'pool.mock.ride' }); +const mainTreasury = format({ dir: mockPath, base: 'mainTreasury.mock.ride' }); + +export const mochaHooks = { + async beforeAll() { + const names = [ + 'wxdaoFunding', + 'wxdao', + 'mainTreasury', + 'wavesUsdtPool', + 'user1', + ]; + this.accounts = Object.fromEntries(names.map((item) => { + const itemSeed = randomSeed(seedWordsCount); + return [ + item, + { seed: itemSeed, addr: address(itemSeed, chainId), publicKey: publicKey(itemSeed) }, + ]; + })); + const amount = 100e8; + const massTransferTx = massTransfer({ + transfers: Object.values(this.accounts).map((item) => ({ recipient: item.addr, amount })), + chainId, + }, baseSeed); + await broadcastAndWait(massTransferTx); + + this.wxdaoAssetId = await broadcastAndWait(issue({ + quantity: 1e6 * 1e8, + decimals: 8, + name: 'WXDAO', + description: 'WXDAO', + chainId, + }, baseSeed)).then((tx) => tx.id); + + await broadcastAndWait(massTransfer({ + transfers: Object.values(this.accounts).map((item) => ({ recipient: item.addr, amount })), + chainId, + assetId: this.wxdaoAssetId, + }, baseSeed)); + + await broadcastAndWait(data({ + additionalFee: 4e5, + data: [ + { + key: '%s__mainTreasuryAddress', + type: 'string', + value: this.accounts.mainTreasury.addr, + }, + { + key: '%s__WavesUSDTPoolAddress', + type: 'string', + value: this.accounts.wavesUsdtPool.addr, + }, + { + key: '%s__WXDAOcontractAddress', + type: 'string', + value: this.accounts.wxdao.addr, + }, + { + key: '%s__WXDAOassetId', + type: 'string', + value: this.wxdaoAssetId, + }, + { + key: '%s__USDTassetId', + type: 'string', + value: 'MOCKED_USDT_ASSET_ID', + }, + { + key: '%s__WXDAOpriceCoeff', + type: 'integer', + value: 1_1000_0000, // 110% + }, + ], + chainId, + }, this.accounts.wxdaoFunding.seed)); + + await setScriptFromFile(wxdaoFundingPath, this.accounts.wxdaoFunding.seed); + await setScriptFromFile(wxdaoMockPath, this.accounts.wxdao.seed); + await setScriptFromFile(poolMockPath, this.accounts.wavesUsdtPool.seed); + await setScriptFromFile(mainTreasury, this.accounts.mainTreasury.seed); + }, +}; diff --git a/test/components/wxdao_funding/mock/mainTreasury.mock.ride b/test/components/wxdao_funding/mock/mainTreasury.mock.ride new file mode 100644 index 00000000..6ad57ba0 --- /dev/null +++ b/test/components/wxdao_funding/mock/mainTreasury.mock.ride @@ -0,0 +1,11 @@ +{-# STDLIB_VERSION 6 #-} +{-# CONTENT_TYPE DAPP #-} +{-# SCRIPT_TYPE ACCOUNT #-} + +# Send 1.5 WAVES +@Callable(i) +func Claim() = { + [ + ScriptTransfer(i.caller, 1_5000_0000, unit) + ] +} diff --git a/test/components/wxdao_funding/mock/pool.mock.ride b/test/components/wxdao_funding/mock/pool.mock.ride new file mode 100644 index 00000000..be22984e --- /dev/null +++ b/test/components/wxdao_funding/mock/pool.mock.ride @@ -0,0 +1,13 @@ +{-# STDLIB_VERSION 6 #-} +{-# CONTENT_TYPE DAPP #-} +{-# SCRIPT_TYPE ACCOUNT #-} + +# Send 1.0 WAVES or 5.5 USDT +@Callable(i) +func getAccBalanceWrapperREADONLY(assetIdString: String) = { + let balance = if(assetIdString == "WAVES") + then 1_0000_0000 + else 5_500_000 + + ([], balance) +} diff --git a/test/components/wxdao_funding/mock/wxdao.mock.ride b/test/components/wxdao_funding/mock/wxdao.mock.ride new file mode 100644 index 00000000..36224829 --- /dev/null +++ b/test/components/wxdao_funding/mock/wxdao.mock.ride @@ -0,0 +1,10 @@ +{-# STDLIB_VERSION 6 #-} +{-# CONTENT_TYPE DAPP #-} +{-# SCRIPT_TYPE ACCOUNT #-} + +# WXDAO Price == 11.0 USDT +@Callable(i) +func call(function: String, args: List[String]) = { + + ([], [11_000_000]) +} diff --git a/test/components/wxdao_funding/processClaim.spec.mjs b/test/components/wxdao_funding/processClaim.spec.mjs new file mode 100644 index 00000000..6adabbb9 --- /dev/null +++ b/test/components/wxdao_funding/processClaim.spec.mjs @@ -0,0 +1,55 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { invokeScript } from '@waves/waves-transactions'; +import { + chainId, broadcastAndWait, +} from '../../utils/api.mjs'; + +chai.use(chaiAsPromised); +const { expect } = chai; + +describe('wxdao_funding: process', /** @this {MochaSuiteModified} */() => { + it( + 'should successfully claim Waves and send WXDAO according to Price', + async function () { + // Prices set in mocks + const claimAmount = 1.5 * 1e8; + const wavesUsdtPrice = 5.5 * 1e6; + const wxdaoUsdtPrice = 11.0 * 1e6 * 1.1; + const processFee = 0.005 * 1e8; + + const swapAmount = claimAmount - processFee; + const wavesWxdaoPrice = Math.floor((wavesUsdtPrice / wxdaoUsdtPrice) * 1e8); + const expectedWxdaoSendAmount = Math.floor((swapAmount / 1e8) * wavesWxdaoPrice); + + const { id: txId, stateChanges } = await broadcastAndWait(invokeScript({ + dApp: this.accounts.wxdaoFunding.addr, + call: { + function: 'process', + }, + chainId, + }, this.accounts.user1.seed)); + + expect(stateChanges.transfers).to.deep.equal([ + { + asset: null, + amount: processFee, + address: this.accounts.user1.addr, + }, + { + asset: this.wxdaoAssetId, + amount: expectedWxdaoSendAmount, + address: this.accounts.mainTreasury.addr, + }, + ]); + + expect(stateChanges.data).to.deep.equal([ + { + key: `%s%s%s__history__process__${txId}`, + type: 'string', + value: `%d%d%d__${claimAmount}__${expectedWxdaoSendAmount}__${processFee}`, + }, + ]); + }, + ); +}); diff --git a/test/components/wxdao_funding/processClaim_minClaimAmount.spec.mjs b/test/components/wxdao_funding/processClaim_minClaimAmount.spec.mjs new file mode 100644 index 00000000..b93fc976 --- /dev/null +++ b/test/components/wxdao_funding/processClaim_minClaimAmount.spec.mjs @@ -0,0 +1,38 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { invokeScript, data } from '@waves/waves-transactions'; +import { + chainId, broadcastAndWait, +} from '../../utils/api.mjs'; + +chai.use(chaiAsPromised); +const { expect } = chai; + +describe('wxdao_funding: process', /** @this {MochaSuiteModified} */() => { + it( + 'should fail if Waves amount is less than minClaimAmount', + async function () { + await broadcastAndWait(data({ + additionalFee: 4e5, + data: [ + { + key: '%s__minClaimAmount', + type: 'integer', + value: 300_0000_0000, // 300.0 WAVES + }, + ], + chainId, + }, this.accounts.wxdaoFunding.seed)); + + const invoke = invokeScript({ + dApp: this.accounts.wxdaoFunding.addr, + call: { + function: 'process', + }, + chainId, + }, this.accounts.user1.seed); + + return expect(broadcastAndWait(invoke)).to.be.rejectedWith('not enough claim amount'); + }, + ); +});