order placement works, including automatic vault creation

This commit is contained in:
Tim Olson
2023-10-06 19:49:31 -04:00
parent c206607547
commit c637e82ac3
8 changed files with 279 additions and 56 deletions

View File

@@ -43,32 +43,16 @@ export const erc20Abi = [
'event Approval(address indexed,address indexed,uint256)', 'event Approval(address indexed,address indexed,uint256)',
] ]
const Route = '(uint8,uint24)'
const Constraint = '(uint8,bytes)'
const Tranche = `(uint64,${Constraint}[])`
const SwapOrder = `(address,address,${Route},uint256,bool,bool,uint64,${Tranche}[])`
const TimedOrderSpec = '(' + export const vaultAbi = [
'address tokenIn,' + 'function withdraw(uint256) public',
'address tokenOut,' + 'function withdrawTo(address payable,uint256) public',
'uint24 fee,' + 'function withdraw(address,uint256) public',
'uint32 deadline,' + 'function withdrawTo(address,address,uint256) public',
'uint32 leeway,' + `function placeOrder(${SwapOrder}) public`,
'uint160 minSqrtPriceX96,' + `function placeOrders(${SwapOrder}[],uint8) public`,
'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,
'QueryHelper': queryHelperAbi,
}

View File

@@ -1,20 +1,48 @@
import {ethers} from "ethers"; import {ethers} from "ethers";
import {factoryAbi, queryHelperAbi} from "@/blockchain/abi.js"; import {factoryAbi, queryHelperAbi, vaultAbi} from "@/blockchain/abi.js";
import {useStore} from "@/store/store.js"; import {useStore} from "@/store/store.js";
import {provider} from "@/blockchain/wallet.js"; import {provider} from "@/blockchain/wallet.js";
export function vaultAddress( owner, num ) {
const s = useStore()
if( s.vaultInitCodeHash === null || s.factory === null )
return null
const abiCoder = ethers.AbiCoder.defaultAbiCoder();
console.log('vaultAddress owner', owner)
const salt = ethers.keccak256(abiCoder.encode(['address','uint8'],[owner,num]))
const result = ethers.getCreate2Address(s.factory, salt, s.vaultInitCodeHash)
console.log('vaultAddress', result, s.factory, salt, s.vaultInitCodeHash)
return result
}
function contractOrNull(addr,abi,provider) {
try {
return new ethers.Contract(addr,abi,provider)
}
catch (e) {
return null
}
}
export async function factoryContract() { export async function factoryContract() {
const s = useStore() const s = useStore()
return new ethers.Contract(s.helper, factoryAbi, provider) return contractOrNull(s.factory, factoryAbi, provider)
} }
export async function queryHelperContract() { export async function queryHelperContract() {
const s = useStore() const s = useStore()
return new ethers.Contract(s.helper, queryHelperAbi, provider) return contractOrNull(s.helper, queryHelperAbi, provider)
} }
export async function poolContract(addr) { export async function poolContract(addr) {
const s = useStore() return contractOrNull(addr, poolAbi, provider)
return new ethers.Contract(addr, poolAbi, provider) }
export async function vaultContract(num, signer) {
const s = useStore()
if( num >= s.vaults.length )
return null
return contractOrNull(s.vaults[num], vaultAbi, signer)
} }

114
src/blockchain/orderlib.js Normal file
View File

