flux submitter
This commit is contained in:
parent
a1a7656cd7
commit
b76aef53ce
|
@ -18,6 +18,7 @@
|
||||||
"commander": "^6.2.0",
|
"commander": "^6.2.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
"solray": "git+https://github.com/czl1378/solray.git",
|
"solray": "git+https://github.com/czl1378/solray.git",
|
||||||
|
"winston": "^3.3.3",
|
||||||
"ws": "^7.4.1"
|
"ws": "^7.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
SPLToken,
|
SPLToken,
|
||||||
} from "solray"
|
} from "solray"
|
||||||
|
|
||||||
|
import BN from "bn.js"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SYSVAR_RENT_PUBKEY,
|
SYSVAR_RENT_PUBKEY,
|
||||||
SYSVAR_CLOCK_PUBKEY,
|
SYSVAR_CLOCK_PUBKEY,
|
||||||
|
@ -83,8 +85,8 @@ interface SubmitParams {
|
||||||
oracle_owner: Account
|
oracle_owner: Account
|
||||||
}
|
}
|
||||||
|
|
||||||
round_id: BigInt
|
round_id: BN
|
||||||
value: BigInt
|
value: BN
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WithdrawParams {
|
interface WithdrawParams {
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
import WebSocket from "ws"
|
||||||
|
import EventEmitter from "events"
|
||||||
|
|
||||||
|
export const UPDATE = "UPDATE"
|
||||||
|
|
||||||
|
export interface IPrice {
|
||||||
|
decimals: number
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPriceFeed {
|
||||||
|
[Symbol.asyncIterator]: () => AsyncIterator<IPrice>
|
||||||
|
}
|
||||||
|
|
||||||
|
// events convert an particular event type of event emitter to an async iterator
|
||||||
|
function events<T>(emitter: EventEmitter, key: string) {
|
||||||
|
// TODO support cancel
|
||||||
|
|
||||||
|
let resolve
|
||||||
|
let p = new Promise<T>((resolveFn) => {
|
||||||
|
resolve = resolveFn
|
||||||
|
})
|
||||||
|
|
||||||
|
emitter.on(key, (value) => {
|
||||||
|
resolve(value)
|
||||||
|
p = new Promise<T>((resolveFn) => {
|
||||||
|
resolve = resolveFn
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
[Symbol.asyncIterator]: () => {
|
||||||
|
return {
|
||||||
|
next() {
|
||||||
|
return p.then((info) => ({ done: false, value: info }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coinbase(pair: string): IPriceFeed {
|
||||||
|
const emitter = new EventEmitter()
|
||||||
|
|
||||||
|
const ws = new WebSocket("wss://ws-feed.pro.coinbase.com")
|
||||||
|
|
||||||
|
ws.on("open", () => {
|
||||||
|
console.log(`${pair} price feed connected`)
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "subscribe",
|
||||||
|
product_ids: [pair.replace("/", "-").toUpperCase()],
|
||||||
|
channels: ["ticker"],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on("message", async (data) => {
|
||||||
|
const json = JSON.parse(data)
|
||||||
|
if (!json || !json.price) {
|
||||||
|
return console.log(data)
|
||||||
|
}
|
||||||
|
const price: IPrice = {
|
||||||
|
decimals: 2,
|
||||||
|
value: Math.floor(json.price * 100),
|
||||||
|
}
|
||||||
|
emitter.emit(UPDATE, price)
|
||||||
|
// console.log("current price:", json.price)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on("close", (err) => {
|
||||||
|
// TODO: automatic reconnect
|
||||||
|
console.error(`websocket closed: ${err}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return events(emitter, UPDATE)
|
||||||
|
}
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { Connection } from "@solana/web3.js"
|
||||||
|
import { PublicKey, Wallet } from "solray"
|
||||||
|
import { conn } from "./context"
|
||||||
|
|
||||||
|
import { Aggregator, Submissions, Oracle } from "./schema"
|
||||||
|
import BN from "bn.js"
|
||||||
|
import { sleep } from "./utils"
|
||||||
|
import FluxAggregator from "./FluxAggregator"
|
||||||
|
|
||||||
|
import { createLogger, Logger } from "winston"
|
||||||
|
import logger from "winston"
|
||||||
|
logger.add(
|
||||||
|
new logger.transports.Console({
|
||||||
|
format: logger.format.simple(),
|
||||||
|
level: "debug",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
import { IPriceFeed } from "./PriceFeed"
|
||||||
|
|
||||||
|
// allow oracle to start a new round after this many slots. each slot is about 500ms
|
||||||
|
const MAX_ROUND_STALENESS = 10
|
||||||
|
|
||||||
|
export class Submitter {
|
||||||
|
public aggregator!: Aggregator
|
||||||
|
public submissions!: Submissions
|
||||||
|
public program: FluxAggregator
|
||||||
|
public logger!: Logger
|
||||||
|
public currentValue: BN
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
programID: PublicKey,
|
||||||
|
public aggregatorPK: PublicKey,
|
||||||
|
public oraclePK: PublicKey,
|
||||||
|
private oracleOwnerWallet: Wallet,
|
||||||
|
private priceFeed: IPriceFeed
|
||||||
|
) {
|
||||||
|
this.program = new FluxAggregator(this.oracleOwnerWallet, programID)
|
||||||
|
|
||||||
|
this.currentValue = new BN(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: harvest rewards if > n
|
||||||
|
|
||||||
|
public async start() {
|
||||||
|
// make sure the states are initialized
|
||||||
|
this.aggregator = await Aggregator.load(this.aggregatorPK)
|
||||||
|
this.submissions = await Submissions.load(this.aggregator.roundSubmissions)
|
||||||
|
this.logger = logger.child({
|
||||||
|
aggregator: this.aggregator.config.description,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([this.observeAggregatorState(), this.observePriceFlux()])
|
||||||
|
}
|
||||||
|
|
||||||
|
private async observeAggregatorState() {
|
||||||
|
conn.onAccountChange(this.aggregatorPK, async (info) => {
|
||||||
|
this.aggregator = Aggregator.deserialize(info.data)
|
||||||
|
this.submissions = await Submissions.load(
|
||||||
|
this.aggregator.roundSubmissions
|
||||||
|
)
|
||||||
|
// TODO: load answer
|
||||||
|
this.logger.debug("state updated", {
|
||||||
|
aggregator: this.aggregator,
|
||||||
|
submissions: this.submissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.onAggregatorStateUpdate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: immediately submit to current round if not submitted yet
|
||||||
|
|
||||||
|
private async observePriceFlux() {
|
||||||
|
for await (let price of this.priceFeed) {
|
||||||
|
if (price.decimals != this.aggregator.config.decimals) {
|
||||||
|
throw new Error(
|
||||||
|
`Expect price with decimals of ${this.aggregator.config.decimals} got: ${price.decimals}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentValue = new BN(price.value)
|
||||||
|
// TODO: check flux against current answer
|
||||||
|
await this.trySubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// compare with current answer
|
||||||
|
|
||||||
|
private async trySubmit() {
|
||||||
|
// TODO: make it possible to be triggered by chainlink task
|
||||||
|
// TODO: If from chainlink node, update state before running
|
||||||
|
|
||||||
|
const { round } = this.aggregator
|
||||||
|
|
||||||
|
const epoch = await conn.getEpochInfo()
|
||||||
|
|
||||||
|
const sinceLastUpdate = new BN(epoch.absoluteSlot).sub(round.updatedAt)
|
||||||
|
// console.log("slot", epoch.absoluteSlot, sinceLastUpdate.toString())
|
||||||
|
|
||||||
|
if (!this.hadSubmitted) {
|
||||||
|
this.logger.info("Submit to current round")
|
||||||
|
await this.submitCurrentValue(round.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sinceLastUpdate.gtn(MAX_ROUND_STALENESS)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The round is stale. start a new round if possible
|
||||||
|
this.logger.info("Starting a new round")
|
||||||
|
const oracle = await Oracle.load(this.oraclePK)
|
||||||
|
if (oracle.canStartNewRound(round.id)) {
|
||||||
|
return this.submitCurrentValue(round.id.addn(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onAggregatorStateUpdate() {
|
||||||
|
// don't try to submit if already submitted...
|
||||||
|
if (this.hadSubmitted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info("Another oracle started a new round")
|
||||||
|
await this.trySubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
get hadSubmitted(): boolean {
|
||||||
|
return this.submissions.hadSubmitted(this.oraclePK)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async submitCurrentValue(round: BN) {
|
||||||
|
// guard zero value
|
||||||
|
const value = this.currentValue
|
||||||
|
if (value.isZero()) {
|
||||||
|
this.logger.warn("current value is zero. skip submit.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info("submit", {
|
||||||
|
round: round.toString(),
|
||||||
|
value: value.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.program.submit({
|
||||||
|
accounts: {
|
||||||
|
aggregator: { write: this.aggregatorPK },
|
||||||
|
roundSubmissions: { write: this.aggregator.roundSubmissions },
|
||||||
|
answerSubmissions: { write: this.aggregator.answerSubmissions },
|
||||||
|
oracle: { write: this.oraclePK },
|
||||||
|
oracle_owner: this.oracleOwnerWallet.account,
|
||||||
|
},
|
||||||
|
|
||||||
|
round_id: round,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { walletFromEnv } from "./utils"
|
||||||
|
|
||||||
|
import FluxAggregator from "./FluxAggregator"
|
||||||
|
|
||||||
|
import { solana, Wallet, Deployer } from "solray"
|
||||||
|
|
||||||
|
export const network = (process.env.NETWORK || "local") as any
|
||||||
|
export const conn = solana.connect(network)
|
||||||
|
|
||||||
|
async function openDeployer(): Promise<Deployer> {
|
||||||
|
return Deployer.open(`deploy.${network}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppContext {
|
||||||
|
// static readonly AGGREGATOR_PROGRAM = "aggregatorProgram"
|
||||||
|
|
||||||
|
// static async forAdmin() {
|
||||||
|
// const deployer = await openDeployer()
|
||||||
|
// const admin = await walletFromEnv("ADMIN_MNEMONIC", conn)
|
||||||
|
|
||||||
|
// return new AppContext(deployer, admin)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// static async forOracle() {
|
||||||
|
// const deployer = await openDeployer()
|
||||||
|
// const wallet = await walletFromEnv("ORACLE_MNEMONIC", conn)
|
||||||
|
|
||||||
|
// return new AppContext(deployer, wallet)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// constructor(public deployer: Deployer, public wallet: Wallet) {}
|
||||||
|
|
||||||
|
async deployer() {
|
||||||
|
return Deployer.open(`deploy.${network}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminWallet() {
|
||||||
|
return walletFromEnv("ADMIN_MNEMONIC", conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
async oracleWallet() {
|
||||||
|
return walletFromEnv("ADMIN_MNEMONIC", conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get aggregatorProgramID() {
|
||||||
|
// return this.aggregatorProgramAccount.publicKey
|
||||||
|
// }
|
||||||
|
|
||||||
|
// get aggregator() {
|
||||||
|
// return new FluxAggregator(this.wallet, this.aggregatorProgramID)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// get aggregatorProgramAccount() {
|
||||||
|
// const program = this.deployer.account(AppContext.AGGREGATOR_PROGRAM)
|
||||||
|
|
||||||
|
// if (program == null) {
|
||||||
|
// throw new Error(`flux aggregator program is not yet deployed`)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return program
|
||||||
|
// }
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { PublicKey, Account } from "solray"
|
import { PublicKey, Account } from "solray"
|
||||||
import BN from "bn.js"
|
import BN from "bn.js"
|
||||||
import { deserialize, serialize } from "borsh"
|
import { deserialize, serialize } from "borsh"
|
||||||
|
import { conn } from "./context"
|
||||||
|
|
||||||
const MAX_ORACLES = 13
|
const MAX_ORACLES = 13
|
||||||
|
|
||||||
|
@ -37,7 +38,29 @@ const str32Mapper = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class Serialization {
|
const u64Date = {
|
||||||
|
encode: (date: Date) => {
|
||||||
|
return new BN(Math.floor(date.getTime() / 1000))
|
||||||
|
},
|
||||||
|
|
||||||
|
decode: (unixtime: BN) => {
|
||||||
|
return new Date(unixtime.toNumber() * 1000)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class Serialization {
|
||||||
|
public static async load<T>(
|
||||||
|
this: { new (data: any): T },
|
||||||
|
key: PublicKey
|
||||||
|
): Promise<T> {
|
||||||
|
const info = await conn.getAccountInfo(key, "recent")
|
||||||
|
if (!info) {
|
||||||
|
throw new Error("account does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserialize(schema, this, info.data)
|
||||||
|
}
|
||||||
|
|
||||||
public static deserialize<T>(this: { new (data: any): T }, data: Buffer): T {
|
public static deserialize<T>(this: { new (data: any): T }, data: Buffer): T {
|
||||||
return deserialize(schema, this, data)
|
return deserialize(schema, this, data)
|
||||||
}
|
}
|
||||||
|
@ -103,6 +126,9 @@ export class AggregatorConfig extends Serialization {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Submissions extends Serialization {
|
export class Submissions extends Serialization {
|
||||||
|
public isInitialized!: boolean
|
||||||
|
public submissions!: Submission[]
|
||||||
|
|
||||||
public static size = 625
|
public static size = 625
|
||||||
public static schema = {
|
public static schema = {
|
||||||
kind: "struct",
|
kind: "struct",
|
||||||
|
@ -111,19 +137,33 @@ export class Submissions extends Serialization {
|
||||||
["submissions", [Submission, MAX_ORACLES]],
|
["submissions", [Submission, MAX_ORACLES]],
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hadSubmitted(pk: PublicKey): boolean {
|
||||||
|
return !!this.submissions.find((s) => {
|
||||||
|
return s.oracle.equals(pk)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
class Round extends Serialization {
|
class Round extends Serialization {
|
||||||
|
public id!: BN
|
||||||
|
public createdAt!: BN
|
||||||
|
public updatedAt!: BN
|
||||||
|
|
||||||
public static schema = {
|
public static schema = {
|
||||||
kind: "struct",
|
kind: "struct",
|
||||||
fields: [
|
fields: [
|
||||||
["id", "u64"],
|
["id", "u64"],
|
||||||
["created_at", "u64"],
|
["createdAt", "u64"],
|
||||||
["updated_at", "u64"],
|
["updatedAt", "u64"],
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Answer extends Serialization {
|
class Answer extends Serialization {
|
||||||
|
public round_id!: BN
|
||||||
|
public created_at!: BN
|
||||||
|
public updated_at!: BN
|
||||||
|
|
||||||
public static schema = {
|
public static schema = {
|
||||||
kind: "struct",
|
kind: "struct",
|
||||||
fields: [
|
fields: [
|
||||||
|
@ -140,6 +180,8 @@ export class Aggregator extends Serialization {
|
||||||
public config!: AggregatorConfig
|
public config!: AggregatorConfig
|
||||||
public roundSubmissions!: PublicKey
|
public roundSubmissions!: PublicKey
|
||||||
public answerSubmissions!: PublicKey
|
public answerSubmissions!: PublicKey
|
||||||
|
public answer!: Answer
|
||||||
|
public round!: Round
|
||||||
|
|
||||||
public static schema = {
|
public static schema = {
|
||||||
kind: "struct",
|
kind: "struct",
|
||||||
|
@ -260,6 +302,7 @@ function boolToInt(t: boolean) {
|
||||||
|
|
||||||
export class Oracle extends Serialization {
|
export class Oracle extends Serialization {
|
||||||
public static size = 113
|
public static size = 113
|
||||||
|
public allowStartRound!: BN
|
||||||
|
|
||||||
public static schema = {
|
public static schema = {
|
||||||
kind: "struct",
|
kind: "struct",
|
||||||
|
@ -267,11 +310,15 @@ export class Oracle extends Serialization {
|
||||||
["description", [32], str32Mapper],
|
["description", [32], str32Mapper],
|
||||||
["isInitialized", "u8", boolMapper],
|
["isInitialized", "u8", boolMapper],
|
||||||
["withdrawable", "u64"],
|
["withdrawable", "u64"],
|
||||||
["allow_start_round", "u64"],
|
["allowStartRound", "u64"],
|
||||||
["aggregator", [32], pubkeyMapper],
|
["aggregator", [32], pubkeyMapper],
|
||||||
["owner", [32], pubkeyMapper],
|
["owner", [32], pubkeyMapper],
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public canStartNewRound(round: BN): boolean {
|
||||||
|
return this.allowStartRound.lte(round)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if there is optional or variable length items, what is: borsh_utils::get_packed_len::<Submission>()?
|
// if there is optional or variable length items, what is: borsh_utils::get_packed_len::<Submission>()?
|
||||||
|
|
|
@ -108,12 +108,3 @@ export async function walletFromEnv(
|
||||||
return wallet
|
return wallet
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openDeployer(): Promise<Deployer> {
|
|
||||||
const deployFile = process.env.DEPLOY_FILE
|
|
||||||
|
|
||||||
if (!deployFile) {
|
|
||||||
throw new Error(`Set DEPLOY_FILE in .env`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Deployer.open(deployFile)
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue