337 lines
11 KiB
Vue
337 lines
11 KiB
Vue
<template>
|
|
<needs-provider>
|
|
<phone-card>
|
|
<v-card-title class="big">DCA / TWAP</v-card-title>
|
|
<v-card-subtitle>Multiple tranches over a time range</v-card-subtitle>
|
|
<v-card-text>
|
|
<token-choice v-model="tokenA" class="token-choice mb-1">
|
|
<template v-slot:prepend>
|
|
<v-btn :text="buy ? 'Buy' : 'Sell'" :color="buy ? 'green' : 'red'"
|
|
variant="outlined" @click="buy=!buy" class="bs-button"/>
|
|
</template>
|
|
</token-choice>
|
|
<token-choice v-model="tokenB" class="token-choice">
|
|
<template v-slot:prepend>
|
|
<v-btn :text="!buy ? 'Buy' : 'Sell'" :color="!buy ? 'green' : 'red'"
|
|
variant="outlined" @click="buy=!buy" class="bs-button"/>
|
|
</template>
|
|
</token-choice>
|
|
|
|
<v-chip v-for="r in routes" variant="text">
|
|
{{ s.chain.name }}
|
|
<v-img src="https://upload.wikimedia.org/wikipedia/commons/e/e7/Uniswap_Logo.svg" width="1.5em"/>
|
|
<span class="uniswap-color ml-0 mr-1">v3</span>
|
|
<span>{{pairSymbol}} {{r.fee/10000}}%</span>
|
|
<route-price :route="r" :inverted="routeInverted(r)" class="text-green clickable" @click="inverted=!inverted"/>
|
|
</v-chip>
|
|
|
|
<div v-if="routesPending">
|
|
<v-progress-circular indeterminate/> Searching for {{pairSymbol}} pools...
|
|
</div>
|
|
|
|
<v-alert v-if="!route && !routesPending" text="No pool found!"/>
|
|
|
|
<div v-if="route && !routesPending">
|
|
<v-text-field label='Amount' type="number" step="1" variant="outlined" aria-valuemin="0" min="0"
|
|
v-model="amount" :rules="[validateRequired,validateAmount]" v-auto-select>
|
|
<template v-slot:append-inner>
|
|
<v-btn @click="amountIsTokenA=!amountIsTokenA" variant="outlined" class="mr-2">
|
|
{{ amountIsTokenA ? tokenA.symbol : tokenB.symbol }}
|
|
</v-btn>
|
|
<v-btn :text="amountIsTotal ? 'total' : 'per tranche'" variant="outlined"
|
|
@click="amountIsTotal=!amountIsTotal" class="total"/>
|
|
</template>
|
|
</v-text-field>
|
|
<v-text-field label="Tranches" type="number" variant="outlined" aria-valuemin="1" min="1" max="255"
|
|
v-model="tranches" :rules="[validateRequired,validateTranches]" v-auto-select>
|
|
<!-- <template v-slot:prepend-inner>-->
|
|
<!-- <div>{{ amountIsTotal ? 'Split into' : 'Times' }}</div>-->
|
|
<!-- </template>-->
|
|
<template v-slot:append-inner>tranches</template>
|
|
</v-text-field>
|
|
<v-text-field type="number" variant="outlined" :min="1" v-model="interval" class="interval"
|
|
:label="intervalIsTotal ? 'Completion time' : 'Time between tranches'" v-auto-select>
|
|
<!-- <template v-slot:append>APART</template>-->
|
|
<template v-slot:prepend-inner>
|
|
<v-btn variant="outlined" :text="intervalIsTotal ? 'Within' : 'Spaced apart'" class="within mr-2"
|
|
@click="intervalIsTotal=!intervalIsTotal"/>
|
|
</template>
|
|
<template v-slot:append-inner>
|
|
<v-btn variant="outlined" :text="timeUnits[timeUnitIndex]" @click="toggleTimeUnits" class="time-units"/>
|
|
</template>
|
|
</v-text-field>
|
|
<v-text-field v-model="limitPrice" :label="(limitIsMinimum?'Minimum':'Maximum')+' Price'" type="number"
|
|
variant="outlined" aria-valuemin="0" min="0"
|
|
clearable :rules="[validateAmount, validateMin]" v-auto-select>
|
|
<template v-slot:append-inner>
|
|
<v-btn variant="outlined" @click="inverted=!inverted">
|
|
{{pairSymbol}}
|
|
</v-btn>
|
|
</template>
|
|
<template #details style="flex-direction: column-reverse">
|
|
<div>
|
|
Current price <route-price :inverted="routeInverted(route)" :route="route" class="text-green"/>
|
|
</div>
|
|
</template>
|
|
</v-text-field>
|
|
</div>
|
|
|
|
</v-card-text>
|
|
|
|
<v-card-actions class="d-flex justify-space-evenly mb-4">
|
|
<v-btn variant="outlined" color="red">Cancel</v-btn>
|
|
<v-btn variant="flat" color="green" :disabled="!validOrder" @click="placeOrder">Place Order</v-btn>
|
|
</v-card-actions>
|
|
</phone-card>
|
|
</needs-provider>
|
|
</template>
|
|
|
|
<script setup>
|
|
import {useStore} from "@/store/store";
|
|
import {computed, onBeforeUnmount, ref} from "vue";
|
|
import TokenChoice from "@/components/TokenChoice.vue"
|
|
import PhoneCard from "@/components/PhoneCard.vue";
|
|
// noinspection ES6UnusedImports
|
|
import {SingletonCoroutine, vAutoSelect} from "@/misc.js";
|
|
import {newLimitConstraint, newOrder, newTimeConstraint, sqrtX96, TimeMode} from "@/blockchain/orderlib.js";
|
|
import {FixedNumber} from "ethers";
|
|
import {pendOrder} from "@/blockchain/wallet.js";
|
|
import NeedsProvider from "@/components/NeedsProvider.vue";
|
|
import {findRoute} from "@/blockchain/route.js";
|
|
import RoutePrice from "@/components/RoutePrice.vue";
|
|
|
|
const s = useStore()
|
|
const buy = ref(false)
|
|
let _tokenA = ref(Object.values(s.tokens).length >= 1 ? Object.values(s.tokens)[0] : null)
|
|
let _tokenB = ref(Object.values(s.tokens).length >= 2 ? Object.values(s.tokens)[1] : null)
|
|
const tokenA = computed({
|
|
get() {
|
|
return _tokenA.value
|
|
},
|
|
set(value) {
|
|
if( !_tokenA.value || _tokenA.value.address !== value.address ) {
|
|
_tokenA.value = value
|
|
routeFinder.invoke()
|
|
}
|
|
}
|
|
})
|
|
const tokenB = computed({
|
|
get() {
|
|
return _tokenB.value
|
|
},
|
|
set(value) {
|
|
if( !_tokenB.value || _tokenB.value.address !== value.address ) {
|
|
_tokenB.value = value
|
|
routeFinder.invoke()
|
|
}
|
|
}
|
|
})
|
|
|
|
const pairSymbol = computed(()=>base.value?.symbol+'/'+quote.value?.symbol)
|
|
const base = computed(()=>{
|
|
const token = inverted.value ? _tokenB.value : _tokenA.value
|
|
return !token?{}:token
|
|
})
|
|
const quote = computed(()=>{
|
|
const token = inverted.value ? _tokenA.value : _tokenB.value
|
|
return !token?{}:token
|
|
})
|
|
const _routes = ref([])
|
|
const routes = computed({
|
|
get() {
|
|
return _routes.value
|
|
},
|
|
set(value) {
|
|
console.log('setting new routes', _routes.value, value)
|
|
_routes.value = value
|
|
}
|
|
})
|
|
const route = computed(()=>_routes.value.length===0 ? null : _routes.value[0])
|
|
const routesPending = ref(false)
|
|
const amount = ref(100) // todo 0
|
|
const amountIsTokenA = ref(false)
|
|
const amountIsTotal = ref(true)
|
|
const tranches = ref(3)
|
|
const inverted = ref(false)
|
|
function routeInverted(route) {
|
|
return route && (route.token0 === tokenA.value) === inverted.value
|
|
}
|
|
const minPrice = ref(null)
|
|
const maxPrice = ref(null)
|
|
const limitPrice = ref(null)
|
|
const interval = ref(10)
|
|
const intervalIsTotal = ref(true)
|
|
const timeUnits = ['minutes', 'hours', 'days']
|
|
const timeUnitIndex = ref(1)
|
|
const limitIsMinimum = computed(() => !(buy.value ^ inverted.value))
|
|
const validOrder = computed(()=>amount.value > 0 && routes.value.length > 0 )
|
|
|
|
|
|
async function componentFindRoute() {
|
|
console.log('finding route', _tokenA.value, _tokenB.value)
|
|
routes.value = []
|
|
if (!_tokenA.value || !_tokenB.value)
|
|
return
|
|
routesPending.value = true
|
|
try {
|
|
const result = await findRoute(tokenA.value, tokenB.value)
|
|
console.log('found route', result)
|
|
routes.value = result
|
|
}
|
|
catch (e) {
|
|
console.log('ignoring routes exception', e)
|
|
}
|
|
finally {
|
|
routesPending.value = false
|
|
}
|
|
}
|
|
|
|
|
|
const routeFinder = new SingletonCoroutine(componentFindRoute,10)
|
|
routeFinder.invoke()
|
|
|
|
function toggleTimeUnits() {
|
|
timeUnitIndex.value++
|
|
if (timeUnitIndex.value >= timeUnits.length)
|
|
timeUnitIndex.value = 0
|
|
}
|
|
|
|
|
|
function isEmpty(v) {
|
|
return v === null || typeof v === 'string' && v.trim() === ''
|
|
}
|
|
|
|
|
|
function validateRequired(v) {
|
|
if (isEmpty(v))
|
|
return 'Required'
|
|
return true
|
|
}
|
|
|
|
|
|
function validateTranches(v) {
|
|
const i = parseInt(v)
|
|
if (parseFloat(v) !== i)
|
|
return 'Whole numbers only'
|
|
if (i < 1)
|
|
return 'Must have at least one tranche'
|
|
if (i > 255)
|
|
return 'Maximum 255 tranches'
|
|
return true
|
|
}
|
|
|
|
|
|
function validateAmount(v) {
|
|
if (isEmpty(v))
|
|
return true
|
|
const floatRegex = /^-?\d*(?:[.,]\d*?)?$/
|
|
if (!floatRegex.test(v))
|
|
return 'Amount must be a number'
|
|
if (parseFloat(v) <= 0)
|
|
return 'Amount must be positive'
|
|
return true
|
|
}
|
|
|
|
function validateMax(v) {
|
|
if (!isEmpty(minPrice.value) && !isEmpty(v) && parseFloat(v) < parseFloat(minPrice.value))
|
|
return 'Must be greater than the minimum price'
|
|
return true
|
|
}
|
|
|
|
function validateMin(v) {
|
|
if (!isEmpty(maxPrice.value) && !isEmpty(v) && parseFloat(v) > parseFloat(maxPrice.value))
|
|
return 'Must be less than the maximum price'
|
|
return true
|
|
}
|
|
|
|
function placeOrder() {
|
|
const ta = tokenA.value;
|
|
const tb = tokenB.value;
|
|
const tokenIn = buy.value ? tb.address : ta.address
|
|
const tokenOut = buy.value ? ta.address : tb.address
|
|
const route = routes.value[0];
|
|
const amountToken = amountIsTokenA.value ? ta : tb
|
|
const amt = FixedNumber.fromString(amount.value.toString(), {decimals: amountToken.decimals}).value
|
|
const amountIsInput = amountIsTokenA.value !== buy.value
|
|
|
|
// build tranches
|
|
const n = tranches.value // num tranches
|
|
const ts = []
|
|
let duration = timeUnitIndex.value === 0 ? interval.value * 60 : // minutes
|
|
timeUnitIndex.value === 1 ? interval.value * 60 * 60 : // hours
|
|
interval.value * 24 * 60 * 60; // days
|
|
let window
|
|
if (!intervalIsTotal.value) {
|
|
window = duration
|
|
duration *= n // duration is the total time for all tranches
|
|
} else {
|
|
window = Math.round(duration / n)
|
|
}
|
|
const oneHundredPercent = 65535n // by contract definition of uint16 fraction
|
|
const ceil = oneHundredPercent % BigInt(n) ? 1n : 0n
|
|
const amtPerTranche = oneHundredPercent / BigInt(n) + ceil
|
|
duration -= 15 // subtract 15 seconds so the last tranche completes before the deadline
|
|
console.log('duration', duration)
|
|
let priceConstraint = null
|
|
if( limitPrice.value ) {
|
|
const inverted = routeInverted(route)
|
|
const isAbove = limitIsMinimum.value ^ inverted
|
|
const isRatio = false // todo ratios
|
|
const decimals = 10 ** (tokenA.value.decimals - tokenB.value.decimals)
|
|
const limit = inverted ? decimals/limitPrice.value : limitPrice.value/decimals
|
|
priceConstraint = !limitPrice.value ? null : newLimitConstraint(isAbove, isRatio, limit)
|
|
}
|
|
for (let i = 0; i < n; i++) {
|
|
const start = Math.floor(i * (duration / (n-1)))
|
|
const end = start + window
|
|
console.log('tranche window', start, end, (end-start)/60)
|
|
const cs = [newTimeConstraint(TimeMode.SinceOrderStart, start, TimeMode.SinceOrderStart, end)]
|
|
if( priceConstraint !== null )
|
|
cs.push(priceConstraint)
|
|
ts.push([amtPerTranche, cs])
|
|
}
|
|
const order = newOrder(tokenIn, tokenOut, route.exchange, route.fee, amt, amountIsInput, ts)
|
|
pendOrder(order)
|
|
}
|
|
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
@use "@/styles/vars" as *;
|
|
|
|
.token-choice {
|
|
width: 16em;
|
|
}
|
|
.bs-button {
|
|
width: 6em;
|
|
}
|
|
.amount {
|
|
width: 23em;
|
|
}
|
|
|
|
.total {
|
|
width: 9em;
|
|
}
|
|
|
|
.split-into {
|
|
width: 8em;
|
|
}
|
|
|
|
.v-input {
|
|
margin-top: 1em;
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
.interval {
|
|
//width: 18em;
|
|
}
|
|
|
|
.within {
|
|
width: 10em;
|
|
}
|
|
|
|
.time-units {
|
|
width: 8em;
|
|
}
|
|
</style>
|