@@ -0,0 +1,114 @@
import {uint32max, uint64max} from "@/misc.js";
import {ethers} from "ethers";
export const NO_CHAIN = uint64max;
export const NO_OCO = uint64max;
// struct SwapOrder {
// address tokenIn;
// address tokenOut;
// Route route;
// uint256 amount;
// bool amountIsInput;
// bool outputDirectlyToOwner;
// uint64 chainOrder; // use NO_CHAIN for no chaining. chainOrder index must be < than this order's index for safety (written first) and chainOrder state must be Template
// Tranche[] tranches;
// }
// struct Route {
// Exchange exchange;
// uint24 fee;
// }
export function newOrder(tokenIn, tokenOut, exchange, fee, amount, amountIsInput, tranches,
outputToOwner=false, chainOrder=NO_CHAIN) {
if(!tranches)
tranches = [newTranche(1,[])] // todo this is just a swap: issue warning?
return [
tokenIn, tokenOut, [exchange,fee], amount, amountIsInput, outputToOwner, chainOrder, tranches
]
}
// struct Tranche {
// uint64 fraction; // 18-decimal fraction of the order amount which is available to this tranche. must be <= 1
// Constraint[] constraints;
// }
export function newTranche(amountRatio, constraints) {
return [
BigInt(Math.ceil(amountRatio * 10**18)), // we use ceil to make sure the sum of tranche fractions doesn't round below 1
constraints
]
}
// enum Exchange {
// UniswapV2,
// UniswapV3
// }
export const Exchange = {
UniswapV2: 0,
UniswapV3: 1,
}
// enum ConstraintMode {
// Time, // 0
// Limit, // 1
// Trailing, // 2
// Barrier, // 3
// Line // 4
// }
export const ConstraintMode = {
Time: 0,
Limit: 1,
Trailing: 2,
Barrier: 3,
Line: 4,
}
// struct Constraint {
// ConstraintMode mode;
// bytes constraint; // abi-encoded constraint struct
// }
function encodeConstraint( constraintMode, types, values ) {
return [constraintMode, ethers.AbiCoder.defaultAbiCoder().encode(types,values)]
}
export const TimeMode = {
Timestamp:0,
SinceOrderStart:1,
}
export const DISTANT_PAST = 0
export const DISTANT_FUTURE = uint32max
// struct Time {
// TimeMode mode;
// uint32 time;
// }
// struct TimeConstraint {
// Time earliest;
// Time latest;
// }
export function newTimeConstraint(startMode, start, endMode, end) {
// absolute time
return encodeConstraint(
ConstraintMode.Time,
['uint8', 'uint32', 'uint8', 'uint32'],
[startMode, start, endMode, end]
)
}
// struct PriceConstraint {
// bool isAbove;
// bool isRatio;
// uint160 valueSqrtX96;
// }
// struct LineConstraint {
// bool isAbove;
// bool isRatio;
// uint32 time;
// uint160 valueSqrtX96;
// int160 slopeSqrtX96; // price change per second
// }

View File

@@ -1,14 +1,18 @@
import {ethers} from "ethers"; import {ethers} from "ethers";
import {useStore} from "@/store/store"; import {useStore} from "@/store/store";
import {socket} from "@/socket.js";
import {vaultContract} from "@/blockchain/contract.js";
export let provider = null export let provider = null
function onChainChanged(chainId) { export function onChainChanged(chainId) {
chainId = Number(chainId) chainId = Number(chainId)
// console.log('chain changed', chainId) // console.log('chain changed', chainId)
const store = useStore() const store = useStore()
store.chainId = chainId store.chainId = chainId
store.account = null
provider = new ethers.BrowserProvider(window.ethereum, chainId) provider = new ethers.BrowserProvider(window.ethereum, chainId)
provider.listAccounts().then(onAccountsChanged)
} }
function onAccountsChanged(accounts) { function onAccountsChanged(accounts) {
@@ -18,8 +22,10 @@ function onAccountsChanged(accounts) {
store.account = null store.account = null
} }
else if (accounts[0] !== store.account) { else if (accounts[0] !== store.account) {
store.account = accounts[0] store.account = accounts[0].address
flushOrders()
} }
socket.emit('address', store.chainId, accounts[0].address)
} }
export async function watchWallet() { export async function watchWallet() {
@@ -62,4 +68,48 @@ const errorHandlingProxy = {
} }
// const wallet = new Proxy(new Wallet(), errorHandlingProxy); export async function connectWallet() {
return provider.getSigner()
}
export async function pendOrder(order) {
console.log('order', order)
const s = useStore()
s.pendingOrders.push(order)
const signer = await connectWallet()
if (!s.vaults.length)
socket.emit('ensureVault', s.chainId, await signer.getAddress(), 0)
else
flushOrders()
}
export function flushOrders() {
// noinspection JSIgnoredPromiseFromCall
asyncFlushOrders()
}
export async function asyncFlushOrders() {
const s = useStore()
const orders = s.pendingOrders
if(!orders.length || !s.account)
return
const contract = await vaultContract(0, await provider.getSigner())
if( !contract )
return
const proms = []
for (const order of orders)
proms.push(contract.placeOrder(order))
s.pendingOrders = []
const txs = await Promise.all(proms)
for( const tx of txs )
console.log('placed order', tx)
}
socket.on('vaults', (vaults)=>{
const s = useStore()
console.log('vaults', vaults)
s.vaults = vaults
flushOrders()
})

View File

