initial checkin with timed order ui

This commit is contained in:
Tim Olson
2023-08-27 21:01:17 -04:00
commit 986a876f86
39 changed files with 2592 additions and 0 deletions

6
src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<router-view/>
</template>
<script setup>
</script>

42
src/blockchain/abi.js Normal file
View File

@@ -0,0 +1,42 @@
export const erc20Abi = [
'function name() view returns (string)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
'function totalSupply() view returns (uint256)',
'function balanceOf(address) view returns (uint256)',
'function transfer(address,uint256) returns (bool)',
'function transferFrom(address,address,uint256) returns (bool)',
'function approve(address,uint256) returns (bool success)',
'function allowance(address,address) view returns (uint256)',
'event Transfer(address indexed,address indexed,uint256)',
'event Approval(address indexed,address indexed,uint256)',
]
const TimedOrderSpec = '(' +
'address tokenIn,' +
'address tokenOut,' +
'uint24 fee,' +
'uint32 deadline,' +
'uint32 leeway,' +
'uint160 minSqrtPriceX96,' +
'uint160 maxSqrtPriceX96,' +
'uint8 numTranches,' +
'uint256 amount,' +
'bool amountIsInput' +
')'
export const timedOrderAbi = [
'event TimedOrderCreated (address owner, uint64 index, Spec spec)',
'event TimedOrderFilled (address owner, uint64 index, uint256 amountIn, uint256 amountOut)',
'event TimedOrderCompleted (address owner, uint64 index)',
'event TimedOrderError (address owner, uint64 index, string reason)',
`timedOrder(${TimedOrderSpec}) returns (uint64 index)`,
]
export const abi = {
'ERC20': erc20Abi,
'TimedOrder': timedOrderAbi,
}

112
src/blockchain/wallet.js Normal file
View File

@@ -0,0 +1,112 @@
import {ethers} from "ethers";
import {useStore} from "@/store/store";
const store = useStore()
class Wallet {
connected = false
_provider = null
_signer = null
_onSignerInits = {}
_enabled = true // prevents tight loops on errors
async connect() {
this._enabled = true
if( !this.connected )
await this.signer()
}
async provider() {
if (this._provider === null && this._enabled) {
console.log('creating provider')
const provider = new ethers.BrowserProvider(window.ethereum, store.chain.id)
await provider.getNetwork() // this invokes a check on having the correct network connected
this._provider = provider
console.log('wallet connected')
}
return this._provider
}
async signer() {
/*
if( !store.geo.approved ) {
console.log('not approved')
this._connected(false)
return null
}
*/
const provider = await this.provider()
if( provider === null ) {
console.log('provider null')
this._connected(false)
return null
}
const signer = await provider.getSigner()
if( this._signer?.address !== signer.address ) {
console.log('new signer', signer.address)
this._signer = signer
for (const key in this._onSignerInits) {
try {
await this._onSignerInits[key](signer)
}
catch (e) {
console.log('during onSignerInit', e)
}
}
console.log('wallet connected')
}
this._connected(true)
return signer
}
async address() {
const signer = await this.signer()
if( signer === null )
return null
return await signer.getAddress()
}
_connected(value) {
this.connected = value
store.$patch({wallet:{connected:value}})
}
onSignerInit(id, cb) {
this._onSignerInits[id] = cb
}
}
const handler = {
get(target, prop, proxy) {
const got = Reflect.get(target, prop, proxy);
if( typeof got !== 'function' ) {
return got
}
else {
return async function (...args) {
try {
return await got.apply(target, args)
}
catch (x) {
target._connected(false)
target._enabled = false
if( x.code === 'NETWORK_ERROR' ) {
store.error('Wrong Blockchain', 'Your wallet is connected to a different blockchain. Please select '+import.meta.env.VITE_CHAIN_NAME+' in your wallet.')
console.log('wallet network error', x)
}
else {
console.log('wallet error')
throw x
}
}
}
}
}
}
export default new Proxy(new Wallet(), handler)

