From 9620ce3bc9eb1b9c6bf4d06ccdf1658b115128fa Mon Sep 17 00:00:00 2001 From: Conner Gallagher Date: Mon, 5 Dec 2022 14:36:51 -0700 Subject: [PATCH] solana.js: added aggregator queue transfer method --- .../src/accounts/aggregatorAccount.ts | 180 +++++++++++++++++- .../solana.js/src/accounts/leaseAccount.ts | 115 ++++++----- .../src/accounts/permissionAccount.ts | 49 ++++- .../solana.js/src/accounts/queueAccount.ts | 93 ++++++--- javascript/solana.js/src/transaction.ts | 5 + javascript/solana.js/test/aggregator.spec.ts | 85 +++++++++ 6 files changed, 445 insertions(+), 82 deletions(-) diff --git a/javascript/solana.js/src/accounts/aggregatorAccount.ts b/javascript/solana.js/src/accounts/aggregatorAccount.ts index f1eb8a8..18b3b7c 100644 --- a/javascript/solana.js/src/accounts/aggregatorAccount.ts +++ b/javascript/solana.js/src/accounts/aggregatorAccount.ts @@ -19,11 +19,13 @@ import crypto from 'crypto'; import { JobAccount } from './jobAccount'; import { QueueAccount } from './queueAccount'; import { LeaseAccount } from './leaseAccount'; -import { PermissionAccount } from './permissionAccount'; +import { PermissionAccount, PermissionSetParams } from './permissionAccount'; import * as spl from '@solana/spl-token'; import { TransactionObject } from '../transaction'; import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { AggregatorHistoryBuffer } from './aggregatorHistoryBuffer'; +import { PermitOracleQueueUsage } from '../generated/types/SwitchboardPermission'; +import { CrankAccount } from './crankAccount'; /** * Account type holding a data feed's update configuration, job accounts, and its current result. @@ -233,6 +235,172 @@ export class AggregatorAccount extends Account { return [account, txnSignature]; } + async transferInstruction( + payer: PublicKey, + params: { + newQueue: QueueAccount; + authority?: Keypair; + crankPubkey?: PublicKey; + } & Partial + ): Promise> { + const txns: Array = []; + + const aggregator = await this.loadData(); + const newQueue = await params.newQueue.loadData(); + + const authorityPubkey = params.authority + ? params.authority.publicKey + : payer; + + if (!aggregator.authority.equals(authorityPubkey)) { + throw new errors.IncorrectAuthority( + aggregator.authority, + authorityPubkey + ); + } + + const jobs = await this.loadJobs(aggregator); + const jobAuthorities = jobs.map(j => j.state.authority); + // const jobAuthorities = Array.from( + // jobs.reduce((set, job) => { + // set.add(job.state.authority); + // return set; + // }, new Set()) + // ); + + const [oldLeaseAccount] = LeaseAccount.fromSeed( + this.program, + aggregator.queuePubkey, + this.publicKey + ); + + const setQueueTxn = new TransactionObject( + payer, + [ + types.aggregatorSetQueue( + this.program, + { params: {} }, + { + aggregator: this.publicKey, + authority: authorityPubkey, + queue: params.newQueue.publicKey, + } + ), + ], + params.authority ? [params.authority] : [] + ); + txns.push(setQueueTxn); + + // create and set permissions + const [newPermissionAccount, permissionInitTxn] = + PermissionAccount.createInstruction(this.program, payer, { + authority: newQueue.authority, + granter: params.newQueue.publicKey, + grantee: this.publicKey, + }); + if (params.enable) { + if ( + params.queueAuthority && + !params.queueAuthority.publicKey.equals(newQueue.authority) + ) { + throw new errors.IncorrectAuthority( + newQueue.authority, + params.queueAuthority.publicKey + ); + } + const permissionSetTxn = newPermissionAccount.setInstruction(payer, { + enable: true, + queueAuthority: params.queueAuthority ?? undefined, + permission: new PermitOracleQueueUsage(), + }); + permissionInitTxn.combine(permissionSetTxn); + } + txns.push(permissionInitTxn); + + // create payer token account if we need to + const payerTokenAccount = this.program.mint.getAssociatedAddress(payer); + const payerTokenAccountInfo = await this.program.connection.getAccountInfo( + payerTokenAccount + ); + if (payerTokenAccountInfo === null) { + const [payerTokenAddress, payerTokenInitTxn] = + await this.program.mint.getOrCreateWrappedUserInstructions(payer, { + amount: 0, + }); + txns.unshift(payerTokenInitTxn); + } + + // withdraw from lease + let oldLeaseWithdrawAuthority = payer; + let oldLeaseBalance = 0; + try { + const oldLease = await oldLeaseAccount.loadData(); + oldLeaseBalance = await oldLeaseAccount.getBalance(oldLease.escrow); + oldLeaseWithdrawAuthority = oldLease.withdrawAuthority; + const withdrawTxn = await oldLeaseAccount.withdrawInstruction(payer, { + amount: oldLeaseBalance, + withdrawWallet: payerTokenAccount, + unwrap: false, + }); + txns.push(withdrawTxn); + } catch { + // failed to get old lease balance, skipping + } + + // create lease for the new queue and transfer existing balance + const [leaseAccount, leaseInitTxn] = await LeaseAccount.createInstructions( + this.program, + payer, + { + aggregatorAccount: this, + queueAccount: params.newQueue, + jobAuthorities, + loadAmount: oldLeaseBalance ?? 0, + withdrawAuthority: oldLeaseWithdrawAuthority, + jobPubkeys: aggregator.jobPubkeysData.slice( + 0, + aggregator.jobPubkeysSize + ), + } + ); + txns.push(leaseInitTxn); + + // push onto crank + if (params.crankPubkey) { + const crankAccount = new CrankAccount(this.program, params.crankPubkey); + const crank = await crankAccount.loadData(); + if (!params.newQueue.publicKey.equals(crank.queuePubkey)) { + throw new Error( + `Desired crank does not belong to new queue, expected ${params.newQueue.publicKey}, received ${crank.queuePubkey}` + ); + } + const crankPush = await crankAccount.pushInstruction(payer, { + aggregatorAccount: this, + }); + + txns.push(crankPush); + } + + return TransactionObject.pack(txns); + } + + async transfer( + params: { + newQueue: QueueAccount; + authority?: Keypair; + crankPubkey?: PublicKey; + } & Partial + ): Promise> { + const transactions = await this.transferInstruction( + this.program.walletPubkey, + params + ); + const txnSignatures = await this.program.signAndSendAll(transactions, { + skipPreflight: true, + }); + return txnSignatures; + } + public getAccounts(params: { queueAccount: QueueAccount; queueAuthority: PublicKey; @@ -439,7 +607,15 @@ export class AggregatorAccount extends Account { * @throws {AggregatorConfigError} if minUpdateDelaySeconds < 5, if batchSize > queueSize, if minOracleResults > batchSize, if minJobResults > aggregator.jobPubkeysSize */ public verifyConfig( - aggregator: types.AggregatorAccountData, + aggregator: + | types.AggregatorAccountData + | { + oracleRequestBatchSize: number; + minOracleResults: number; + minJobResults: number; + minUpdateDelaySeconds: number; + jobPubkeysSize: number; + }, queue: types.OracleQueueAccountData, target: { batchSize?: number; diff --git a/javascript/solana.js/src/accounts/leaseAccount.ts b/javascript/solana.js/src/accounts/leaseAccount.ts index bc78475..0dc0fe9 100644 --- a/javascript/solana.js/src/accounts/leaseAccount.ts +++ b/javascript/solana.js/src/accounts/leaseAccount.ts @@ -5,6 +5,7 @@ import { SwitchboardProgram } from '../program'; import { Account } from './account'; import * as spl from '@solana/spl-token'; import { + AccountMeta, Keypair, PublicKey, SystemProgram, @@ -78,31 +79,25 @@ export class LeaseAccount extends Account { static getWallets( jobAuthorities: Array, mint: PublicKey - ): { - wallets: Array<{ publicKey: PublicKey; bump: number }>; - walletBumps: Uint8Array; - } { + ): Array<{ publicKey: PublicKey; bump: number }> { const wallets: Array<{ publicKey: PublicKey; bump: number }> = []; - const walletBumps: Array = []; - for (const jobAuthority in jobAuthorities) { - const authority = new PublicKey(jobAuthority); - if (!jobAuthority || PublicKey.default.equals(authority)) { + for (const jobAuthority of jobAuthorities) { + if (!jobAuthority || PublicKey.default.equals(jobAuthority)) { continue; } const [jobWallet, bump] = anchor.utils.publicKey.findProgramAddressSync( [ - authority.toBuffer(), + jobAuthority.toBuffer(), spl.TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer(), ], spl.ASSOCIATED_TOKEN_PROGRAM_ID ); wallets.push({ publicKey: jobWallet, bump }); - walletBumps.push(bump); } - return { wallets, walletBumps: new Uint8Array(walletBumps) }; + return wallets; } static async createInstructions( @@ -111,11 +106,12 @@ export class LeaseAccount extends Account { params: { aggregatorAccount: AggregatorAccount; queueAccount: QueueAccount; + jobAuthorities?: Array; + jobPubkeys?: Array; loadAmount?: number; funderTokenAccount?: PublicKey; funderAuthority?: Keypair; withdrawAuthority?: PublicKey; - jobAuthorities: Array; } ): Promise<[LeaseAccount, TransactionObject]> { const loadAmount = params.loadAmount ?? 0; @@ -152,46 +148,68 @@ export class LeaseAccount extends Account { spl.ASSOCIATED_TOKEN_PROGRAM_ID ); - const { walletBumps } = LeaseAccount.getWallets( - params.jobAuthorities, + // load jobPubkeys and authorities ONLY if undefined + // we need to allow empty arrays for initial job creation or else loading aggregator will fail + let jobPubkeys = params.jobPubkeys; + let jobAuthorities = params.jobAuthorities; + if (jobPubkeys === undefined || jobAuthorities === undefined) { + const aggregator = await params.aggregatorAccount.loadData(); + jobPubkeys = aggregator.jobPubkeysData.slice( + 0, + aggregator.jobPubkeysSize + ); + const jobs = await params.aggregatorAccount.loadJobs(aggregator); + jobAuthorities = jobs.map(j => j.state.authority); + } + + const wallets = LeaseAccount.getWallets( + jobAuthorities ?? [], program.mint.address ); + const walletBumps = new Uint8Array(wallets.map(w => w.bump)); + + const remainingAccounts: Array = (jobPubkeys ?? []) + .concat(wallets.map(w => w.publicKey)) + .map(pubkey => { + return { isSigner: false, isWritable: true, pubkey }; + }); + + const createTokenAccountIxn = spl.createAssociatedTokenAccountInstruction( + payer, + escrow, + leaseAccount.publicKey, + program.mint.address + ); + const leaseInitIxn = types.leaseInit( + program, + { + params: { + loadAmount: loadTokenAmountBN, + withdrawAuthority: params.withdrawAuthority ?? payer, + leaseBump: leaseBump, + stateBump: program.programState.bump, + walletBumps: walletBumps, + }, + }, + { + lease: leaseAccount.publicKey, + queue: params.queueAccount.publicKey, + aggregator: params.aggregatorAccount.publicKey, + payer: payer, + systemProgram: SystemProgram.programId, + tokenProgram: spl.TOKEN_PROGRAM_ID, + funder: funderTokenAccount, + owner: funderAuthority, + escrow: escrow, + programState: program.programState.publicKey, + mint: program.mint.address, + } + ); + leaseInitIxn.keys.push(...remainingAccounts); const leaseInitTxn = new TransactionObject( payer, - [ - spl.createAssociatedTokenAccountInstruction( - payer, - escrow, - leaseAccount.publicKey, - program.mint.address - ), - types.leaseInit( - program, - { - params: { - loadAmount: loadTokenAmountBN, - withdrawAuthority: params.withdrawAuthority ?? payer, - leaseBump: leaseBump, - stateBump: program.programState.bump, - walletBumps: walletBumps, - }, - }, - { - lease: leaseAccount.publicKey, - queue: params.queueAccount.publicKey, - aggregator: params.aggregatorAccount.publicKey, - payer: payer, - systemProgram: SystemProgram.programId, - tokenProgram: spl.TOKEN_PROGRAM_ID, - funder: funderTokenAccount, - owner: funderAuthority, - escrow: escrow, - programState: program.programState.publicKey, - mint: program.mint.address, - } - ), - ], + [createTokenAccountIxn, leaseInitIxn], params.funderAuthority ? [params.funderAuthority] : [] ); @@ -208,11 +226,12 @@ export class LeaseAccount extends Account { params: { aggregatorAccount: AggregatorAccount; queueAccount: QueueAccount; + jobAuthorities?: Array; + jobPubkeys?: Array; loadAmount?: number; funderTokenAccount?: PublicKey; funderAuthority?: Keypair; withdrawAuthority?: PublicKey; - jobAuthorities: Array; } ): Promise<[LeaseAccount, TransactionSignature]> { const [leaseAccount, transaction] = await LeaseAccount.createInstructions( diff --git a/javascript/solana.js/src/accounts/permissionAccount.ts b/javascript/solana.js/src/accounts/permissionAccount.ts index a37c203..e5c81d8 100644 --- a/javascript/solana.js/src/accounts/permissionAccount.ts +++ b/javascript/solana.js/src/accounts/permissionAccount.ts @@ -1,5 +1,7 @@ import * as anchor from '@project-serum/anchor'; +import { ACCOUNT_DISCRIMINATOR_SIZE } from '@project-serum/anchor'; import { + AccountInfo, Keypair, PublicKey, SystemProgram, @@ -49,8 +51,6 @@ export class PermitNone { } export interface PermissionSetParams { - /** The {@linkcode types.SwitchboardPermission} to set for the grantee. */ - permission: types.SwitchboardPermissionKind; /** Whether to enable PERMIT_ORACLE_HEARTBEAT permissions. **Note:** Requires a provided queueAuthority keypair or payer to be the assigned queue authority. */ enable: boolean; /** Keypair used to enable heartbeat permissions if payer is not the queue authority. */ @@ -196,7 +196,12 @@ export class PermissionAccount extends Account { /** * Sets the permission in the PermissionAccount */ - public async set(params: PermissionSetParams): Promise { + public async set( + params: PermissionSetParams & { + /** The {@linkcode types.SwitchboardPermission} to set for the grantee. */ + permission: types.SwitchboardPermissionKind; + } + ): Promise { const setTxn = this.setInstruction(this.program.walletPubkey, params); const txnSignature = await this.program.signAndSend(setTxn); return txnSignature; @@ -207,7 +212,10 @@ export class PermissionAccount extends Account { */ public setInstruction( payer: PublicKey, - params: PermissionSetParams + params: PermissionSetParams & { + /** The {@linkcode types.SwitchboardPermission} to set for the grantee. */ + permission: types.SwitchboardPermissionKind; + } ): TransactionObject { return new TransactionObject( payer, @@ -231,4 +239,37 @@ export class PermissionAccount extends Account { params.queueAuthority ? [params.queueAuthority] : [] ); } + + static getGranteePermissions( + grantee: AccountInfo + ): types.SwitchboardPermissionKind { + if (grantee.data.byteLength < ACCOUNT_DISCRIMINATOR_SIZE) { + throw new Error(`Cannot assign permissions to grantee`); + } + const discriminator = grantee.data.slice(0, ACCOUNT_DISCRIMINATOR_SIZE); + + // check oracle + if (types.OracleAccountData.discriminator.compare(discriminator) === 0) { + return new PermitOracleHeartbeat(); + } + + // check aggregator and buffer relayer + if ( + types.AggregatorAccountData.discriminator.compare(discriminator) === 0 || + types.BufferRelayerAccountData.discriminator.compare(discriminator) + ) { + return new PermitOracleQueueUsage(); + } + + // check vrf + if (types.VrfAccountData.discriminator.compare(discriminator) === 0) { + return new PermitVrfRequests(); + } + + throw new Error( + `Cannot find permissions to assign for account with discriminator of [${discriminator.join( + ', ' + )}]` + ); + } } diff --git a/javascript/solana.js/src/accounts/queueAccount.ts b/javascript/solana.js/src/accounts/queueAccount.ts index b9b56ea..5e21b1b 100644 --- a/javascript/solana.js/src/accounts/queueAccount.ts +++ b/javascript/solana.js/src/accounts/queueAccount.ts @@ -296,7 +296,7 @@ export class QueueAccount extends Account { payer: PublicKey, params: OracleInitParams & OracleStakeParams & - Partial> & { + Partial & { queueAuthorityPubkey?: PublicKey; } ): Promise<[OracleAccount, Array]> { @@ -355,9 +355,7 @@ export class QueueAccount extends Account { * ``` */ public async createOracle( - params: OracleInitParams & - OracleStakeParams & - Partial> + params: OracleInitParams & OracleStakeParams & Partial ): Promise<[OracleAccount, Array]> { const signers: Keypair[] = []; @@ -438,7 +436,7 @@ export class QueueAccount extends Account { fundAmount?: number; funderAuthority?: Keypair; funderTokenAccount?: PublicKey; - } & Partial> & { + } & Partial & { // job params jobs?: Array<{ pubkey: PublicKey; weight?: number } | JobInitParams>; } & { @@ -512,7 +510,8 @@ export class QueueAccount extends Account { funderAuthority: params.funderAuthority, aggregatorAccount: aggregatorAccount, queueAccount: this, - jobAuthorities: [], + jobAuthorities: [], // create lease before adding jobs to skip this step + jobPubkeys: [], }) )[1]; txns.push(leaseInit); @@ -626,7 +625,7 @@ export class QueueAccount extends Account { fundAmount?: number; funderAuthority?: Keypair; funderTokenAccount?: PublicKey; - } & Partial> & { + } & Partial & { // job params jobs?: Array<{ pubkey: PublicKey; weight?: number } | JobInitParams>; } @@ -745,8 +744,7 @@ export class QueueAccount extends Account { */ public async createVrfInstructions( payer: PublicKey, - params: Omit & - Partial> + params: Omit & Partial ): Promise<[VrfAccount, TransactionObject]> { const queue = await this.loadData(); @@ -808,8 +806,7 @@ export class QueueAccount extends Account { * ``` */ public async createVrf( - params: Omit & - Partial> + params: Omit & Partial ): Promise<[VrfAccount, TransactionSignature]> { const [vrfAccount, txn] = await this.createVrfInstructions( this.program.walletPubkey, @@ -845,7 +842,7 @@ export class QueueAccount extends Account { public async createBufferRelayerInstructions( payer: PublicKey, params: Omit, 'queueAccount'> & - Partial> & { + Partial & { // job params job: JobAccount | PublicKey | Omit; } @@ -947,7 +944,7 @@ export class QueueAccount extends Account { */ public async createBufferRelayer( params: Omit, 'queueAccount'> & - Partial> & { + Partial & { // job params job: JobAccount | PublicKey | Omit; } @@ -1166,7 +1163,7 @@ export interface QueueInitParams { */ name?: string; /** - * Buffer for queue metadata + * Metadata for the queue for easier identification. */ metadata?: string; /** @@ -1233,18 +1230,58 @@ export interface QueueInitParams { dataBufferKeypair?: Keypair; } -export type QueueSetConfigParams = Partial<{ +export interface QueueSetConfigParams { + /** Alternative keypair that is the queue authority and is permitted to make account changes. Defaults to the payer if not provided. */ authority?: anchor.web3.Keypair; - name: string; - metadata: string; - unpermissionedFeedsEnabled: boolean; - unpermissionedVrfEnabled: boolean; - enableBufferRelayers: boolean; - slashingEnabled: boolean; - varianceToleranceMultiplier: number; - oracleTimeout: number; - reward: number; - minStake: number; - consecutiveFeedFailureLimit: number; - consecutiveOracleFailureLimit: number; -}>; + /** + * A name to assign to this {@linkcode QueueAccount} + */ + name?: string; + /** + * Metadata for the queue for easier identification. + */ + metadata?: string; + /** + * Enabling this setting means data feeds do not need explicit permission to join the queue. + */ + unpermissionedFeedsEnabled?: boolean; + /** + * Enabling this setting means data feeds do not need explicit permission + * to request VRF proofs and verifications from this queue. + */ + unpermissionedVrfEnabled?: boolean; + /** + * Enabling this setting will allow buffer relayer accounts to call openRound. + */ + enableBufferRelayers?: boolean; + /** + * Whether slashing is enabled on this queue. + */ + slashingEnabled?: boolean; + /** + * The tolerated variance amount oracle results can have from the accepted round result + * before being slashed. + * slashBound = varianceToleranceMultiplier * stdDeviation + */ + varianceToleranceMultiplier?: number; + /** + * Time period (in seconds) we should remove an oracle after if no response. + */ + oracleTimeout?: number; + /** + * Rewards to provide oracles and round openers on this queue. + */ + reward?: number; + /** + * The minimum amount of stake oracles must present to remain on the queue. + */ + minStake?: number; + /** + * Consecutive failure limit for a feed before feed permission is revoked. + */ + consecutiveFeedFailureLimit?: number; + /** + * Consecutive failure limit for an oracle before oracle permission is revoked. + */ + consecutiveOracleFailureLimit?: number; +} diff --git a/javascript/solana.js/src/transaction.ts b/javascript/solana.js/src/transaction.ts index 3929245..3cea3ea 100644 --- a/javascript/solana.js/src/transaction.ts +++ b/javascript/solana.js/src/transaction.ts @@ -109,6 +109,11 @@ export class TransactionObject implements ITransactionObject { throw new errors.SwitchboardProgramReadOnlyError(); } + // if empty object, return + if (ixns.length === 0) { + return; + } + // verify num ixns if (ixns.length > 10) { throw new errors.TransactionInstructionOverflowError(ixns.length); diff --git a/javascript/solana.js/test/aggregator.spec.ts b/javascript/solana.js/test/aggregator.spec.ts index b0d3340..f70635b 100644 --- a/javascript/solana.js/test/aggregator.spec.ts +++ b/javascript/solana.js/test/aggregator.spec.ts @@ -8,9 +8,12 @@ import { AggregatorAccount, JobAccount, LeaseAccount, + PermissionAccount, QueueAccount, } from '../src'; import { OracleJob } from '@switchboard-xyz/common'; +import { assert } from 'console'; +import { PermitOracleQueueUsage } from '../src/generated/types/SwitchboardPermission'; describe('Aggregator Tests', () => { let ctx: TestContext; @@ -248,4 +251,86 @@ describe('Aggregator Tests', () => { ); } }); + + it('Transfers aggregator to a new queue', async () => { + const newQueueAuthority = Keypair.generate(); + + const [newQueue] = await sbv2.QueueAccount.create(ctx.program, { + name: 'new-queue', + metadata: '', + authority: newQueueAuthority.publicKey, + queueSize: 1, + reward: 0, + minStake: 0, + oracleTimeout: 86400, + slashingEnabled: false, + unpermissionedFeeds: true, + unpermissionedVrf: true, + enableBufferRelayers: false, + }); + + const [aggregatorAccount] = await queueAccount.createFeed({ + queueAuthority: queueAuthority, + batchSize: 1, + minRequiredOracleResults: 1, + minRequiredJobResults: 1, + minUpdateDelaySeconds: 60, + fundAmount: 1.25, + enable: true, + jobs: [ + { + weight: 1, + data: OracleJob.encodeDelimited( + OracleJob.fromObject({ + tasks: [ + { + valueTask: { + value: 1, + }, + }, + ], + }) + ).finish(), + }, + ], + }); + await aggregatorAccount.loadData(); + + await aggregatorAccount.transfer({ + newQueue, + enable: true, + queueAuthority: newQueueAuthority, + }); + + const aggregator = await aggregatorAccount.loadData(); + assert( + aggregator.queuePubkey.equals(newQueue.publicKey), + `Aggregator queue mismatch, expected ${newQueue.publicKey}, received ${aggregator.queuePubkey}` + ); + + const [newLeaseAccount] = LeaseAccount.fromSeed( + ctx.program, + newQueue.publicKey, + aggregatorAccount.publicKey + ); + const balance = await newLeaseAccount.getBalance(); + assert( + balance === 1.25, + `Aggregator did not transfer full lease balance, expected 1.25, received ${balance}` + ); + + const [newPermissionAccount] = PermissionAccount.fromSeed( + ctx.program, + newQueueAuthority.publicKey, + newQueue.publicKey, + aggregatorAccount.publicKey + ); + const newPermission = await newPermissionAccount.loadData(); + assert( + newPermission.permissions === PermitOracleQueueUsage.discriminator + 1, + `Aggregator permission mismatch, expected ${ + PermitOracleQueueUsage.discriminator + 1 + }, received ${newPermission.permissions}` + ); + }); });