diff --git a/package.json b/package.json index 9b38079..01fb868 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "author": "", "license": "ISC", "dependencies": { + "@ltd/j-toml": "^1.6.0", "@solana/web3.js": "^0.90.5", "buffer-layout": "^1.2.0", "commander": "^6.2.0", diff --git a/src/FluxAggregator.ts b/src/FluxAggregator.ts index 9fe1dba..fcd1638 100644 --- a/src/FluxAggregator.ts +++ b/src/FluxAggregator.ts @@ -28,7 +28,7 @@ import { decodeOracleInfo } from "./utils" // @ts-ignore // import BufferLayout from "buffer-layout"; -import { schema } from "./schema" +import { AggregatorConfig, IAggregatorConfig, schema } from "./schema" import * as encoding from "./schema" import { deserialize, serialize } from "borsh" import { conn } from "./context" @@ -51,7 +51,7 @@ export const SubmissionLayout = BufferLayout.struct([ ]) interface InitializeParams { - config: encoding.AggregatorConfig + config: IAggregatorConfig owner: Account } @@ -90,15 +90,17 @@ interface SubmitParams { } interface WithdrawParams { - aggregator: PublicKey - // withdraw to - receiver: PublicKey - // withdraw amount - amount: bigint - tokenAccount: PublicKey - tokenOwner: PublicKey - // signer - authority: Account + accounts: { + aggregator: PublicKey + + faucet: { write: PublicKey }, + faucetOwner: PublicKey, + oracle: { write: PublicKey }, + oracleOwner: Account, + receiver: { write: PublicKey }, + } + + faucetOwnerSeed: Buffer } interface WithdrawInstructionParams extends WithdrawParams {} @@ -115,7 +117,9 @@ export default class FluxAggregator extends BaseProgram { const answer_submissions = new Account() const round_submissions = new Account() - const input = encoding.Initialize.serialize({ config: params.config }) + const input = encoding.Initialize.serialize({ + config: new AggregatorConfig(params.config), + }) await this.sendTx( [ @@ -182,96 +186,95 @@ export default class FluxAggregator extends BaseProgram { return oracle } - public async oracleInfo(pubkey: PublicKey) { - const info = await this.conn.getAccountInfo(pubkey) - return decodeOracleInfo(info) - } + // public async oracleInfo(pubkey: PublicKey) { + // const info = await this.conn.getAccountInfo(pubkey) + // return decodeOracleInfo(info) + // } - public async removeOracle(params: RemoveOracleParams): Promise { - await this.sendTx( - [this.removeOracleInstruction(params)], - [this.account, params.authority || this.wallet.account] - ) - } + // public async removeOracle(params: RemoveOracleParams): Promise { + // await this.sendTx( + // [this.removeOracleInstruction(params)], + // [this.account, params.authority || this.wallet.account] + // ) + // } - private removeOracleInstruction( - params: RemoveOracleInstructionParams - ): TransactionInstruction { - const { authority, aggregator, oracle } = params + // private removeOracleInstruction( + // params: RemoveOracleInstructionParams + // ): TransactionInstruction { + // const { authority, aggregator, oracle } = params - const layout = BufferLayout.struct([ - BufferLayout.u8("instruction"), - BufferLayout.blob(32, "oracle"), - ]) + // const layout = BufferLayout.struct([ + // BufferLayout.u8("instruction"), + // BufferLayout.blob(32, "oracle"), + // ]) - return this.instructionEncode( - layout, - { - instruction: 2, // remove oracle instruction - oracle: oracle.toBuffer(), - }, - [ - // - { write: aggregator }, - authority || this.wallet.account, - ] - ) - } + // return this.instructionEncode( + // layout, + // { + // instruction: 2, // remove oracle instruction + // oracle: oracle.toBuffer(), + // }, + // [ + // // + // { write: aggregator }, + // authority || this.wallet.account, + // ] + // ) + // } public async submit(params: SubmitParams): Promise { const input = encoding.Submit.serialize(params) - let auths = [ - SYSVAR_CLOCK_PUBKEY, - ...Object.values(params.accounts), - ] + let auths = [SYSVAR_CLOCK_PUBKEY, ...Object.values(params.accounts)] await this.sendTx( - [ - this.instruction(input, auths), - ], + [this.instruction(input, auths)], [this.account, params.accounts.oracle_owner] ) } public async withdraw(params: WithdrawParams): Promise { + const input = encoding.Withdraw.serialize(params) + + let auths = [SPLToken.programID, ...Object.values(params.accounts)] + await this.sendTx( - [this.withdrawInstruction(params)], - [this.account, params.authority] + [this.instruction(input, auths)], + [this.account, params.accounts.oracleOwner] ) } - private withdrawInstruction( - params: WithdrawInstructionParams - ): TransactionInstruction { - const { - aggregator, - receiver, - amount, - tokenOwner, - tokenAccount, - authority, - } = params + // private withdrawInstruction( + // params: WithdrawInstructionParams + // ): TransactionInstruction { + // const { + // aggregator, + // receiver, + // amount, + // tokenOwner, + // tokenAccount, + // authority, + // } = params - const layout = BufferLayout.struct([ - BufferLayout.u8("instruction"), - uint64("amount"), - ]) + // const layout = BufferLayout.struct([ + // BufferLayout.u8("instruction"), + // uint64("amount"), + // ]) - return this.instructionEncode( - layout, - { - instruction: 4, // withdraw instruction - amount: u64LEBuffer(amount), - }, - [ - { write: aggregator }, - { write: tokenAccount }, - { write: receiver }, - SPLToken.programID, - tokenOwner, - { write: authority }, - ] - ) - } + // return this.instructionEncode( + // layout, + // { + // instruction: 4, // withdraw instruction + // amount: u64LEBuffer(amount), + // }, + // [ + // { write: aggregator }, + // { write: tokenAccount }, + // { write: receiver }, + // SPLToken.programID, + // tokenOwner, + // { write: authority }, + // ] + // ) + // } } diff --git a/src/Submitter.ts b/src/Submitter.ts index 7ffde45..9b67393 100644 --- a/src/Submitter.ts +++ b/src/Submitter.ts @@ -28,6 +28,7 @@ interface SubmitterConfig { export class Submitter { public aggregator!: Aggregator + public oracle!: Oracle public roundSubmissions!: Submissions public answerSubmissions!: Submissions public program: FluxAggregator @@ -40,7 +41,7 @@ export class Submitter { public oraclePK: PublicKey, private oracleOwnerWallet: Wallet, private priceFeed: IPriceFeed, - private cfg: SubmitterConfig, + private cfg: SubmitterConfig ) { this.program = new FluxAggregator(this.oracleOwnerWallet, programID) @@ -66,6 +67,10 @@ export class Submitter { await Promise.all([this.observeAggregatorState(), this.observePriceFeed()]) } + public async withdrawRewards() { + + } + private async observeAggregatorState() { conn.onAccountChange(this.aggregatorPK, async (info) => { this.aggregator = Aggregator.deserialize(info.data) @@ -96,9 +101,13 @@ export class Submitter { this.currentValue = new BN(price.value) - const valueDiff = this.aggregator.answer.median.sub(this.currentValue).abs() - if(valueDiff.lten(this.cfg.minValueChangeForNewRound)) { - this.logger.debug("price did not change enough to start a new round", { diff: valueDiff.toNumber()}); + const valueDiff = this.aggregator.answer.median + .sub(this.currentValue) + .abs() + if (valueDiff.lten(this.cfg.minValueChangeForNewRound)) { + this.logger.debug("price did not change enough to start a new round", { + diff: valueDiff.toNumber(), + }) continue } @@ -110,6 +119,9 @@ export class Submitter { // TODO: make it possible to be triggered by chainlink task // TODO: If from chainlink node, update state before running + this.oracle = await Oracle.load(this.oraclePK) + this.logger.debug("oracle", { oracle: this.oracle }) + const { round } = this.aggregator if (this.canSubmitToCurrentRound) { diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..741b0e7 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,59 @@ +import toml from "@ltd/j-toml" + +import fs from "fs" +import { Oracle } from "./schema" + +function loadJSON(file: string): any { + return JSON.parse(fs.readFileSync(file, "utf8")) +} + +const aggregatorConfigDefaults = { + decimals: 0, + minSubmissions: 0, + maxSubmissions: 1, + restartDelay: 0, + rewardAmount: 0, +} + +export function loadAggregatorSetup(file: string): AggregatorSetupFile { + let obj: AggregatorSetupFile = loadJSON(file) + + for (let key of Object.keys(obj.aggregators)) { + obj.aggregators[key] = { + ...aggregatorConfigDefaults, + ...obj.aggregators[key], + } + } + return obj +} + +export interface OracleConfig { + owner: string +} + +export interface AggregatorSetupConfig { + decimals: number + minSubmissions: number + maxSubmissions: number + restartDelay: number + rewardAmount: number + rewardTokenAccount?: string + + oracles?: string[] +} + +export interface AggregatorSetupFile { + programID: string + + aggregators: { + [key: string]: AggregatorSetupConfig + } + oracles: { + [key: string]: OracleConfig + } +} + +// // +// export interface DeployManifest { +// programID: +// } diff --git a/src/deploy.ts b/src/deploy.ts new file mode 100644 index 0000000..a26d0ab --- /dev/null +++ b/src/deploy.ts @@ -0,0 +1,191 @@ +import { stateFromJSON } from "./state" +import fs from "fs" +import path from "path" + +import { + Account, + BPFLoader, + ProgramAccount, + PublicKey, + SPLToken, + Wallet, +} from "solray" +import { + AggregatorSetupFile, + AggregatorSetupConfig, + loadAggregatorSetup, + OracleConfig, +} from "./config" +import FluxAggregator from "./FluxAggregator" +import { AggregatorConfig, IAggregatorConfig } from "./schema" +// import { AggregatorConfig } from "./schema" + +interface OracleDeployInfo { + pubkey: PublicKey + owner: PublicKey +} +interface AggregatorDeployInfo { + pubkey: PublicKey + config: IAggregatorConfig + + oracles: { + [key: string]: OracleDeployInfo + } +} + +export interface AggregatorDeployFile { + programID: PublicKey + + aggregators: { + [key: string]: AggregatorDeployInfo + } +} + +const FLUX_AGGREGATOR_SO = path.resolve( + __dirname, + "../build/flux_aggregator.so" +) + +function jsonReviver(_key: string, val: any) { + if (val && typeof val == "object") { + if (val["type"] == "PublicKey") { + return new PublicKey(val.base58) + } + } + return val +} + +function jsonReplacer(key: string, value: any) { + if (value && typeof value != "object") { + return value + } + + if (value.constructor == PublicKey) { + return { + type: "PublicKey", + base58: value.toBase58(), + } + } + + return value +} + +export class Deployer { + // file backed json state + public setup: AggregatorSetupFile + public state: AggregatorDeployFile + constructor(statePath: string, setupFile: string, private wallet: Wallet) { + this.state = stateFromJSON( + statePath, + { + aggregators: {}, + } as any, + { + replacer: jsonReplacer, + reviver: jsonReviver, + } + ) + this.setup = loadAggregatorSetup(setupFile) + } + + async runAll() { + await this.deployProgram() + await this.createAggregators() + } + + async deployProgram() { + if (this.state.programID) { + console.log("program deployed") + return + } + + const programBinary = fs.readFileSync(FLUX_AGGREGATOR_SO) + + console.log(`deploying ${FLUX_AGGREGATOR_SO}...`) + const bpfLoader = new BPFLoader(this.wallet) + + const account = await bpfLoader.load(programBinary) + this.state.programID = account.publicKey + } + + async createAggregators() { + for (let key of Object.keys(this.setup.aggregators)) { + const aggregatorSetup = this.setup.aggregators[key] + + let info = this.state.aggregators[key] + if (!info) { + this.state.aggregators[key] = await this.createAggregator( + key, + aggregatorSetup + ) + info = this.state.aggregators[key] + } + + console.log(`${key} aggregator deployed`) + for (let oracleName of aggregatorSetup.oracles || []) { + const oracleSetup = this.setup.oracles[oracleName] + // TODO: check that key exists + + let oinfo = info.oracles[oracleName] + if (!oinfo) { + oinfo = await this.createOracle(info, oracleName, oracleSetup) + + // hmm... not triggering save + info.oracles[oracleName] = oinfo + } + console.log(`${key} added oracle:`, oracleSetup.owner) + } + } + } + + get program() { + return new FluxAggregator(this.wallet, this.state.programID) + } + + async createOracle( + aggregatorInfo: AggregatorDeployInfo, + name: string, + setup: OracleConfig + ): Promise { + const config = { + description: name, + aggregator: aggregatorInfo.pubkey, + aggregatorOwner: this.wallet.account, + oracleOwner: new PublicKey(setup.owner), + } + + const account = await this.program.addOracle(config) + + return { + pubkey: account.publicKey, + owner: config.oracleOwner, + } + } + + async createAggregator( + name: string, + cfg: AggregatorSetupConfig + ): Promise { + const config = { + description: name, + decimals: cfg.decimals, + minSubmissions: cfg.minSubmissions, + maxSubmissions: cfg.maxSubmissions, + restartDelay: cfg.restartDelay, + rewardTokenAccount: new PublicKey(cfg.rewardTokenAccount || 0), + rewardAmount: cfg.rewardAmount, + } + + const account = await this.program.initialize({ + // FIXME: move this into initialize method + config: new AggregatorConfig(config), + owner: this.wallet.account, + }) + + return { + pubkey: account.publicKey, + config, + oracles: {}, + } + } +} diff --git a/src/schema.ts b/src/schema.ts index b7386ff..2241f8c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -104,7 +104,19 @@ class Submission { } } -export class AggregatorConfig extends Serialization { +export interface IAggregatorConfig { + decimals: number + description: string + restartDelay: number + rewardAmount: number + maxSubmissions: number + minSubmissions: number + rewardTokenAccount: PublicKey +} + +export class AggregatorConfig + extends Serialization + implements IAggregatorConfig { public decimals!: number public description!: string public restartDelay!: number @@ -248,7 +260,6 @@ export class AddOracle extends InstructionSerialization { } } - export class RemoveOracle extends InstructionSerialization { public static schema = { kind: "struct", @@ -256,6 +267,13 @@ export class RemoveOracle extends InstructionSerialization { } } +export class Withdraw extends InstructionSerialization { + public static schema = { + kind: "struct", + fields: [["faucetOwnerSeed", ["u8"]]], + } +} + export class Submit extends InstructionSerialization { public static schema = { kind: "struct", diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..2c3cb78 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,60 @@ +import fs from "fs" + +// stateFromJSON returns a JSON file backed object. When object mutates, will +// serialize the object as JSON to the specified path. +// +// with recursive proxy support similar to: +// +// https://stackoverflow.com/questions/41299642/how-to-use-javascript-proxy-for-nested-objects +export function stateFromJSON( + filepath: string, + defaultObject: T, + opts: { + replacer?: Function + reviver?: Function + } = {} +): T { + let root = defaultObject + + try { + const data = fs.readFileSync(filepath, "utf8") + // TODO: support custom revive + root = JSON.parse(data, opts.reviver as any) + console.log(`Loaded state object: ${filepath}`) + } catch (err) { + console.log(`Init state object: ${filepath}`) + } + + function save() { + // should be sync to avoid write races + fs.writeFileSync(filepath, JSON.stringify(root, opts.replacer as any, 2)) + } + + const proxy = { + get(target, key) { + if (typeof target[key] === "object" && target[key] !== null) { + return new Proxy(target[key], proxy) + } else { + return target[key] + } + }, + + set(obj, prop, val) { + obj[prop] = val + save() + return true + }, + } + + return new Proxy(root, proxy) as T +} + +// import { BigNumber } from "ethers"; +// function jsonRevive(_key: string, val: any) { +// if (val && typeof val == "object") { +// if (val["type"] == "BigNumber") { +// return BigNumber.from(val["hex"]); +// } +// } +// return val; +// } diff --git a/test.ts b/test.ts index 31185a0..e02976a 100644 --- a/test.ts +++ b/test.ts @@ -3,11 +3,11 @@ dotenv.config() import BN from "bn.js" -import { BPFLoader, ProgramAccount, SPLToken, Wallet } from "solray" +import { ProgramAccount, SPLToken, Wallet } from "solray" import { AppContext, conn, network } from "./src/context" import fs from "fs" -import path from "path" + import { AggregatorConfig } from "./src/schema" import FluxAggregator from "./src/FluxAggregator" @@ -15,138 +15,121 @@ import * as encoding from "./src/schema" import { Account, AccountInfo, Connection, PublicKey } from "@solana/web3.js" import { coinbase } from "./src/PriceFeed" import { Submitter } from "./src/Submitter" +import { Deployer } from "./src/deploy" -const FLUX_AGGREGATOR_SO = path.resolve(__dirname, "build/flux_aggregator.so") +import { loadAggregatorSetup } from "./src/config" +import { stateFromJSON } from "./src/state" async function main() { + let ctx = new AppContext() - - let deployer = await ctx.deployer() let adminWallet = await ctx.adminWallet() - let oracleWallet = await ctx.oracleWallet() - - console.log(network) - - await conn.requestAirdrop(adminWallet.pubkey, 10 * 1e9) - console.log((await conn.getBalance(adminWallet.pubkey)) / 1e9) - - let aggregatorProgram = await deployer.ensure( - "aggregatorProgram", - async () => { - const programBinary = fs.readFileSync(FLUX_AGGREGATOR_SO) - - console.log(`deploying ${FLUX_AGGREGATOR_SO}...`) - const bpfLoader = new BPFLoader(adminWallet) - - return bpfLoader.load(programBinary) - } + const deployer = new Deployer( + `deploy2.${network}.json`, + `config/setup.${network}.json`, + adminWallet ) - const spltoken = new SPLToken(adminWallet) - const rewardToken = await deployer.ensure("create reward token", async () => { - return spltoken.initializeMint({ - mintAuthority: adminWallet.pubkey, - decimals: 8, - }) - }) + await deployer.runAll() + console.log("done") - const rewardTokenOwner = await ProgramAccount.forSeed( - Buffer.from("solink"), - aggregatorProgram.publicKey - ) + return - const rewardTokenAccount = await deployer.ensure( - "initialize reward token account", - async () => { - const vault = await spltoken.initializeAccount({ - token: rewardToken.publicKey, - owner: rewardTokenOwner.pubkey, - }) + // let deployer = await ctx.deployer() - await spltoken.mintTo({ - token: rewardToken.publicKey, - to: vault.publicKey, - amount: BigInt(1e6 * 1e8), // 1M - authority: adminWallet.pubkey, - }) + // let oracleWallet = await ctx.oracleWallet() - return vault - } - ) + // console.log(network) - console.log(await spltoken.mintInfo(rewardToken.publicKey)) + // await conn.requestAirdrop(adminWallet.pubkey, 10 * 1e9) + // console.log((await conn.getBalance(adminWallet.pubkey)) / 1e9) - const program = new FluxAggregator(adminWallet, aggregatorProgram.publicKey) + // const spltoken = new SPLToken(adminWallet) + // const rewardToken = await deployer.ensure("create reward token", async () => { + // return spltoken.initializeMint({ + // mintAuthority: adminWallet.pubkey, + // decimals: 8, + // }) + // }) - let aggregator = await deployer.ensure( - "create btc:usd aggregator", - async () => { - let name = "btc:usd" - return program.initialize({ - config: new AggregatorConfig({ - description: name, - decimals: 2, - minSubmissions: 1, - maxSubmissions: 3, - restartDelay: 0, - rewardAmount: BigInt(10), - rewardTokenAccount: rewardTokenAccount.publicKey, - }), - owner: adminWallet.account, - }) - } - ) + // const rewardTokenOwner = await ProgramAccount.forSeed( + // Buffer.from("solink"), + // aggregatorProgram.publicKey + // ) - const N_ORACLES = 4 - interface OracleRole { - owner: Account - oracle: PublicKey - } + // const rewardTokenAccount = await deployer.ensure( + // "initialize reward token account", + // async () => { + // const vault = await spltoken.initializeAccount({ + // token: rewardToken.publicKey, + // owner: rewardTokenOwner.pubkey, + // }) - const oracleRoles: OracleRole[] = [] + // await spltoken.mintTo({ + // token: rewardToken.publicKey, + // to: vault.publicKey, + // amount: BigInt(1e6 * 1e8), // 1M + // authority: adminWallet.pubkey, + // }) - for (let i = 0; i < N_ORACLES; i++) { - // TODO: probably put the desired oracles in a config file... - let owner = await deployer.ensure(`create oracle[${i}] owner`, async () => { - return new Account() - }) + // return vault + // } + // ) - let oracle = await deployer.ensure( - `add oracle[${i}] to btc:usd`, - async () => { - return program.addOracle({ - description: "test-oracle", - aggregator: aggregator.publicKey, - aggregatorOwner: adminWallet.account, - oracleOwner: owner.publicKey, - }) - } - ) + // console.log(await spltoken.mintInfo(rewardToken.publicKey)) - oracleRoles.push({ owner, oracle: oracle.publicKey }) - } - for (const role of oracleRoles) { - // const wallet = Wallet.from - const owner = Wallet.fromAccount(role.owner, conn) - await conn.requestAirdrop(owner.pubkey, 10 * 1e9) - console.log(owner.address, await conn.getBalance(owner.pubkey)) - const priceFeed = coinbase("BTC/USD") - const submitter = new Submitter( - aggregatorProgram.publicKey, - aggregator.publicKey, - role.oracle, - owner, - priceFeed, - { - // don't submit value unless btc changes at least a dollar - minValueChangeForNewRound: 100, - } - ) + // const N_ORACLES = 4 + // interface OracleRole { + // owner: Account + // oracle: PublicKey + // } - submitter.start() - } + // const oracleRoles: OracleRole[] = [] + + // for (let i = 0; i < N_ORACLES; i++) { + // // TODO: probably put the desired oracles in a config file... + // let owner = await deployer.ensure(`create oracle[${i}] owner`, async () => { + // return new Account() + // }) + + // let oracle = await deployer.ensure( + // `add oracle[${i}] to btc:usd`, + // async () => { + // return program.addOracle({ + // description: "test-oracle", + // aggregator: aggregator.publicKey, + // aggregatorOwner: adminWallet.account, + // oracleOwner: owner.publicKey, + // }) + // } + // ) + + // oracleRoles.push({ owner, oracle: oracle.publicKey }) + // } + + // for (const role of oracleRoles) { + // // const wallet = Wallet.from + // const owner = Wallet.fromAccount(role.owner, conn) + // await conn.requestAirdrop(owner.pubkey, 10 * 1e9) + // console.log(owner.address, await conn.getBalance(owner.pubkey)) + + // const priceFeed = coinbase("BTC/USD") + // const submitter = new Submitter( + // aggregatorProgram.publicKey, + // aggregator.publicKey, + // role.oracle, + // owner, + // priceFeed, + // { + // // don't submit value unless btc changes at least a dollar + // minValueChangeForNewRound: 100, + // } + // ) + + // submitter.start() + // } } main().catch((err) => console.log(err))