View File

@@ -0,0 +1,22 @@
<template>
<v-btn :text="modelValue ? 'Buy' : 'Sell'" :color="modelValue ? 'green' : 'red'"
variant="outlined" size="x-large" @click="toggle"/>
</template>
<script setup>
import {useStore} from "@/store/store";
const s = useStore()
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
function toggle() {
emit('update:modelValue', !props.modelValue)
}
</script>
<style scoped lang="scss">
@use "src/styles/vars" as *;
</style>

View File

@@ -0,0 +1,75 @@
<template>
<v-container class="fill-height">
<v-responsive class="align-center text-center fill-height">
<v-img height="300" src="@/assets/logo.svg" />
<div class="text-body-2 font-weight-light mb-n1">Welcome to</div>
<h1 class="text-h2 font-weight-bold">Vuetify</h1>
<div class="py-14" />
<v-row class="d-flex align-center justify-center">
<v-col cols="auto">
<v-btn
href="https://vuetifyjs.com/components/all/"
min-width="164"
rel="noopener noreferrer"
target="_blank"
variant="text"
>
<v-icon
icon="mdi-view-dashboard"
size="large"
start
/>
Components
</v-btn>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
href="https://vuetifyjs.com/introduction/why-vuetify/#feature-guides"
min-width="228"
rel="noopener noreferrer"
size="x-large"
target="_blank"
variant="flat"
>
<v-icon
icon="mdi-speedometer"
size="large"
start
/>
Get Started
</v-btn>
</v-col>
<v-col cols="auto">
<v-btn
href="https://community.vuetifyjs.com/"
min-width="164"
rel="noopener noreferrer"
target="_blank"
variant="text"
>
<v-icon
icon="mdi-account-group"
size="large"
start
/>
Community
</v-btn>
</v-col>
</v-row>
</v-responsive>
</v-container>
</template>
<script setup>
//
</script>

View File