@@ -2,9 +2,8 @@
<NeedsQueryHelper> <NeedsQueryHelper>
<PhoneCard> <PhoneCard>
<v-card-title class="big">DCA / TWAP</v-card-title> <v-card-title class="big">DCA / TWAP</v-card-title>
<v-card-subtitle>Split order across time</v-card-subtitle> <v-card-subtitle>Multiple tranches over a time range</v-card-subtitle>
<v-card-text> <v-card-text>
<token-choice v-model="tokenA" class="token-choice mb-1"> <token-choice v-model="tokenA" class="token-choice mb-1">
<template v-slot:prepend> <template v-slot:prepend>
<v-btn :text="buy ? 'Buy' : 'Sell'" :color="buy ? 'green' : 'red'" <v-btn :text="buy ? 'Buy' : 'Sell'" :color="buy ? 'green' : 'red'"
@@ -35,15 +34,15 @@
<v-text-field label='Amount' type="number" step="1" variant="outlined" aria-valuemin="0" min="0" <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> v-model="amount" :rules="[validateRequired,validateAmount]" v-auto-select>
<template v-slot:append-inner> <template v-slot:append-inner>
<v-btn @click="amountIsBase=!amountIsBase" variant="outlined" class="mr-2"> <v-btn @click="amountIsTokenA=!amountIsTokenA" variant="outlined" class="mr-2">
{{ amountIsBase ? tokenA.symbol : tokenB.symbol }} {{ amountIsTokenA ? tokenA.symbol : tokenB.symbol }}
</v-btn> </v-btn>
<v-btn :text="amountIsTotal ? 'total' : 'per tranche'" variant="outlined" <v-btn :text="amountIsTotal ? 'total' : 'per tranche'" variant="outlined"
@click="amountIsTotal=!amountIsTotal" class="total"/> @click="amountIsTotal=!amountIsTotal" class="total"/>
</template> </template>
</v-text-field> </v-text-field>
<v-text-field label="Tranches" type="number" variant="outlined" aria-valuemin="1" min="1" max="255" <v-text-field label="Tranches" type="number" variant="outlined" aria-valuemin="1" min="1" max="255"
:model-value="tranches" :rules="[validateRequired,validateTranches]" v-auto-select> v-model="tranches" :rules="[validateRequired,validateTranches]" v-auto-select>
<!-- <template v-slot:prepend-inner>--> <!-- <template v-slot:prepend-inner>-->
<!-- <div>{{ amountIsTotal ? 'Split into' : 'Times' }}</div>--> <!-- <div>{{ amountIsTotal ? 'Split into' : 'Times' }}</div>-->
<!-- </template>--> <!-- </template>-->
@@ -60,6 +59,7 @@
<v-btn variant="outlined" :text="timeUnits[timeUnitIndex]" @click="toggleTimeUnits" class="time-units"/> <v-btn variant="outlined" :text="timeUnits[timeUnitIndex]" @click="toggleTimeUnits" class="time-units"/>
</template> </template>
</v-text-field> </v-text-field>
<!--
<v-text-field v-model="limitPrice" :label="(limitIsMinimum?'Minimum':'Maximum')+' Price'" type="number" <v-text-field v-model="limitPrice" :label="(limitIsMinimum?'Minimum':'Maximum')+' Price'" type="number"
variant="outlined" aria-valuemin="0" min="0" variant="outlined" aria-valuemin="0" min="0"
clearable :rules="[validateAmount, validateMin]" v-auto-select> clearable :rules="[validateAmount, validateMin]" v-auto-select>
@@ -69,13 +69,14 @@
</v-btn> </v-btn>
</template> </template>
</v-text-field> </v-text-field>
-->
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions class="d-flex justify-space-evenly mb-4"> <v-card-actions class="d-flex justify-space-evenly mb-4">
<v-btn variant="outlined" color="red">Cancel</v-btn> <v-btn variant="outlined" color="red">Cancel</v-btn>
<v-btn variant="flat" color="green" :disabled="!validOrder">Place Order</v-btn> <v-btn variant="flat" color="green" :disabled="!validOrder" @click="placeOrder">Place Order</v-btn>
</v-card-actions> </v-card-actions>
</PhoneCard> </PhoneCard>
</NeedsQueryHelper> </NeedsQueryHelper>
@@ -87,13 +88,15 @@ import {computed, ref} from "vue";
import TokenChoice from "@/components/TokenChoice.vue" import TokenChoice from "@/components/TokenChoice.vue"
import PhoneCard from "@/components/PhoneCard.vue"; import PhoneCard from "@/components/PhoneCard.vue";
import {queryHelperContract} from "@/blockchain/contract.js"; import {queryHelperContract} from "@/blockchain/contract.js";
import {SingletonCoroutine} from "@/misc.js";
import NeedsQueryHelper from "@/components/NeedsQueryHelper.vue";
// noinspection ES6UnusedImports // noinspection ES6UnusedImports
import {vAutoSelect} from "@/misc.js"; import {SingletonCoroutine, vAutoSelect} from "@/misc.js";
import NeedsQueryHelper from "@/components/NeedsQueryHelper.vue";
import {Exchange, newOrder, newTimeConstraint, TimeMode} from "@/blockchain/orderlib.js";
import {FixedNumber} from "ethers";
import {pendOrder} from "@/blockchain/wallet.js";
const s = useStore() const s = useStore()
const buy = ref(false) const buy = ref(true)
let _tokenA = ref(s.tokens && s.tokens.length >= 1 ? s.tokens[0] : null) let _tokenA = ref(s.tokens && s.tokens.length >= 1 ? s.tokens[0] : null)
let _tokenB = ref(s.tokens && s.tokens.length >= 2 ? s.tokens[1] : null) let _tokenB = ref(s.tokens && s.tokens.length >= 2 ? s.tokens[1] : null)
const tokenA = computed({ const tokenA = computed({
@@ -129,8 +132,8 @@ const quote = computed(()=>{
return !token?{}:token return !token?{}:token
}) })
const routes = ref([]) const routes = ref([])
const amount = ref(0) const amount = ref(100) // todo 0
const amountIsBase = ref(false) const amountIsTokenA = ref(false)
const amountIsTotal = ref(true) const amountIsTotal = ref(true)
const tranches = ref(3) const tranches = ref(3)
const inverted = ref(false) const inverted = ref(false)
@@ -169,7 +172,7 @@ async function findRoute() {
case 0: // UniswapV2 case 0: // UniswapV2
break break
case 1: // UniswapV3 case 1: // UniswapV3
result = {exchange: 'UniswapV3', pool, fee,} result = {exchange: Exchange.UniswapV3, pool, fee,}
break break
} }
} }
@@ -234,6 +237,41 @@ function validateMin(v) {
return true return true
} }
async 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 ? ta : tb
const amt = FixedNumber.fromString(amount.value.toString(), {decimals: amountToken.decimals}).value
const amountIsInput = !buy.value ^ amountIsTokenA.value
// build tranches
const n = tranches.value // num tranches
const ts = []
let duration = timeUnitIndex === 0 ? interval.value * 60 : // minutes
timeUnitIndex === 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 = duration / n
}
const ceil = 10n ** 18n % BigInt(n) ? 1n : 0n
const amtPerTranche = 10n ** 18n / BigInt(n) + ceil
duration -= 15 // subtract 15 seconds so the last tranche completes before the deadline
for (let i = 0; i < n; i++) {
const start = Math.floor(i * (duration / n))
const end = start + window
const cs = [newTimeConstraint(TimeMode.SinceOrderStart, start, TimeMode.SinceOrderStart, end)]
ts.push([amtPerTranche, cs])
}
const order = newOrder(tokenIn, tokenOut, route.exchange, route.fee, amt, amountIsInput, ts)
await pendOrder(order)
}
</script> </script>

