-
Notifications
You must be signed in to change notification settings - Fork 38
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
feat(sdk): add action builders #134
Open
joeymeere
wants to merge
10
commits into
Squads-Protocol:main
Choose a base branch
from
joeymeere:feature/eng-3084-wrapper-methods-for-v4-sdk
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
9df7864
wip(sdk): add action builders
joeymeere a205ebb
feat(sdk): scaffold batch action builder
joeymeere ec367d2
feat(sdk): batch create actions + enforce method order
joeymeere 50ca3b7
refactor(sdk): fix async handling
joeymeere 0bee728
feat(sdk): create builder instance from account key
joeymeere 7850ae2
wip: testing quirks
joeymeere 5690d2e
all tests passing
joeymeere 291100e
feat(test): refactor + add granular tests
joeymeere 960ff2e
chore(docs): refine typedocs & tests
joeymeere 5749d4e
Merge branch 'main' into feature/eng-3084-wrapper-methods-for-v4-sdk
joeymeere File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,289 @@ | ||
import { | ||
Connection, | ||
Keypair, | ||
PublicKey, | ||
TransactionInstruction, | ||
TransactionMessage, | ||
TransactionSignature, | ||
VersionedTransaction, | ||
} from "@solana/web3.js"; | ||
import { | ||
BaseBuilderArgs, | ||
BuildResult, | ||
BuildTransactionSettings, | ||
SendSettings, | ||
} from "./types"; | ||
|
||
export abstract class BaseBuilder< | ||
T extends BuildResult, | ||
U extends BaseBuilderArgs = BaseBuilderArgs | ||
> { | ||
public createKey?: Keypair; | ||
protected connection: Connection; | ||
protected instructions: TransactionInstruction[] = []; | ||
protected creator: PublicKey = PublicKey.default; | ||
protected buildPromise: Promise<void>; | ||
protected args: Omit<U, keyof BaseBuilderArgs>; | ||
private built: boolean = false; | ||
// Use this as an indicator to clear all instructions? | ||
private sent: boolean = false; | ||
|
||
constructor(args: U, options: { generateCreateKey?: boolean } = {}) { | ||
this.connection = args.connection; | ||
this.creator = args.creator; | ||
this.args = this.extractAdditionalArgs(args); | ||
if (options.generateCreateKey) { | ||
this.createKey = Keypair.generate(); | ||
} | ||
this.buildPromise = this.initializeBuild(); | ||
} | ||
|
||
private async initializeBuild(): Promise<void> { | ||
await this.build(); | ||
this.built = true; | ||
} | ||
|
||
protected async ensureBuilt(): Promise<void> { | ||
if (!this.built) { | ||
await this.buildPromise; | ||
} | ||
} | ||
|
||
private extractAdditionalArgs(args: U): Omit<U, keyof BaseBuilderArgs> { | ||
const { connection, creator, ...additionalArgs } = args; | ||
return additionalArgs; | ||
} | ||
|
||
protected abstract build(): Promise<void>; | ||
|
||
/** | ||
* Fetches built instructions. Will always contain at least one instruction corresponding to | ||
* the builder you are using, unless cleared after sending. | ||
* @returns `Promise<TransactionInstruction[]>` - An array of built instructions. | ||
*/ | ||
async getInstructions(): Promise<TransactionInstruction[]> { | ||
await this.ensureBuilt(); | ||
return this.instructions; | ||
} | ||
|
||
/** | ||
* Creates a `VersionedTransaction` containing the corresponding instruction(s). | ||
* | ||
* @args {@link BuildTransactionSettings} - **(Optional)** Address Lookup Table accounts, signers, a custom fee-payer to add to the transaction. | ||
* @returns `VersionedTransaction` | ||
* | ||
* @example | ||
* // Get pre-built transaction from builder instance. | ||
* const builder = createMultisig({ | ||
* // ... args | ||
* }); | ||
* const transaction = await builder.transaction(); | ||
* @example | ||
* // Run chained async method to return the | ||
* // transaction all in one go. | ||
* const transaction = await createMultisig({ | ||
* // ... args | ||
* }).transaction(); | ||
*/ | ||
async transaction( | ||
settings?: BuildTransactionSettings | ||
): Promise<VersionedTransaction> { | ||
await this.ensureBuilt(); | ||
const message = new TransactionMessage({ | ||
payerKey: settings?.feePayer?.publicKey ?? this.creator, | ||
recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, | ||
instructions: [...this.instructions], | ||
}).compileToV0Message(settings?.addressLookupTableAccounts); | ||
|
||
const tx = new VersionedTransaction(message); | ||
if (settings?.feePayer) { | ||
tx.sign([settings?.feePayer]); | ||
} | ||
if (settings?.signers) { | ||
tx.sign([...settings?.signers]); | ||
} | ||
return tx; | ||
} | ||
|
||
/** | ||
* Builds a transaction with the corresponding instruction(s), and sends it. | ||
* | ||
* **NOTE: Not wallet-adapter compatible.** | ||
* | ||
* @args {@link SendSettings} - Optional pre/post instructions, fee payer, and send options. | ||
* @returns `TransactionSignature` | ||
* @example | ||
* const builder = createMultisig({ | ||
* // ... args | ||
* }); | ||
* const signature = await builder.send(); | ||
* @example | ||
* const builder = createMultisig({ | ||
* // ... args | ||
* }); | ||
* | ||
* // With settings | ||
* const signature = await builder.send({ | ||
* preInstructions: [...preInstructions], | ||
* postInstructions: [...postInstructions], | ||
* feePayer: someKeypair, | ||
* options: { skipPreflight: true }, | ||
* }); | ||
*/ | ||
async send(settings?: SendSettings): Promise<TransactionSignature> { | ||
await this.ensureBuilt(); | ||
const instructions = [...this.instructions]; | ||
if (settings?.preInstructions) { | ||
instructions.unshift(...settings.preInstructions); | ||
} | ||
if (settings?.postInstructions) { | ||
instructions.push(...settings.postInstructions); | ||
} | ||
const message = new TransactionMessage({ | ||
payerKey: settings?.feePayer?.publicKey ?? this.creator, | ||
recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, | ||
instructions: [...instructions], | ||
}).compileToV0Message(settings?.addressLookupTableAccounts); | ||
|
||
const tx = new VersionedTransaction(message); | ||
if (settings?.feePayer) { | ||
tx.sign([settings.feePayer]); | ||
} | ||
if (settings?.signers) { | ||
tx.sign([...settings.signers]); | ||
} | ||
const signature = await this.connection.sendTransaction( | ||
tx, | ||
settings?.options | ||
); | ||
this.sent = true; | ||
|
||
if (settings?.clearInstructions) { | ||
this.instructions = []; | ||
} | ||
|
||
return signature; | ||
} | ||
|
||
/** | ||
* Builds a transaction with the corresponding instruction(s), sends it, and confirms the transaction. | ||
* | ||
* **NOTE: Not wallet-adapter compatible.** | ||
* | ||
* @args {@link SendSettings} - Optional pre/post instructions, fee payer keypair, and send options. | ||
* @returns `TransactionSignature` | ||
* @example | ||
* const builder = createMultisig({ | ||
* // ... args | ||
* }); | ||
* const signature = await builder.sendAndConfirm(); | ||
* @example | ||
* const builder = createMultisig({ | ||
* // ... args | ||
* }); | ||
* | ||
* // With settings | ||
* const signature = await builder.sendAndConfirm({ | ||
* preInstructions: [...preInstructions], | ||
* postInstructions: [...postInstructions], | ||
* feePayer: someKeypair, | ||
* options: { skipPreflight: true }, | ||
* }); | ||
*/ | ||
async sendAndConfirm(settings?: SendSettings): Promise<TransactionSignature> { | ||
await this.ensureBuilt(); | ||
const instructions = [...this.instructions]; | ||
if (settings?.preInstructions) { | ||
instructions.unshift(...settings.preInstructions); | ||
} | ||
if (settings?.postInstructions) { | ||
instructions.push(...settings.postInstructions); | ||
} | ||
const message = new TransactionMessage({ | ||
payerKey: settings?.feePayer?.publicKey ?? this.creator, | ||
recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, | ||
instructions: [...instructions], | ||
}).compileToV0Message(settings?.addressLookupTableAccounts); | ||
|
||
const tx = new VersionedTransaction(message); | ||
if (settings?.feePayer) { | ||
tx.sign([settings.feePayer]); | ||
} | ||
if (settings?.signers) { | ||
tx.sign([...settings.signers]); | ||
} | ||
const signature = await this.connection.sendTransaction( | ||
tx, | ||
settings?.options | ||
); | ||
|
||
let commitment = settings?.options?.preflightCommitment; | ||
|
||
let sent = false; | ||
const maxAttempts = 10; | ||
const delayMs = 1000; | ||
for (let attempt = 0; attempt < maxAttempts && !sent; attempt++) { | ||
const status = await this.connection.getSignatureStatus(signature); | ||
if (status?.value?.confirmationStatus === commitment || "confirmed") { | ||
await new Promise((resolve) => setTimeout(resolve, delayMs)); | ||
sent = true; | ||
} else { | ||
await new Promise((resolve) => setTimeout(resolve, delayMs)); | ||
} | ||
} | ||
|
||
if (!sent) { | ||
throw new Error( | ||
"Transaction was not confirmed within the expected timeframe" | ||
); | ||
} | ||
|
||
if (settings?.clearInstructions) { | ||
this.instructions = []; | ||
} | ||
|
||
return signature; | ||
} | ||
|
||
/** | ||
* We build a message with the corresponding instruction(s), you give us a callback | ||
* for post-processing, sending, and confirming. | ||
* | ||
* @args `callback` - Async function with `TransactionMessage` as argument, and `TransactionSignature` as return value. | ||
* @returns `TransactionSignature` | ||
* | ||
* @example | ||
* const txBuilder = createVaultTransaction({ | ||
* connection, | ||
* creator: creator, | ||
* message: message | ||
* multisig: multisig, | ||
* vaultIndex: 0, | ||
* }); | ||
* | ||
* await txBuilder | ||
* .withProposal() | ||
* .withApproval() | ||
* .withExecute(); | ||
* | ||
* const signature = await txBuilder.customSend( | ||
* // Callback with transaction message, and your function. | ||
* async (msg) => await customSender(msg, connection) | ||
* ); | ||
*/ | ||
async customSend( | ||
callback: (args: TransactionMessage) => Promise<TransactionSignature> | ||
): Promise<TransactionSignature> { | ||
await this.ensureBuilt(); | ||
const message = new TransactionMessage({ | ||
payerKey: this.creator, | ||
recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, | ||
instructions: [...this.instructions], | ||
}); | ||
|
||
const signature = await callback(message); | ||
this.sent = true; | ||
|
||
return signature; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { PublicKey } from "@solana/web3.js"; | ||
import { TransactionBuildResult, TransactionBuilderArgs } from "./types"; | ||
import { PROGRAM_ID, Proposal } from "../../generated"; | ||
import { getProposalPda, getTransactionPda } from "../../pda"; | ||
import { BaseBuilder } from "./base"; | ||
|
||
export abstract class BaseTransactionBuilder< | ||
T extends TransactionBuildResult, | ||
U extends TransactionBuilderArgs | ||
> extends BaseBuilder<T, U> { | ||
public index: number = 1; | ||
public vaultIndex: number = 0; | ||
|
||
constructor(args: U) { | ||
super(args); | ||
} | ||
|
||
async getIndex(): Promise<number> { | ||
await this.ensureBuilt(); | ||
return this.index; | ||
} | ||
|
||
/** | ||
* Fetches the `PublicKey` of the corresponding account for the transaction being built. | ||
* | ||
* @returns `PublicKey` | ||
*/ | ||
async getTransactionKey(): Promise<PublicKey> { | ||
await this.ensureBuilt(); | ||
const index = this.index; | ||
const [transactionPda] = getTransactionPda({ | ||
multisigPda: this.args.multisig, | ||
index: BigInt(index ?? 1), | ||
programId: this.args.programId ?? PROGRAM_ID, | ||
}); | ||
|
||
return transactionPda; | ||
} | ||
|
||
/** | ||
* Fetches the `PublicKey` of the corresponding {@link Proposal} account for the transaction being built. | ||
* | ||
* @returns `PublicKey` | ||
*/ | ||
getProposalKey(): PublicKey { | ||
const index = this.index; | ||
const [proposalPda] = getProposalPda({ | ||
multisigPda: this.args.multisig, | ||
transactionIndex: BigInt(index ?? 1), | ||
programId: this.args.programId ?? PROGRAM_ID, | ||
}); | ||
|
||
return proposalPda; | ||
} | ||
|
||
/** | ||
* Fetches and deserializes the {@link Proposal} account after it is built and sent. | ||
* @args `key` - The public key of the `Proposal` account. | ||
* @returns `Proposal` - Deserialized `Proposal` account data. | ||
*/ | ||
async getProposalAccount(key: PublicKey) { | ||
return this.buildPromise.then(async () => { | ||
const propAccount = await Proposal.fromAccountAddress( | ||
this.connection, | ||
key | ||
); | ||
|
||
return propAccount; | ||
}); | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
You may have already considered this, but if you want methods and properties to be "truly" private, meaning they can't be accessed with trickery, I recommend using the native JS private properties
#send: boolean = false.
In case you're curious: MDN: Private Properties