@@ -0,0 +1,40 @@
<template>
<token-choice v-model="modelValue.tokenA" class="token-choice mb-1">
<template v-slot:prepend>
<v-btn :text="modelValue.buy ? 'Buy' : 'Sell'" :color="modelValue.buy ? 'green' : 'red'"
variant="outlined" @click="modelValue.buy=!modelValue.buy" class="bs-button"/>
</template>
</token-choice>
<token-choice v-model="modelValue.tokenB" class="token-choice">
<template v-slot:prepend>
<v-btn :text="!modelValue.buy ? 'Buy' : 'Sell'" :color="!modelValue.buy ? 'green' : 'red'"
variant="outlined" @click="modelValue.buy=!modelValue.buy" class="bs-button"/>
</template>
</token-choice>
</template>
<script setup>
import {useStore} from "@/store/store";
import TokenChoice from "@/components/TokenChoice.vue";
import {computed, ref} from "vue";
const s = useStore()
// {
// tokenA, tokenB, buy
// }
const props = defineProps(['modelValue'])
</script>
<style scoped lang="scss">
@use "src/styles/vars" as *;
.token-choice {
width: 16em;
}
.bs-button {
width: 6em;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<v-card title="DCA / TWAP" subtitle="Split order across time" class="order-card" elevation="4">
<v-card-text>
<pair-entry v-model="pair"/>
<v-text-field label='Amount' type="number" step="1" variant="outlined" aria-valuemin="0" min="0"
:model-value="amount" :rules="[validateRequired,validateAmount]" class="amount">
<template v-slot:append-inner>
<v-btn @click="amountIsBase=!amountIsBase" variant="outlined">
{{ amountIsBase ? pair.tokenA.symbol : pair.tokenB.symbol }}
</v-btn>
</template>
<template v-slot:append>
<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"
:model-value="tranches" :rules="[validateRequired]">
<template v-slot:prepend>
<v-btn class="split-into" variant="outlined" @click="amountIsTotal=!amountIsTotal">
{{ amountIsTotal ? 'Split into' : 'Times' }}
</v-btn>
</template>
</v-text-field>
<v-text-field type="number" variant="outlined" :min="1" v-model="interval" class="interval"
:label="intervalIsTotal ? 'Complete within' : 'Time between tranches'">
<!-- <template v-slot:append>APART</template>-->
<template v-slot:prepend>
<v-btn variant="outlined" :text="intervalIsTotal ? 'Within' : 'Spaced apart'" class="within"
@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]">
<template v-slot:append-inner>
<v-btn variant="outlined" @click="inverted=!inverted">
{{ inverted ? pair.tokenB.symbol + '/' + pair.tokenA.symbol : pair.tokenA.symbol + '/' + pair.tokenB.symbol }}
</v-btn>
</template>
</v-text-field>
<!--
<v-text-field v-model="minPrice" label="Minimum Price" type="number" variant="outlined" aria-valuemin="0" min="0"
clearable :rules="[validateAmount, validateMin]">
<template v-slot:append-inner>
<v-btn variant="outlined" @click="inverted=!inverted">
{{ inverted ? pair.tokenB.symbol + '/' + pair.tokenA.symbol : pair.tokenA.symbol + '/' + pair.tokenB.symbol }}
</v-btn>
</template>
</v-text-field>
<v-text-field v-model="maxPrice" label="Maximum Price" type="number" variant="outlined" aria-valuemin="0" min="0"
clearable :rules="[validateAmount, validateMax]">
<template v-slot:append-inner>
<v-btn variant="outlined" @click="inverted=!inverted">
{{ inverted ? pair.tokenB.symbol + '/' + pair.tokenA.symbol : pair.tokenA.symbol + '/' + pair.tokenB.symbol }}
</v-btn>
</template>
</v-text-field>
-->
</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">Place Order</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import {useStore} from "@/store/store";
import {computed, ref} from "vue";
import PairEntry from "@/components/PairEntry.vue";
const s = useStore()
const pair = ref({tokenA: s.tokens[0], tokenB: s.tokens[1], buy: true})
const amount = ref(1)
const amountIsBase = ref(false)
const amountIsTotal = ref(true)
const tranches = ref(3)
const inverted = ref(false)
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(()=>!(pair.value.buy ^ inverted.value))
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 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) {
console.log('validate max',v,isEmpty(v),minPrice,isEmpty(minPrice))
if( !isEmpty(minPrice.value) && !isEmpty(v) && parseFloat(v) < parseFloat(minPrice.value) )
return 'Must be greater than the minimum price'
return true
}
function validateMin(v) {
console.log('validate min',v,isEmpty(v),maxPrice,isEmpty(maxPrice))
if( !isEmpty(maxPrice.value) && !isEmpty(v) && parseFloat(v) > parseFloat(maxPrice.value) )
return 'Must be less than the maximum price'
return true
}
</script>
<style scoped lang="scss">
@use "@/styles/vars" as *;
.order-card {
width: 25em;
}
.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>

View File

@@ -0,0 +1,18 @@
<template>
<v-chip>
<!-- <v-avatar start :image="token.icon === null ? '' : token.icon"/>-->
{{token.symbol}}&nbsp;{{token.name}}
</v-chip>
</template>
<script setup>
import {useStore} from "@/store/store";
const s = useStore()
const props = defineProps(['token'])
</script>
<style scoped lang="scss">
@use "src/styles/vars" as *;
</style>

View File

