Skip to content

Add InstructionPlan type and helpers #533

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

Open
wants to merge 1 commit into
base: 06-04-add_empty_solana_instruction-plans_package
Choose a base branch
from

Conversation

lorisleiva
Copy link
Member

@lorisleiva lorisleiva commented Jun 6, 2025

This PR adds the InstructionPlan type and a variety of helpers to create InstructionPlans of different kinds.

Copy link

changeset-bot bot commented Jun 6, 2025

🦋 Changeset detected

Latest commit: 0895d75

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 41 packages
Name Type
@solana/instruction-plans Patch
@solana/accounts Patch
@solana/addresses Patch
@solana/assertions Patch
@solana/codecs-core Patch
@solana/codecs-data-structures Patch
@solana/codecs-numbers Patch
@solana/codecs-strings Patch
@solana/codecs Patch
@solana/compat Patch
@solana/errors Patch
@solana/fast-stable-stringify Patch
@solana/functional Patch
@solana/instructions Patch
@solana/keys Patch
@solana/kit Patch
@solana/nominal-types Patch
@solana/options Patch
@solana/programs Patch
@solana/promises Patch
@solana/react Patch
@solana/rpc-api Patch
@solana/rpc-graphql Patch
@solana/rpc-parsed-types Patch
@solana/rpc-spec-types Patch
@solana/rpc-spec Patch
@solana/rpc-subscriptions-api Patch
@solana/rpc-subscriptions-channel-websocket Patch
@solana/rpc-subscriptions-spec Patch
@solana/rpc-subscriptions Patch
@solana/rpc-transformers Patch
@solana/rpc-transport-http Patch
@solana/rpc-types Patch
@solana/rpc Patch
@solana/signers Patch
@solana/subscribable Patch
@solana/sysvars Patch
@solana/transaction-confirmation Patch
@solana/transaction-messages Patch
@solana/transactions Patch
@solana/webcrypto-ed25519-polyfill Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Member Author

lorisleiva commented Jun 6, 2025

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

Copy link

bundlemon bot commented Jun 6, 2025

BundleMon

