Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,5 +185,18 @@ export type {
export { StellarWalletsKitAdapter } from "./wallet-adapter";
export type { StellarWalletsKitAdapterConfig } from "./wallet-adapter";

// Invocation tree utilities — for inspecting auth_context structure and
// determining contextRuleIds before signing transactions with sub-invocations.
export {
countAuthContexts,
walkInvocationTree,
validateContextRuleIds,
hintContextRuleIds,
resolveContextRuleIds,
type InvocationNode,
type ContextRuleMatch,
type InvocationContextHint,
} from "./kit/invocation-utils";

// Re-export stellar-sdk types for convenience
export type { AssembledTransaction } from "@stellar/stellar-sdk/contract";
90 changes: 90 additions & 0 deletions src/kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,13 @@ import {
import { convertPolicyParams, buildPoliciesScVal } from "./kit/policies-ops";
import {
findWebAuthnSignerForCredential,
listContextRules,
resolveContextRuleIdsForEntry,
} from "./kit/context-rules";
import {
hintContextRuleIds as hintContextRuleIdsFromTree,
type InvocationContextHint,
} from "./kit/invocation-utils";
import { validateAddress, validateAmount, xlmToStroops } from "./utils";


Expand Down Expand Up @@ -1213,6 +1218,91 @@ export class SmartAccountKit {
return this.signAndSubmit(transaction, options);
}

// ==========================================================================
// Invocation Tree Utilities
// ==========================================================================

/**
* Get per-node rule suggestions for a multi-contract invocation tree.
*
* Walks the invocation tree depth-first, fetches all on-chain context rules,
* and matches each node against the rules by specificity:
* 1. `CallContract(address)` — most specific
* 2. `CreateContract(wasmHash)` — intermediate
* 3. `Default` — catch-all
*
* Use this to inspect which rules apply before signing, or to let users
* choose when multiple rules match a single node.
*
* @param authEntry - The authorization entry to inspect
* @param options - Optional settings
* @returns Array of hints, one per auth_context node in the tree
*
* @example
* ```typescript
* const hints = await kit.hintContextRuleIds(authEntry);
* // hints[0].suggestedRuleId = 1 (CallContract rule for deposit contract)
* // hints[1].suggestedRuleId = 0 (Default rule for transfer)
*
* const ruleIds = hints.map(h => h.suggestedRuleId);
* await kit.signAndSubmit(tx, { resolveContextRuleIds: () => ruleIds });
* ```
*/
async hintContextRuleIds(
authEntry: xdr.SorobanAuthorizationEntry,
options?: {
/** Rule ID used when no rule matches a node (default 0) */
defaultRuleId?: number;
}
): Promise<InvocationContextHint[]> {
const { wallet, contractId } = this.requireWallet();
const discoveryDeps = {
getContractDetailsFromIndexer: () => this.getContractDetailsFromIndexer(contractId),
probeRuleIds: this.probeRuleIds,
rpc: this.rpc,
contractId,
networkPassphrase: this.networkPassphrase,
timeoutInSeconds: this.timeoutInSeconds,
};
const rules = await listContextRules(wallet, discoveryDeps);

return hintContextRuleIdsFromTree(
authEntry.rootInvocation(),
rules,
options?.defaultRuleId
);
}

/**
* Auto-resolve context rule IDs for every auth_context in an invocation tree.
*
* This is the non-interactive version of {@link hintContextRuleIds} — it
* returns only the suggested IDs. Use `hintContextRuleIds` when you need to
* inspect or override individual suggestions.
*
* @param authEntry - The authorization entry to resolve
* @param options - Optional settings
* @returns Array of rule IDs, one per auth_context, ready to use as `contextRuleIds`
*
* @example
* ```typescript
* const ruleIds = await kit.resolveContextRuleIds(authEntry);
* // -> [1, 0] (deposit contract: rule 1, transfer: default rule 0)
*
* await kit.signAndSubmit(tx, { resolveContextRuleIds: () => ruleIds });
* ```
*/
async resolveContextRuleIds(
authEntry: xdr.SorobanAuthorizationEntry,
options?: {
/** Rule ID used when no rule matches a node (default 0) */
defaultRuleId?: number;
}
): Promise<number[]> {
const hints = await this.hintContextRuleIds(authEntry, options);
return hints.map((h) => h.suggestedRuleId);
}

// ==========================================================================
// Private Helpers
// ==========================================================================
Expand Down
92 changes: 10 additions & 82 deletions src/kit/context-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "../signer-utils";
import type { ContractDetailsResponse } from "../indexer";
import { BASE_FEE } from "../constants";
import { walkInvocationTree } from "./invocation-utils";

type ContextRuleQueryClient = {
get_context_rule: (args: { context_rule_id: number }) => Promise<AssembledTransaction<ContextRule>>;
Expand Down Expand Up @@ -78,37 +79,17 @@ export function contextRuleTypeMatches(
export function buildInvocationContextTypes(
entry: xdr.SorobanAuthorizationEntry
): ContextRuleType[] {
const contexts: ContextRuleType[] = [];

const walk = (invocation: xdr.SorobanAuthorizedInvocation) => {
const fn = invocation.function();
const switchName = fn.switch().name;

if (switchName === "sorobanAuthorizedFunctionTypeContractFn") {
const args = fn.contractFn();
contexts.push({
tag: "CallContract",
values: [Address.fromScAddress(args.contractAddress()).toString()],
});
} else if (switchName.startsWith("sorobanAuthorizedFunctionTypeCreateContract")) {
const wasmHash = extractCreateContractWasmHash(fn);
if (!wasmHash) {
throw new Error("Unable to extract WASM hash from create-contract authorization entry");
}

contexts.push({
tag: "CreateContract",
values: [wasmHash],
});
return walkInvocationTree(entry.rootInvocation()).map((node) => {
if (node.contractAddress) {
return { tag: "CallContract", values: [node.contractAddress] } as ContextRuleType;
}

for (const sub of invocation.subInvocations()) {
walk(sub);
if (node.wasmHash) {
return { tag: "CreateContract", values: [node.wasmHash] } as ContextRuleType;
}
};

walk(entry.rootInvocation());
return contexts;
throw new Error(
"Unable to determine context type for invocation node"
);
});
}

function hasRpcReadConfig(
Expand Down Expand Up @@ -558,59 +539,6 @@ export async function resolveContextRuleIdsForEntry(
});
}

function extractCreateContractWasmHash(
fn: xdr.SorobanAuthorizedFunction
): Buffer | null {
const candidates: Array<unknown> = [];
const fnAny = fn as unknown as {
createContractHostFn?: () => unknown;
createContractWithCtorHostFn?: () => unknown;
createContractWithConstructorHostFn?: () => unknown;
};

if (typeof fnAny.createContractHostFn === "function") {
candidates.push(fnAny.createContractHostFn());
}
if (typeof fnAny.createContractWithCtorHostFn === "function") {
candidates.push(fnAny.createContractWithCtorHostFn());
}
if (typeof fnAny.createContractWithConstructorHostFn === "function") {
candidates.push(fnAny.createContractWithConstructorHostFn());
}

for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") {
continue;
}

const ctx = candidate as { executable?: unknown };
const executable = typeof ctx.executable === "function"
? (ctx.executable as () => unknown)()
: ctx.executable;

if (!executable || typeof executable !== "object") {
continue;
}

const execAny = executable as {
switch?: () => { name: string };
wasm?: (() => Buffer) | Buffer;
};
const execSwitch = execAny.switch?.();

if (execSwitch?.name !== "contractExecutableWasm") {
continue;
}

const wasm = typeof execAny.wasm === "function" ? execAny.wasm() : execAny.wasm;
if (wasm) {
return Buffer.from(wasm);
}
}

return null;
}

function isMissingContextRuleError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
Expand Down
Loading