View File

@@ -38,4 +38,6 @@ export const vAutoSelect = {
const input = el.querySelector('input') const input = el.querySelector('input')
input.onfocus = () => setTimeout(() => input.select(), 0) input.onfocus = () => setTimeout(() => input.select(), 0)
} }
} }
export const uint64max = 18446744073709551615n
export const uint32max = 4294967295n

View File

@@ -1,5 +1,7 @@
import {io} from "socket.io-client"; import {io} from "socket.io-client";
import {useStore} from "@/store/store.js"; import {useStore} from "@/store/store.js";
import {onChainChanged} from "@/blockchain/wallet.js";
import {ethers} from "ethers";
export const socket = io(import.meta.env.VITE_WS_URL || undefined, { transports: ["websocket"] }) export const socket = io(import.meta.env.VITE_WS_URL || undefined, { transports: ["websocket"] })
@@ -11,11 +13,14 @@ socket.on('disconnect', ()=>{
console.log('ws disconnected') console.log('ws disconnected')
}) })
socket.on('welcome', (data)=>{ socket.on('welcome', async (data)=>{
console.log('welcome',data) console.log('welcome',data)
const s = useStore()
s.chainInfo = data.chainInfo
s.vaultInitCodeHash = data.vaultInitCodeHash
const p = new ethers.BrowserProvider(window.ethereum)
const network = await p.getNetwork()
if( network !== null )
onChainChanged(network.chainId)
}) })
socket.on('chainInfo', async (chainInfo)=>{
const s = useStore()
s.chainInfo = chainInfo
})

View File

@@ -4,10 +4,12 @@ import {knownTokens} from "@/tokens.js";
export const useStore = defineStore('app', { export const useStore = defineStore('app', {
state: () => ({ state: () => ({
chainInfo: null,
chainId: null, chainId: null,
chainInfo: null,
vaultInitCodeHash: null,
account: null, account: null,
vault: null, vaults: [],
pendingOrders: [], // created but not yet sent to metamask. maybe waiting on vault creation.
errors: [{ errors: [{
title: 'DANGER!', title: 'DANGER!',
text: 'This is early development (alpha) software, which could have severe bugs that lose all your money. Thank you for testing a SMALL amount!', text: 'This is early development (alpha) software, which could have severe bugs that lose all your money. Thank you for testing a SMALL amount!',