Files added (3)
Status Path Size Limits
instruction-plans/dist/index.browser.mjs
+873B -
instruction-plans/dist/index.native.mjs
+872B -
instruction-plans/dist/index.node.mjs
+871B -
Unchanged files (127)
Status Path Size Limits
@solana/kit production bundle
kit/dist/index.production.min.js
34.39KB -
rpc-graphql/dist/index.browser.mjs
18.78KB -
rpc-graphql/dist/index.native.mjs
18.78KB -
rpc-graphql/dist/index.node.mjs
18.78KB -
errors/dist/index.node.mjs
14.54KB -
errors/dist/index.browser.mjs
14.52KB -
errors/dist/index.native.mjs
14.52KB -
transaction-messages/dist/index.browser.mjs
7.24KB -
transaction-messages/dist/index.native.mjs
7.24KB -
transaction-messages/dist/index.node.mjs
7.24KB -
codecs-data-structures/dist/index.native.mjs
4.77KB -
codecs-data-structures/dist/index.browser.mjs
4.77KB -
codecs-data-structures/dist/index.node.mjs
4.77KB -
webcrypto-ed25519-polyfill/dist/index.node.mj
s
3.57KB -
webcrypto-ed25519-polyfill/dist/index.browser
.mjs
3.56KB -
webcrypto-ed25519-polyfill/dist/index.native.
mjs
3.54KB -
rpc-subscriptions/dist/index.browser.mjs
3.38KB -
rpc-subscriptions/dist/index.node.mjs
3.34KB -
rpc-subscriptions/dist/index.native.mjs
3.31KB -
codecs-core/dist/index.browser.mjs
3.3KB -
codecs-core/dist/index.native.mjs
3.3KB -
codecs-core/dist/index.node.mjs
3.3KB -
rpc-transformers/dist/index.browser.mjs
2.93KB -
rpc-transformers/dist/index.native.mjs
2.93KB -
rpc-transformers/dist/index.node.mjs
2.93KB -
addresses/dist/index.browser.mjs
2.86KB -
addresses/dist/index.native.mjs
2.86KB -
addresses/dist/index.node.mjs
2.86KB -
kit/dist/index.browser.mjs
2.71KB -
kit/dist/index.native.mjs
2.71KB -
kit/dist/index.node.mjs
2.71KB -
signers/dist/index.browser.mjs
2.63KB -
signers/dist/index.native.mjs
2.63KB -
signers/dist/index.node.mjs
2.62KB -
codecs-strings/dist/index.browser.mjs
2.53KB -
codecs-strings/dist/index.node.mjs
2.48KB -
codecs-strings/dist/index.native.mjs
2.45KB -
transaction-confirmation/dist/index.node.mjs
2.39KB -
sysvars/dist/index.browser.mjs
2.35KB -
sysvars/dist/index.native.mjs
2.34KB -
transaction-confirmation/dist/index.native.mj
s
2.34KB -
sysvars/dist/index.node.mjs
2.34KB -
transaction-confirmation/dist/index.browser.m
js
2.34KB -
transactions/dist/index.browser.mjs
2.27KB -
transactions/dist/index.native.mjs
2.27KB -
transactions/dist/index.node.mjs
2.26KB -
rpc-subscriptions-spec/dist/index.node.mjs
2.14KB -
rpc-subscriptions-spec/dist/index.native.mjs
2.09KB -
rpc-subscriptions-spec/dist/index.browser.mjs
2.09KB -
keys/dist/index.browser.mjs
2.02KB -
keys/dist/index.native.mjs
2.02KB -
keys/dist/index.node.mjs
2.02KB -
codecs-numbers/dist/index.native.mjs
2.01KB -
codecs-numbers/dist/index.browser.mjs
2.01KB -
codecs-numbers/dist/index.node.mjs
2.01KB -
react/dist/index.native.mjs
1.99KB -
react/dist/index.browser.mjs
1.99KB -
react/dist/index.node.mjs
1.99KB -
rpc/dist/index.node.mjs
1.95KB -
rpc-transport-http/dist/index.browser.mjs
1.91KB -
rpc-transport-http/dist/index.native.mjs
1.91KB -
rpc/dist/index.native.mjs
1.8KB -
rpc/dist/index.browser.mjs
1.8KB -
subscribable/dist/index.node.mjs
1.8KB -
subscribable/dist/index.native.mjs
1.75KB -
subscribable/dist/index.browser.mjs
1.74KB -
rpc-transport-http/dist/index.node.mjs
1.73KB -
rpc-types/dist/index.browser.mjs
1.53KB -
rpc-types/dist/index.native.mjs
1.53KB -
rpc-types/dist/index.node.mjs
1.53KB -
rpc-subscriptions-channel-websocket/dist/inde
x.node.mjs
1.33KB -
rpc-subscriptions-channel-websocket/dist/inde
x.native.mjs
1.27KB -
rpc-subscriptions-channel-websocket/dist/inde
x.browser.mjs
1.26KB -
options/dist/index.browser.mjs
1.18KB -
options/dist/index.native.mjs
1.18KB -
options/dist/index.node.mjs
1.17KB -
accounts/dist/index.browser.mjs
1.13KB -
accounts/dist/index.native.mjs
1.12KB -
accounts/dist/index.node.mjs
1.12KB -
compat/dist/index.browser.mjs
971B -
compat/dist/index.native.mjs
970B -
compat/dist/index.node.mjs
968B -
rpc-spec-types/dist/index.browser.mjs
964B -
rpc-api/dist/index.browser.mjs
963B -
rpc-api/dist/index.native.mjs
962B -
rpc-spec-types/dist/index.native.mjs
962B -
rpc-api/dist/index.node.mjs
961B -
rpc-spec-types/dist/index.node.mjs
961B -
rpc-subscriptions-api/dist/index.native.mjs
870B -
rpc-subscriptions-api/dist/index.node.mjs
869B -
rpc-subscriptions-api/dist/index.browser.mjs
868B -
rpc-spec/dist/index.browser.mjs
852B -
rpc-spec/dist/index.native.mjs
851B -
rpc-spec/dist/index.node.mjs
850B -
promises/dist/index.browser.mjs
799B -
promises/dist/index.native.mjs
798B -
promises/dist/index.node.mjs
797B -
assertions/dist/index.browser.mjs
783B -
instructions/dist/index.browser.mjs
769B -
instructions/dist/index.native.mjs
768B -
instructions/dist/index.node.mjs
767B -
fast-stable-stringify/dist/index.browser.mjs
726B -
fast-stable-stringify/dist/index.native.mjs
725B -
assertions/dist/index.native.mjs
724B -
fast-stable-stringify/dist/index.node.mjs
724B -
assertions/dist/index.node.mjs
723B -
programs/dist/index.browser.mjs
329B -
programs/dist/index.native.mjs
327B -
programs/dist/index.node.mjs
325B -
event-target-impl/dist/index.node.mjs
230B -
functional/dist/index.browser.mjs
154B -
functional/dist/index.native.mjs
152B -
text-encoding-impl/dist/index.native.mjs
152B -
functional/dist/index.node.mjs
151B -
codecs/dist/index.browser.mjs
137B -
codecs/dist/index.native.mjs
136B -
codecs/dist/index.node.mjs
134B -
event-target-impl/dist/index.browser.mjs
133B -
ws-impl/dist/index.node.mjs
131B -
text-encoding-impl/dist/index.browser.mjs
122B -
text-encoding-impl/dist/index.node.mjs
119B -
ws-impl/dist/index.browser.mjs
113B -
crypto-impl/dist/index.node.mjs
111B -
crypto-impl/dist/index.browser.mjs
109B -
rpc-parsed-types/dist/index.browser.mjs
66B -
rpc-parsed-types/dist/index.native.mjs
65B -
rpc-parsed-types/dist/index.node.mjs
63B -

