Skip to content

feat: add invocation tree utilities for context rule ID resolution#9

Open
brozorec wants to merge 3 commits into
kalepail:mainfrom
brozorec:feat/invocation-tree-utils
Open

feat: add invocation tree utilities for context rule ID resolution#9
brozorec wants to merge 3 commits into
kalepail:mainfrom
brozorec:feat/invocation-tree-utils

Conversation

@brozorec

Copy link
Copy Markdown

Summary

  • Add src/kit/invocation-utils.ts — pure utility functions for inspecting Soroban invocation trees and resolving context rule IDs
  • Add kit.hintContextRuleIds() and kit.resolveContextRuleIds() convenience methods that auto-fetch rules and return per-node suggestions
  • Consolidate tree walking: buildInvocationContextTypes in context-rules.ts now delegates to walkInvocationTree, eliminating a duplicate depth-first traversal
  • Fix extractCreateContractWasmHash to also check the wasmHash accessor (current SDK uses wasmHash(), not wasm())

Motivation

When a smart account executes a multi-contract operation (e.g. a DeFi deposit that internally calls transfer), AuthPayload.context_rule_ids must have one entry per node in the invocation tree. Building this array manually requires understanding the depth-first traversal order and matching each node to the correct rule.

These utilities automate that:

  const simResult = await rpc.simulateTransaction(tx);
  const authEntry = simResult.result!.auth[0];

  // Option A: inspect per-node suggestions (useful when multiple rules match)
  const hints = await kit.hintContextRuleIds(authEntry);
  // hints[0] = { suggestedRuleId: 1, contractAddress: "CB7Z...", functionName: "deposit",
  //              matchingRules: [{ ruleId: 1, contextType: "CallContract" }, { ruleId: 0, contextType: "Default" }] }
  // hints[1] = { suggestedRuleId: 0, contractAddress: "CDSL...", functionName: "transfer",
  //              matchingRules: [{ ruleId: 0, contextType: "Default" }] }

  // Option B: just get the IDs
  const ruleIds = await kit.resolveContextRuleIds(authEntry);
  // -> [1, 0]

  await kit.signAndSubmit(tx, { resolveContextRuleIds: () => ruleIds });

brozorec and others added 3 commits April 19, 2026 18:24
Add pure utility functions for inspecting Soroban invocation trees and
resolving context_rule_ids for multi-contract operations (e.g. deposit
that internally calls transfer). Includes kit.hintContextRuleIds() and
kit.resolveContextRuleIds() convenience methods, comprehensive tests,
and public exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@devin-ai-integration

Copy link
Copy Markdown

Nice work on this — the problem it solves (manually building contextRuleIds for multi-contract invocations) is a real developer pain point, and the API design handles it well. A few notes below, but overall this is in good shape.

What I like:

  • The two-tier API is well thought out: hintContextRuleIds for inspectable results with full match reasoning, and resolveContextRuleIds as the simple shortcut. Gives developers the right level of control depending on their use case.
  • The test coverage is thorough — 392 lines covering single/nested/mixed trees, specificity ordering, fallback behavior, and error message formatting. Good stuff.
  • validateContextRuleIds producing developer-friendly errors with a tree dump and suggested fix is excellent DX.
  • Refactoring buildInvocationContextTypes to delegate to the shared walkInvocationTree eliminates a duplicated DFS traversal. Clean consolidation.
  • The extractCreateContractWasmHash fix to also check the wasmHash accessor (not just wasm()) is a legit fix for current SDK versions.

Things to address:

  1. CreateContract matching doesn't compare WASM hashes — this is the main one. See inline comment for details.
  2. countAuthContexts efficiency — minor, but worth considering since it's a public utility. See inline comment.

Looking forward to seeing this land after the WASM hash fix.

}
} else if (ct.tag === "CreateContract") {
if (!node.contractAddress) {
matchingRules.push({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CreateContract matching — WASM hash comparison is missing

In hintContextRuleIds, the CallContract branch correctly compares the rule's address against the node's address:

if (node.contractAddress && ct.values[0] === node.contractAddress) {

But the CreateContract branch only checks !node.contractAddress — it never compares the rule's WASM hash (ct.values[0]) against the node's wasmHash:

} else if (ct.tag === "CreateContract") {
  if (!node.contractAddress) {   // ← no hash comparison
    matchingRules.push({ ... });
  }
}

This means any CreateContract rule will match any create-contract invocation, regardless of which WASM is being deployed. If a smart account has multiple CreateContract rules scoped to different contracts, the hint will suggest the first one found rather than the correct one.

Something like this would fix it:

} else if (ct.tag === "CreateContract") {
  if (node.wasmHash && ct.values[0] && Buffer.from(ct.values[0]).equals(node.wasmHash)) {
    matchingRules.push({
      ruleId: rule.id,
      ruleName: rule.name,
      contextType: "CreateContract",
      reason: "CreateContract rule (matching WASM hash)",
    });
  } else if (!node.contractAddress && !node.wasmHash) {
    // Non-contract invocation without a hash — still a candidate
    matchingRules.push({ ... });
  }
}

The exact comparison depends on how ct.values[0] is typed (Buffer vs string), but the key point is it should be checked against node.wasmHash. You'd want a corresponding test case for a scenario with two different CreateContract rules and two different WASM hashes to verify the right one is selected.

invocation: xdr.SorobanAuthorizedInvocation
): number {
return walkInvocationTree(invocation).length;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: countAuthContexts builds the entire InvocationNode[] array (with string allocations for addresses, function names, etc.) just to return .length. Since this is exported as a public utility, callers might use it in hot paths where they only care about the count.

A lightweight recursive counter would avoid those allocations:

export function countAuthContexts(
  invocation: xdr.SorobanAuthorizedInvocation
): number {
  let count = 1;
  for (const sub of invocation.subInvocations()) {
    count += countAuthContexts(sub);
  }
  return count;
}

Not a blocker — just a nice-to-have if you're touching this anyway.

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.

1 participant