-
Notifications
You must be signed in to change notification settings - Fork 261
OPDATA-7578: Refactor Solana exchange-rate shared utilities #5128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
b04b6a5
98ac887
bd429a7
2a2b1ef
f26783e
2e4c934
ddd4f56
cd5e81a
f7f069b
71d87ca
b5f794f
7aabb83
fb3738a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@chainlink/solana-functions-adapter': patch | ||
| --- | ||
|
|
||
| Add shared Solana exchange-rate utilities. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' | ||
|
|
||
| export const RESULT_DECIMALS = 18 | ||
| const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/ | ||
|
|
||
| export const parseRateBound = (value: string, name: string) => { | ||
| if (!POSITIVE_INTEGER_PATTERN.test(value)) { | ||
| throw new AdapterInputError({ | ||
| message: `${name} must be a positive base-10 integer string`, | ||
| statusCode: 400, | ||
| }) | ||
| } | ||
|
|
||
| return BigInt(value) | ||
| } | ||
|
|
||
| export const parseRateBounds = (minRateValue: string, maxRateValue: string) => { | ||
| const minRate = parseRateBound(minRateValue, 'minRate') | ||
| const maxRate = parseRateBound(maxRateValue, 'maxRate') | ||
| if (minRate > maxRate) { | ||
| throw new AdapterInputError({ | ||
| message: 'minRate must be less than or equal to maxRate', | ||
| statusCode: 400, | ||
| }) | ||
| } | ||
|
|
||
| return { minRate, maxRate } | ||
| } | ||
|
|
||
| export const applyRateBounds = (computedRate: bigint, minRate: bigint, maxRate: bigint) => { | ||
| const rate = computedRate < minRate ? minRate : computedRate > maxRate ? maxRate : computedRate | ||
|
|
||
| return { | ||
| rate, | ||
| boundsApplied: rate !== computedRate, | ||
| } | ||
| } | ||
|
|
||
| export const calculateNormalizedRate = ( | ||
| assets: bigint, | ||
| shares: bigint, | ||
| assetDecimals: number, | ||
| shareDecimals: number, | ||
| ) => { | ||
| if (shares === 0n) { | ||
| return null | ||
| } | ||
|
|
||
| return ( | ||
| (assets * 10n ** BigInt(RESULT_DECIMALS + shareDecimals)) / | ||
| (shares * 10n ** BigInt(assetDecimals)) | ||
| ) | ||
| } | ||
|
|
||
| export const calculateUnvestedAssets = ( | ||
| assets: bigint, | ||
| unixTimestamp: bigint, | ||
| vestingStartTime: bigint, | ||
| vestingEndTime: bigint, | ||
| ) => { | ||
| if (assets === 0n || vestingEndTime <= vestingStartTime || unixTimestamp >= vestingEndTime) { | ||
| return 0n | ||
| } | ||
| if (unixTimestamp <= vestingStartTime) { | ||
| return assets | ||
| } | ||
|
|
||
| return (assets * (vestingEndTime - unixTimestamp)) / (vestingEndTime - vestingStartTime) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' | ||
| import { address, getProgramDerivedAddress } from '@solana/addresses' | ||
| import { type Rpc, type SolanaRpcApi } from '@solana/rpc' | ||
|
|
||
| export const CLOCK_SYSVAR_ADDRESS = 'SysvarC1ock11111111111111111111111111111111' | ||
|
|
||
| const CLOCK_ACCOUNT_LENGTH = 40 | ||
| const CLOCK_UNIX_TIMESTAMP_OFFSET = 32 | ||
|
|
||
| type MultipleAccountsResponse = Awaited< | ||
| ReturnType<ReturnType<Rpc<SolanaRpcApi>['getMultipleAccounts']>['send']> | ||
| > | ||
|
|
||
| export type AccountInfo = NonNullable<MultipleAccountsResponse['value']>[number] | ||
|
|
||
| export type PdaSeed = Parameters<typeof getProgramDerivedAddress>[0]['seeds'][number] | ||
|
|
||
| export const parseSolanaAddress = (value: string, name: string) => { | ||
| try { | ||
| return address(value) | ||
| } catch { | ||
| throw new AdapterInputError({ | ||
| message: `${name} must be a valid Solana address`, | ||
| statusCode: 400, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| export const derivePda = async (programAddress: string, seeds: PdaSeed[]) => { | ||
| const [pda] = await getProgramDerivedAddress({ | ||
| programAddress: parseSolanaAddress(programAddress, 'programAddress'), | ||
| seeds, | ||
| }) | ||
|
|
||
| return pda | ||
| } | ||
|
Comment on lines
+46
to
+53
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that async fetchAccountInformationByAddressAndSeeds<T>(
rpc, programAddress: Address, seeds: any[], accountName, idl,
): Promise<T> {
const [pda] = await getProgramDerivedAddress({ programAddress, seeds })
}which is the same Two asks:
|
||
|
|
||
| export const getAccountDataBuffer = ( | ||
| accountInfo: AccountInfo | null | undefined, | ||
| description: string, | ||
| ) => { | ||
| const encodedData = accountInfo?.data?.[0] | ||
| if (!encodedData) { | ||
| throw new AdapterInputError({ | ||
| message: `No account data found for ${description}`, | ||
| statusCode: 500, | ||
| }) | ||
| } | ||
|
|
||
| return Buffer.from(encodedData, 'base64') | ||
| } | ||
|
|
||
| export const assertOwnerProgram = ( | ||
| accountInfo: AccountInfo | null | undefined, | ||
| description: string, | ||
| expectedOwners: string[], | ||
| ownerDescription: string, | ||
| ) => { | ||
| const owner = accountInfo?.owner?.toString() | ||
| if (!owner || !expectedOwners.includes(owner)) { | ||
| throw new AdapterInputError({ | ||
| message: `Expected ${description} to be owned by ${ownerDescription} [${expectedOwners.join( | ||
| ', ', | ||
| )}], found '${owner}'`, | ||
| statusCode: 500, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| export const assertNameMatches = ( | ||
| actualName: string, | ||
| expectedName: string, | ||
| description: string, | ||
| ) => { | ||
| if (actualName !== expectedName) { | ||
| throw new AdapterInputError({ | ||
| message: `Expected ${description} name to be '${expectedName}', found '${actualName}'`, | ||
| statusCode: 500, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| export const assertAddressMatches = (actual: string, expected: string, description: string) => { | ||
| if (actual !== expected) { | ||
| throw new AdapterInputError({ | ||
| message: `Expected ${description} to be '${expected}', found '${actual}'`, | ||
| statusCode: 500, | ||
| }) | ||
| } | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove dead exports.**
Comment on lines
+55
to
+81
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix the error taxonomy in
|
||
|
|
||
| export const assertDataLength = (data: Buffer, description: string, minLength: number) => { | ||
| if (data.length < minLength) { | ||
| throw new AdapterInputError({ | ||
| message: `Expected ${description} account data to be at least ${minLength} bytes, found ${data.length}`, | ||
| statusCode: 500, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| export const assertDiscriminator = (data: Buffer, description: string, discriminator: Buffer) => { | ||
| if (!data.subarray(0, discriminator.length).equals(discriminator)) { | ||
| throw new AdapterInputError({ | ||
| message: `Expected ${description} discriminator to be ${discriminator.toString( | ||
| 'hex', | ||
| )}, found ${data.subarray(0, discriminator.length).toString('hex')}`, | ||
| statusCode: 500, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| export const decodeClockUnixTimestamp = (accountInfo: AccountInfo | null | undefined) => { | ||
| const data = getAccountDataBuffer(accountInfo, `Clock sysvar '${CLOCK_SYSVAR_ADDRESS}'`) | ||
| assertDataLength(data, 'Clock sysvar', CLOCK_ACCOUNT_LENGTH) | ||
|
|
||
| return data.readBigInt64LE(CLOCK_UNIX_TIMESTAMP_OFFSET) | ||
| } | ||
|
|
||
| export const fetchMultipleAccounts = async (rpc: Rpc<SolanaRpcApi>, addresses: string[]) => { | ||
| const encoding = 'base64' as const | ||
| const validatedAddresses = addresses.map((accountAddress) => | ||
| parseSolanaAddress(accountAddress, 'address'), | ||
| ) | ||
| const resp = await rpc.getMultipleAccounts(validatedAddresses, { encoding }).send() | ||
|
|
||
| if (!resp.value || resp.value.length !== addresses.length) { | ||
| throw new AdapterInputError({ | ||
| message: `Expected ${addresses.length} account responses, received ${ | ||
| resp.value?.length ?? 0 | ||
| }`, | ||
| statusCode: 500, | ||
| }) | ||
| } | ||
|
|
||
| return resp.value | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please consider validation using
customInputValidation, exampleThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@magiodev-cll I think this one was missed, please let me know if it's unclear.