Total files change +2.55KB +0.74%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

@lorisleiva lorisleiva marked this pull request as ready for review June 6, 2025 16:16
@lorisleiva lorisleiva force-pushed the 06-05-add_instructionplan_type_and_helpers branch 3 times, most recently from e5531e9 to 4b471ec Compare June 6, 2025 16:29
Comment on lines +198 to +205
// TODO(loris): Either remove lifetime constraint or use the new
// `fillMissingTransactionMessageLifetimeUsingProvisoryBlockhash`
// function from https://github.com/anza-xyz/kit/pull/519.
m =>
setTransactionMessageLifetimeUsingBlockhash(
{} as Parameters<typeof setTransactionMessageLifetimeUsingBlockhash>[0],
m,
),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@steveluscher Here's an example showing how coupled the CompilableTransactionMessage type is to this instruction plan system.

Copy link
Contributor

github-actions bot commented Jun 6, 2025

Documentation Preview: https://kit-docs-ewgovoqca-anza-tech.vercel.app

Copy link
Collaborator

@steveluscher steveluscher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's talk about this iterator design. Check some assumptions for me.

  1. The iterator produces instructions. Given a transaction message, it will produce the next instruction or null if it doesn't fit in the supplied message.
  2. The getAll() method doesn't consider any one transaction message; it just presumes infinite space for instructions and gives them to you without complaining.
  3. As far as I can tell, the only use case for obtaining an instruction is to append it to the same transaction message you passed to next().

If those are even close to true, could the API instead take in a transaction message, and do one of two things:

  1. Pack all the instructions into a copy of the message then return that.
  2. Throw an error with:
    • A new message with as many instructions packed into it as would fit.
    • A new InstructionPlan you can use to pack the remaining ones.