@@ -0,0 +1,105 @@
<template>
<v-combobox :items="s.tokens" :auto-select-first="true"
item-title="symbol"
:filter-keys="['raw.name','raw.symbol','raw.address']"
:model-value="modelValue"
:error-messages="errors"
:loading="loading"
:hide-selected="true"
:label="label"
v-bind="modelValue" @update:modelValue="updateValue"
variant="outlined"
>
<template v-slot:prepend><slot name="prepend"/></template>
<template v-slot:item="{props,item}">
<v-list-item v-bind="props" :prepend-avatar="item.raw.image===null?'':item.raw.image" :title="item.raw.symbol" :subtitle="item.raw.name"/>
</template>
</v-combobox>
</template>
<script setup>
import {useStore as useStore2} from "@/store/store";
import {ref} from "vue";
import {ethers} from "ethers";
const s = useStore2()
const props = defineProps(['modelValue', 'label'])
const emit = defineEmits(['update:modelValue'])
const loading = ref(false)
const errors = ref([])
function good() {
errors.value = []
loading.value = false;
}
function error(msg) {
errors.value = [msg]
loading.value = false;
}
function updateValue(v) {
if( v === null ) {
error('Type a token name, symbol, or address')
}
else if( typeof v !== 'string' ) {
emit('update:modelValue', v);
good()
}
else {
try {
const addr = ethers.getAddress(v);
const found = s.tokens[addr]
if(found !== undefined ) {
good()
emit('update:modelValue', found)
return
}
errors.value = []
loading.value = true;
addExtraToken(addr).then((info)=>{
if(loading.value && errors.value.length===0) {
good()
emit('update:modelValue', info)
}
}).catch((e)=>{
console.log(e)
error(`${addr} is not a valid ERC20 token`)
})
}
catch {
error('Invalid token or address')
}
}
}
</script>
<script>
import {ethers} from "ethers";
import wallet from "@/blockchain/wallet.js";
import {erc20Abi} from "@/blockchain/abi.js";
import {useStore} from "@/store/store.js";
const s = useStore()
async function addExtraToken(addr) {
const token = new ethers.Contract(addr, erc20Abi, await wallet.provider())
const symbol = await token.symbol()
const decimals = Number(await token.decimals())
const info = {name:`${symbol} (${addr})`, symbol, decimals, address:addr}
s.$patch((state)=>{
let extras = state.extraTokens[state.chain.id]
if( extras === undefined ) {
extras = {}
state.extraTokens[state.chain.id] = extras
}
extras[info.address] = info
})
return info
}
</script>
<style scoped lang="scss">
@use "src/styles/vars" as *;
</style>

View File

@@ -0,0 +1,11 @@
<template>
<v-app-bar flat>
<v-app-bar-title class="sc">
<v-icon icon="mdi-chart-line" />
DexOrder
</v-app-bar-title>
</v-app-bar>
</template>
<script setup>
</script>

View File

@@ -0,0 +1,11 @@
<template>
<v-app>
<default-bar />
<default-view />
</v-app>
</template>
<script setup>
import DefaultBar from './AppBar.vue'
import DefaultView from './View.vue'
</script>

View File

@@ -0,0 +1,10 @@
<template>
<v-main>
<v-container>
<router-view />
</v-container>
</v-main>
</template>
<script setup>
</script>

26
src/main.js Normal file
View File

@@ -0,0 +1,26 @@
/**
* main.js
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Components
import App from './App.vue'
// Composables
import { createApp } from 'vue'
// Plugins
import { registerPlugins } from '@/plugins'
import '@/styles/style.scss'
import {useStore} from "@/store/store.js";
const app = createApp(App)
registerPlugins(app)
const s = useStore()
s.chains = known_chains
s.chain = known_chains[0]
app.mount('#app')

19
src/plugins/index.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* plugins/index.js
*
* Automatically included in `./src/main.js`
*/
// Plugins
import { loadFonts } from './webfontloader'
import vuetify from './vuetify'
import pinia from '../store'
import router from '../router'
export function registerPlugins (app) {
loadFonts()
app
.use(vuetify)
.use(router)
.use(pinia)
}

35
src/plugins/vuetify.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* plugins/vuetify.js
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import { createVuetify } from 'vuetify'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
themes: {
light: {
colors: {
background: '#fffefd',
surface: '#fffefd',
primary: '#1A6CAB',
// secondary: '#59B8FF',
success: '#00CC33',
// info: '#fba92c',
warning: '#ffcc00',
error: '#CC0033',
},
dark: false,
variables: {},
},
// todo dark mode
},
},
})

View File

@@ -0,0 +1,17 @@
/**
* plugins/webfontloader.js
*
* webfontloader documentation: https://github.com/typekit/webfontloader
*/
export async function loadFonts () {
const webFontLoader = await import(/* webpackChunkName: "webfontloader" */'webfontloader')
webFontLoader.load({
google: {
// families: ['Roboto:100,300,400,500,700,900&display=swap'],
// families: ['Marcellus','Marcellus SC'],
families: ['Victor Mono', 'Tektur'],
},
})
}

26
src/router/index.js Normal file
View File

@@ -0,0 +1,26 @@
// Composables
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
component: () => import('@/layouts/default/Default.vue'),
children: [
{
path: '',
name: 'Home',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue'),
},
],
},
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
})
export default router

4
src/store/index.js Normal file
View File

@@ -0,0 +1,4 @@
// Utilities
import { createPinia } from 'pinia'
export default createPinia()

32
src/store/store.js Normal file
View File

@@ -0,0 +1,32 @@
// Utilities
import { defineStore } from 'pinia'
export const useStore = defineStore('app', {
state: () => ({
chain: {name:'Not Connected', id:0, icon:''},
wallet: {
connected: false,
},
errors: [],
extraTokens: {},
}),
getters: {
tokens: (s)=>{
const extras = s.extraTokens[s.chain.id]
return extras === undefined ? Object.values(known_tokens[s.chain.id])
: [...Object.values(known_tokens[s.chain.id]), ...Object.values(extras)]
},
},
actions: {
error(title, message) {
this.errors.push({title:title, message:message})
},
closeError(title, message) {
console.log('closing error', title, message)
const result = []
this.errors.forEach((i)=>{if(i.title!==title && i.message!==message) result.push(i)})
this.errors = result
},
}
})

14
src/styles/settings.scss Normal file
View File

@@ -0,0 +1,14 @@
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
@use "src/styles/vars" as v;
// https://vuetifyjs.com/features/sass-variables/`
@forward 'vuetify/settings' with (
//$variable: false,
$body-font-family: v.$body-font-family,
$heading-font-family: v.$heading-font-family,
);

26
src/styles/style.scss Normal file
View File

@@ -0,0 +1,26 @@
@use "/src/styles/vars" as v;
//.app {
// .v-btn {
// font-family: v.$heading-font-family;
// text-transform: none;
// }
//
// .v-card-title {
// font-family: v.$heading-font-family;
// color: v.$blue;
// }
//
// .title {
// font-family: v.$heading-font-family;
// }
//
// .v-list-subheader {
// font-family: v.$heading-font-family;
// }
//}
.sc {
font-family: v.$heading-font-family;
}

38
src/styles/vars.scss Normal file
View File

@@ -0,0 +1,38 @@
// these must also be set in vuetify.ts for the "theme"
$green: #00CC33;
$red: #CC0033;
$yellow: #ffcc00;
$blue: #0033CC;
$white: #fffefd; // just a touch green
$black: #000102;
$primary: $blue;
$primary-50: transparentize($primary,0.5);
$primary-25: transparentize($primary,0.75);
$theme: (
background: $white,
surface: $white,
primary: $blue,
//secondary: $sky,
success: $green,
info: $primary-25,
warning: $yellow,
error: $red,
);
$all-colors: (
white: $white,
black: $black,
blue: $blue,
green: $green,
yellow: $yellow,
red: $red,
);
$body-font-family: 'Victor Mono', monospace, sans-serif;
$heading-font-family: 'Tektur', sans-serif;
$sm-breakpoint: 600px;
$card-maxw: 30rem;

7
src/views/Home.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<TimedOrder/>
</template>
<script setup>
import TimedOrder from "@/components/TimedOrderEntry.vue";
</script>