// https://codesandbox.io/p/sandbox/generator-for-instruction-plan-24rpp8
function getWhateverWeCallThis<TInstruction extends Instruction = Instruction>(
  instructions: TInstruction[]
) {
  return {
    packThatMessage(message: BaseTransactionMessage) {
      let out = [message];
      for (let ii = 0; ii < instructions.length; ii++) {
        out.push(`instruction-${instructions[ii]}`);
        if (ii === 2) {
          throw new ItDidNotFitError(out.join(","), instructions.slice(ii + 1));
        }
      }
      return out.join(",");
    },
    [Symbol.iterator](): Iterator<string> {
      let ii = 0;
      return {
        next() {
          if (ii == instructions.length) {
            return {
              value: undefined,
              done: true,
            };
          }
          return {
            value: `instruction-${ii++}`,
            done: false,
          };
        },
      };
    },
  };
}

let thing = getWhateverWeCallThis([
  "doThis",
  "doThat",
  "doTheOtherThing",
  "doOneTooManyThings",
]);

console.log("--- Get all the instructions");
const allInstructions = Array.from(thing);
console.log({ allInstructions });

console.log("--- Pack the instructions into a message");
let message = "message";
while (true) {
  try {
    const packedMessage = thing.packThatMessage(message);
    console.log("packedMessage", packedMessage);
    break;
  } catch (e) {
    if (e instanceof ItDidNotFitError) {
      console.log(
        "At least we managed to pack this so far",
        e.whateverWePackedSoFar
      );
      thing = e.continuationThing;
      message = "newMessage";
      continue;
    }
    throw e;
  }
}

Also, the ‘thing’ can implement the Iterator protocol, so that you can get all the instructions in the normal way (ie. passing the Iterator to Array.from()).

Am I too far off?

expect(iterator.hasNext()).toBe(false);
expect(iterator.next(message)).toBeNull();
});
it('offers a `getAll` method that packs everything into a single instruction', () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessarily true, or is it just that the one in this test happens to pack it into a single instruction?

@lorisleiva
Copy link
Member Author

Let me just step back a bit and add more context on the IterableInstructionPlan.

Without the IterableInstructionPlan, we can only produce plans whose instruction size is known regardless of the transaction message being packed. That is, creating a plan with sequential, parallel and single nodes, will always produce the same plan which may not be the most optimal way to pack a set of instructions.

To illustrate this, let’s look at the write instruction of the Loader V3 program. This instruction is used to gradually write bytes into a buffer such that this buffer can then be used to deploy a new Solana program or update an existing one.

Now, imagine we want to create a helper function that returns an InstructionPlan for the operation “Deploy a new program”. Let’s call it getDeployProgramInstructionPlan(input). This will include instructions such as: create buffer, write, deploy (using that buffer).

Since the user will provide the program binary to deploy — in the input argument — we know exactly how many bytes we must gradually write to the buffer. Since this will likely span multiple transactions, we must provide as many write instructions as necessary to fill the entire buffer. We could “guess” the right amount of bytes per message and simply split these instructions using this heuristic. For instance, we could say 900 bytes per transaction is a safe bet. However, this doesn’t take into account that these write instructions:

  • could be used to fill existing transaction messages with fewer space (e.g. you have 400 bytes free in a previous message being packed).
  • may have less space than 900 bytes to work with (e.g. the base transaction message uses lots of meta instructions such as setting compute budget units, prices, etc.).
  • may have more than 900 bytes of space to use per transaction which significantly increases the total amount of transactions to land for large binaries.

So instead of providing a static array of write instructions guessing how much space each of them will be able to use, we introduce a dynamic InstructionPlan that can adapt to the current transaction message being packed.

Let’s see how this works with a concrete example using the current architecture.

First of all, imagine we are in the middle of planning a TransactionPlan from a given InstructionPlan and the current state of the TransactionPlan being built looks like this:

Instruction Plans@2x1

That is, we are inside a ParallelTransactionPlan that currently contains two transaction messages. One with 400 bytes left and one with very little space left (10 bytes is likely not going to be enough to add one more instruction since the program address already takes 32 bytes of space unless there is already an instruction from that program in there). To simplify this example a bit, let’s just say that each instruction requires a base of 50 bytes to be able to be added to a message even without any instruction data.

Now say that the next InstructionPlan we have to pack into this TransactionPlan is an InterableInstructionPlan for the write instruction we discussed before.

For this example, we’ll assume there is a total of 1000 bytes we need to write to our buffer account. Looking at the current state of our transaction messages, we can see that the ideal scenario would be:

  • To use up the 400 bytes available in Message A.
  • To skip Message B since 10 bytes isn’t enough to add a write instruction to this message.
  • To spin up a new Message C to use up the rest of the bytes.

This is exactly how the base TransactionPlanner works (PR in progress but you can see its prototype here).

First, we call getIterator() so we can start a stateful object keeping track of how many bytes have been successfully packed into transaction messages. Then we call next(message) until hasMessage() is false. Here’s a diagram showcasing how this algorithm works:

Instruction Plans@2x2

As you can see, before trying a new message to put our write instructions to, we first evaluate candidates to consider in order to fill up the spaces of valid transaction messages. We evaluate Message A and B because we are inside a ParallelInstructionPlan meaning the order of these instructions does not matter.

  • Message A has 400 bytes left so — including the base 50 bytes — it means we can use 350 bytes of the total 1000 bytes we need to write to the buffer account. So only 650 bytes left.
  • Message B has 10 bytes left. That’s not enough to add an instruction so the iterator returns null (or could throw an error instead) to let us know that we must try another candidate.
  • Since we have no other candidates to evaluate, we create a new one — using a factory function provided by the user. Let’s assume that new messages have 1200 bytes available after they have been set up however the user wants them to be. Therefore, our new candidate, Message C has 1200 bytes left which is enough to fit the rest of our iterator. We include the 650 bytes left in an instruction making a total of 700 bytes (with the base 50 bytes).

We are left with the following TransactionPlan which matches our previous expectations exactly.

Instruction Plans@2x3

Now, regarding the getAll() method of the IterableInstructionPlan. This one could probably be removed but it allows some SequentialInstructionPlans to be flatten into a parent candidate when applicable. This is only useful for non-divisible sequential plans inside a parent or for sequential plans inside parallel plans. For write instructions, the getAll() function returns a single instruction using all the bytes it must write — even if that was to overflow the TRANSACTION_SIZE_LIMIT. The planner then gathers all the instructions within that branch and decides whether or not it can be flattened into the parent candidate.

Instruction Plans@2x4

As mentioned though, I think there is a way to remove that getAll() method and still make the planner work as expected. I will try to remove it in my upcoming PR that offers an implementation for the TransactionPlanner function. If I am able to, then I will update this PR accordingly.

Now, to go back to your message. First of all, your assumptions were all correct. However, the example you used to reason with IterableInstructionPlans doesn’t truly reflect why it is useful because it simply wraps an array of static instructions. This PR does offer a helper function that does this (especially for realloc instructions) but this was more an exercise to ensure the iterator is flexible enough to only be an array wrapper.

The proposed solution essentially makes the following changes:

  • The getAll() method is replaced by a native Iterator such that you can use Array.from on it. However, this is not the most valuable part of the IterableInstructionPlan and — as mentioned — could potentially even be removed from the API.
  • The getIterator() method is replaced by a packThatMessage function that must catch an error every time a candidate has been exhausted and return another object with a nested packThatMessage to continue the journey. This API feels more complex to me that this homemade getIterator() API that simply asks you to call next(message) until hasMessage() is false.

There is likely a better design out there but I’m not sure that the packThatMessage design really is an improvement here. Do let me know if the additional context sparks new design ideas though. 🙏

Copy link
Collaborator

steveluscher commented Jun 18, 2025

Thanks for the detailed explainer! It helps a ton.

I think these are my lingering concerns:

  1. I don't love null as a sigil for ‘this didn't fit.’ The reason is that an application can ignore a null, whereas it can't as easily ignore a thrown SolanaError. Perhaps, as you suggested, changing it to throw a SolanaError would do.
  2. I think the ‘iterator’ language is throwing me off. It's not, really, an iterator because iterators' next() functions don't take arguments. I think this API is closer to WritableStream::getWriter() or something, without being mutative. If I were to name this function based on what it's literally doing it would be something like getNextInstructionThatFitsInTransactionMessage(message).
  3. The only way I've seen this API used so far is to add the ‘next’ instruction to the exact same message that you supplied to next(). The gap between getting the instruction and adding it to the instruction makes it possible for the developer to (a) forget to add it, or (b) add it to the wrong message. If the API instead returned a new message with the instruction added (eg. .packNextInstruction(message)) this could not happen.
  4. Right now, if a bunch of instructions fit in one message, the only way to make that so is to call next() a bunch of times and mutate it a bunch of times. If there were separate APIs for packNextInstruction(message) that packed the next instruction, and .packNextInstructions(message) that packed as many as would fit, maybe we could avoid this.
  5. Right now, there's no way to distinguish ‘this didn't fit in the message you gave me’ vs. ‘there's no way in hell this will ever fit.’ We can end up in infinite loops if we create an API that encourages developers to keep trying and trying.

All of my suggestions above make the API more restrictive and less flexible, but with maybe fewer ways to screw it up and a little more efficiency. If we adopted those changes, can you think of any use cases that would prohibit?

I think your example here would become:

while (!messagePacker.done()) {
  for (const candidate in candidates) {
    try {
      (candidate as Mutable<SingleTransactionPlan>).message =
        messagePacker.packNextInstructions(candidate.message);
    } catch (e) {
      if (
        isSolanaError(e, SOLANA_ERROR__MESSAGE_PACKER__NO_ROOM_LEFT_AT_THE_INN)
      ) {
        const newPlan = await context.createSingleTransactionPlan(
          [],
          context.abortSignal
        );
        transactionPlans.push(newPlan);
        candidates.push(newPlan);
        continue;
      }
      throw e;
    }
  }
}

@lorisleiva
Copy link
Member Author

Ooh I like that. Let me tackle your points one by one.

  1. SolanaError over null. Yes, you’re absolutely right that throwing an error here is a better design.
  2. Confusing name. I agree the “Iterator” language does not work here. I’ll come back to this after answering the other points as they affect the naming.
  3. Message package over Instruction getter. This makes a lot of sense since we’re only ever getting an instruction to immediately pack it. This also gives more power to this dynamic instruction plan since they can decide to transform the provided message however they see fit — e.g. adding multiple instructions, enforcing a lifetime constraint, providing extra context, etc.
  4. Packing one versus multiple instructions. Here I don’t think we need to have two API such as packNextInstruction and packNextInstructions. A simple transform function (naming TBD) that accepts a message and return a new one is enough to allow the dynamic instruction plan to be the one deciding if one or more instructions should be added to the provided candidate.
  5. 🤔 Invalid candidate errors. Putting aside the getAll() function that we’re aiming to remove, there should technically never be a “there's no way in hell this will ever fit” error because that would mean the dynamic instruction plan itself is faulty and trying to fit instructions that are above the transaction size limit. That being said, if the user’s default message (prior to adding instructions from plans) never has enough space for any new instruction, then it is the responsibility of the planner to catch this. Currently, it does this by trying all candidate, then a brand new one and if that one fails, it throws. By design, the dynamic instruction plan (Program land) is decoupled from that default message (Client land) and therefore only the planner can handle this.

I think the provided example is perfect and I’ll work on updating the prototype first to see if there are any gotchas we’re not seeing before updating this stack.

Going back to the naming convention (2), I think MessagePacker { done(), packNextInstructions() } works but maybe it is not generic enough to describe how powerful the dynamic instruction plan is — since it can technically do whatever it wants with that message.

What about something like:

type MessageAssemblerInstructionPlan = {
  getMessageAssembler: () => MessageAssembler;
}

type MessageAssembler = {
  done: () => boolean;
  assembleNext(message: TransactionMessage) => TransactionMessage
}

Copy link
Collaborator

‘Assemble’ sort of gives the impression that something is being made from scratch, so I'm not sure.

  • MessagePacker/packMessageToCapacity()
  • MessageFiller/fillMessage()

‘Pack’ maybe has precedent 'round these parts, as in ‘pack a block,’ so maybe people understand what ‘packing’ is in this context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants