@crediolabs/policy-synth 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@
19
19
  import { createHash } from 'node:crypto';
20
20
  import { Address, BASE_FEE, Contract, Keypair, Operation, rpc, scValToBigInt, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
21
21
  import { buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION } from "./build-add-context-rule.js";
22
+ import { accountEntry, authDigest, authPayload, delegatedSignerEntry, signaturePayload, } from "./oz-auth.js";
22
23
  /** Convert a real rpc.Server into the InstallRpcClient surface. The
23
24
  * `getContractVersion` lookup uses `simulateTransaction` against
24
25
  * `contract.call('grammar_version')` and decodes the returned u32.
@@ -83,31 +84,28 @@ export async function buildInstallPolicyXdr(args) {
83
84
  ...(args.oracleParams ? { oracleParams: args.oracleParams } : {}),
84
85
  ...(args.grammarVersion !== undefined ? { grammarVersion: args.grammarVersion } : {}),
85
86
  });
86
- // 2. Network pass: source account sequence + latest ledger.
87
+ // 2. Fetch the source sequence, then build the recording transaction with a
88
+ // bare host call. The recording pass assigns the smart-account auth nonce
89
+ // and root invocation needed to construct OZ's AuthPayload.
87
90
  const source = await args.rpc.getAccount(args.sourceAccount);
88
- const latest = await args.rpc.getLatestLedger();
89
- const validUntilLedger = latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS);
90
- // 3. Build the unsigned transaction envelope with the bare host call.
91
- const op = Operation.invokeHostFunction({
92
- func: xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
93
- contractAddress: new Address(args.smartAccount).toScAddress(),
94
- functionName: 'add_context_rule',
95
- args: [...callArgs],
96
- })),
97
- auth: [],
98
- });
91
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
92
+ contractAddress: new Address(args.smartAccount).toScAddress(),
93
+ functionName: 'add_context_rule',
94
+ args: [...callArgs],
95
+ }));
96
+ const makeOperation = (auth = []) => Operation.invokeHostFunction({ func: hostFunction, auth });
99
97
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
100
- const tx = buildUnsignedTx({
98
+ const buildTx = (op) => buildUnsignedTx({
101
99
  sourceAccount: args.sourceAccount,
102
100
  sequence: source.sequenceNumber(),
103
101
  fee: baseFee,
104
102
  networkPassphrase: args.networkPassphrase,
105
103
  op,
106
104
  });
107
- // 4. Simulation pass. Returns the auth nonce + rootInvocation the wallet
108
- // signature binds to. We do NOT add the AuthPayload here - the wallet
109
- // injects auth entries using its own __check_auth flow.
110
- const recorded = await args.rpc.simulateTransaction(tx);
105
+ const recordingTx = buildTx(makeOperation());
106
+ // 3. Recording simulation: capture the smart account's nonce and invocation
107
+ // tree. Nothing is signed here.
108
+ const recorded = await args.rpc.simulateTransaction(recordingTx);
111
109
  if (rpc.Api.isSimulationError(recorded)) {
112
110
  // Short, stable reason. The full `simulateTransaction` error (which
113
111
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -121,14 +119,32 @@ export async function buildInstallPolicyXdr(args) {
121
119
  if (!original) {
122
120
  throw new Error(`install_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
123
121
  }
124
- // 5. Decode the structured description FROM the unsigned XDR. The human
125
- // approval binds to the bytes the wallet will sign; deriving this from
126
- // the input args would re-describe the caller's intent rather than the
127
- // transaction. Re-parse the envelope we just built so the description
128
- // reflects what the wallet actually signs, not what we thought it would.
129
- const describes = decodeInstallCallDescribes(tx, op, args.installNonce);
122
+ // 4. `add_context_rule` is authorised by the deploy-time admin rule (rule 0).
123
+ // The delegated signer uses source-account credentials, so its signature
124
+ // bytes stay empty and the ordinary transaction-envelope signature covers
125
+ // the call when the consumer signs it.
126
+ const validUntilLedger = (await args.rpc.getLatestLedger()).sequence +
127
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS);
128
+ const contextRuleIds = [0];
129
+ const digest = authDigest(signaturePayload(args.networkPassphrase, original.credentials().address().nonce(), validUntilLedger, original.rootInvocation()), contextRuleIds);
130
+ const authEntries = [
131
+ accountEntry(original, validUntilLedger, authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))),
132
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
133
+ ];
134
+ // 5. Simulate again with the OZ entries already attached. The SDK preserves
135
+ // existing auth during assembly and adds the simulated Soroban footprint +
136
+ // resource fee, yielding a complete unsigned envelope.
137
+ const txWithAuth = buildTx(makeOperation(authEntries));
138
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth);
139
+ if (rpc.Api.isSimulationError(enforcing)) {
140
+ throw new Error('install_policy: auth simulateTransaction failed');
141
+ }
142
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build();
143
+ // 6. Decode the structured description FROM the final assembled transaction.
144
+ // The human approval binds to the exact bytes the wallet will sign.
145
+ const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
130
146
  return {
131
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
147
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
132
148
  smartAccount: args.smartAccount,
133
149
  sourceAccount: args.sourceAccount,
134
150
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -157,25 +173,21 @@ export async function buildInstallPolicyXdr(args) {
157
173
  * be guessing at a contract it cannot read, and the chain is the authority. */
158
174
  export async function buildRevokePolicyXdr(args) {
159
175
  const source = await args.rpc.getAccount(args.sourceAccount);
160
- const latest = await args.rpc.getLatestLedger();
161
- const validUntilLedger = latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS);
162
- const op = Operation.invokeHostFunction({
163
- func: xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
164
- contractAddress: new Address(args.smartAccount).toScAddress(),
165
- functionName: 'remove_context_rule',
166
- args: [xdr.ScVal.scvU32(args.ruleId)],
167
- })),
168
- auth: [],
169
- });
176
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
177
+ contractAddress: new Address(args.smartAccount).toScAddress(),
178
+ functionName: 'remove_context_rule',
179
+ args: [xdr.ScVal.scvU32(args.ruleId)],
180
+ }));
181
+ const makeOperation = (auth = []) => Operation.invokeHostFunction({ func: hostFunction, auth });
170
182
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
171
- const tx = buildUnsignedTx({
183
+ const buildTx = (op) => buildUnsignedTx({
172
184
  sourceAccount: args.sourceAccount,
173
185
  sequence: source.sequenceNumber(),
174
186
  fee: baseFee,
175
187
  networkPassphrase: args.networkPassphrase,
176
188
  op,
177
189
  });
178
- const recorded = await args.rpc.simulateTransaction(tx);
190
+ const recorded = await args.rpc.simulateTransaction(buildTx(makeOperation()));
179
191
  if (rpc.Api.isSimulationError(recorded)) {
180
192
  // Short, stable reason. The full `simulateTransaction` error (which
181
193
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -189,8 +201,27 @@ export async function buildRevokePolicyXdr(args) {
189
201
  if (!original) {
190
202
  throw new Error(`revoke_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
191
203
  }
204
+ // Removal is master-only, so the deploy-time admin rule authorises it. The
205
+ // recorded rootInvocation still includes remove_context_rule(ruleId), binding
206
+ // the auth payload to the exact rule being removed. As with install,
207
+ // source-account credentials carry the delegated signer entry and the
208
+ // consumer supplies only the ordinary envelope signature.
209
+ const validUntilLedger = (await args.rpc.getLatestLedger()).sequence +
210
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS);
211
+ const contextRuleIds = [0];
212
+ const digest = authDigest(signaturePayload(args.networkPassphrase, original.credentials().address().nonce(), validUntilLedger, original.rootInvocation()), contextRuleIds);
213
+ const authEntries = [
214
+ accountEntry(original, validUntilLedger, authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))),
215
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
216
+ ];
217
+ const txWithAuth = buildTx(makeOperation(authEntries));
218
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth);
219
+ if (rpc.Api.isSimulationError(enforcing)) {
220
+ throw new Error('revoke_policy: auth simulateTransaction failed');
221
+ }
222
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build();
192
223
  return {
193
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
224
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
194
225
  smartAccount: args.smartAccount,
195
226
  sourceAccount: args.sourceAccount,
196
227
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -200,7 +231,7 @@ export async function buildRevokePolicyXdr(args) {
200
231
  };
201
232
  }
202
233
  /** ~25 minutes at 5s/ledger. */
203
- const DEFAULT_AUTH_VALID_LEDGERS = 300;
234
+ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
204
235
  // ---- internals ----
205
236
  /** Adapter: turn the install-policy wire `PolicyRef` shape into the core
206
237
  * `PolicyRef` shape `buildAddContextRuleArgs` expects. Keeps the wire
@@ -238,20 +269,24 @@ function buildUnsignedTx(args) {
238
269
  // its signature, and broadcasts. We do NOT sign here.
239
270
  return tx;
240
271
  }
241
- /** Decode the install call's structured fields directly out of the
242
- * built (transaction, operation) pair. The bytes are the source of
243
- * truth: a human reviewing the install wants to see what the wallet
244
- * will actually sign, not what we think it will. The decode is
245
- * deliberately strict - any shape we did not build ourselves throws
246
- * a ToolError, since that would mean the XDR was tampered with (or
272
+ /** Decode the install call's structured fields directly out of the final
273
+ * assembled transaction. The bytes are the source of truth: a human reviewing
274
+ * the install wants to see what the wallet will actually sign, not what we
275
+ * think it will. The decode is deliberately strict - any shape we did not
276
+ * build ourselves throws, since that would mean the XDR was tampered with (or
247
277
  * the encoder changed and this descriptor lags).
248
278
  *
249
279
  * `expectedInstallNonce` is a fallback for the rare case where the
250
280
  * install has no interpreter policy (no `install_nonce` to read); the
251
281
  * caller already knows it. */
252
- function decodeInstallCallDescribes(tx, op, expectedInstallNonce) {
282
+ function decodeInstallCallDescribes(tx, expectedInstallNonce) {
283
+ const operations = tx.toEnvelope().v1().tx().operations();
284
+ if (operations.length !== 1 || !operations[0]) {
285
+ throw new Error(`install_policy: final transaction has ${operations.length} operations, expected 1`);
286
+ }
287
+ const op = operations[0];
253
288
  const hostFn = op.body().invokeHostFunctionOp()?.hostFunction();
254
- if (!hostFn || hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
289
+ if (hostFn?.switch().name !== 'hostFunctionTypeInvokeContract') {
255
290
  throw new Error('install_policy: built op is not an invokeHostFunction(invokeContract(...)) call');
256
291
  }
257
292
  const invokeArgs = hostFn.invokeContract();
@@ -404,10 +439,6 @@ function decodeInstallCallDescribes(tx, op, expectedInstallNonce) {
404
439
  // `observedInstallNonce` is the nonce baked into whichever interpreter
405
440
  // policy is present; when none, fall back to the caller-supplied value.
406
441
  const installNonce = observedInstallNonce ?? expectedInstallNonce;
407
- // Touch `tx` so the linter does not flag it as unused - the parameter
408
- // documents that the caller built this Transaction; we re-parse the
409
- // operation off it as a sanity check.
410
- void tx;
411
442
  return {
412
443
  targetContract,
413
444
  fnName: 'add_context_rule',
@@ -0,0 +1,27 @@
1
+ import { xdr } from '@stellar/stellar-sdk';
2
+ /** `Signer::Delegated(addr)`. */
3
+ export declare const delegatedSigner: (a: string) => xdr.ScVal;
4
+ /** The standard Soroban authorization preimage hash for one entry. */
5
+ export declare function signaturePayload(networkPassphrase: string, nonce: xdr.Int64, signatureExpirationLedger: number, invocation: xdr.SorobanAuthorizedInvocation): Buffer;
6
+ /**
7
+ * `sha256(signature_payload || xdr(context_rule_ids))`.
8
+ *
9
+ * OZ binds the selected rule ids into the digest so a signature for one rule
10
+ * cannot be replayed against another.
11
+ */
12
+ export declare function authDigest(payload: Buffer, contextRuleIds: number[]): Buffer;
13
+ /** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
14
+ export declare function authPayload(signerAddresses: string[], contextRuleIds: number[], signatureFor: (addr: string) => Buffer): xdr.ScVal;
15
+ /**
16
+ * The nested entry a `Delegated` signer needs.
17
+ *
18
+ * `require_auth_for_args` authorizes the CURRENT frame, which while
19
+ * `__check_auth` is running is the account contract executing `__check_auth`,
20
+ * with the digest as its single argument.
21
+ *
22
+ * The signer is the transaction source here, so source-account credentials
23
+ * carry it and no separate signature is required.
24
+ */
25
+ export declare function delegatedSignerEntry(accountId: string, digest: Buffer): xdr.SorobanAuthorizationEntry;
26
+ /** Rebuild the account's entry with the AuthPayload in the signature slot. */
27
+ export declare function accountEntry(original: xdr.SorobanAuthorizationEntry, signatureExpirationLedger: number, payload: xdr.ScVal): xdr.SorobanAuthorizationEntry;
@@ -0,0 +1,105 @@
1
+ // Build the authorization entries OpenZeppelin's smart account requires.
2
+ //
3
+ // This is the piece that makes an end-to-end test of the account -> policy
4
+ // path possible. Without it, every attempt to exercise `__check_auth`
5
+ // produces a false positive: mocked auth skips `__check_auth` entirely,
6
+ // direct invocation is rejected by the host, and recording-mode simulation
7
+ // does not verify auth at all.
8
+ //
9
+ // Two entries are needed per call:
10
+ //
11
+ // 1. The ACCOUNT's entry. Its `signature` slot is not a signature - OZ puts
12
+ // an `AuthPayload { signers, context_rule_ids }` there, which
13
+ // `__check_auth` receives as its `signatures` argument.
14
+ //
15
+ // 2. One entry per DELEGATED signer. `do_check_auth` calls
16
+ // `addr.require_auth_for_args((auth_digest,))` for each, which is a
17
+ // nested authorization requirement the host will not record during
18
+ // simulation (it never runs `__check_auth` in recording mode), so it has
19
+ // to be constructed by hand.
20
+ //
21
+ // The digest OZ binds is NOT the raw auth payload hash:
22
+ //
23
+ // auth_digest = sha256(signature_payload || xdr(context_rule_ids))
24
+ //
25
+ // where `signature_payload` is the standard Soroban authorization preimage
26
+ // hash. See `do_check_auth` in
27
+ // stellar-contracts/packages/accounts/src/smart_account/storage.rs.
28
+ import { Address, hash, xdr } from '@stellar/stellar-sdk';
29
+ const sym = (s) => xdr.ScVal.scvSymbol(s);
30
+ const u32 = (n) => xdr.ScVal.scvU32(n);
31
+ const vec = (i) => xdr.ScVal.scvVec(i);
32
+ const bytes = (b) => xdr.ScVal.scvBytes(b);
33
+ function struct(fields) {
34
+ return xdr.ScVal.scvMap(Object.keys(fields)
35
+ .sort()
36
+ .map((k) => new xdr.ScMapEntry({ key: sym(k), val: fields[k] })));
37
+ }
38
+ /** `Signer::Delegated(addr)`. */
39
+ export const delegatedSigner = (a) => vec([sym('Delegated'), new Address(a).toScVal()]);
40
+ /** The standard Soroban authorization preimage hash for one entry. */
41
+ export function signaturePayload(networkPassphrase, nonce, signatureExpirationLedger, invocation) {
42
+ const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(new xdr.HashIdPreimageSorobanAuthorization({
43
+ networkId: hash(Buffer.from(networkPassphrase)),
44
+ nonce,
45
+ signatureExpirationLedger,
46
+ invocation,
47
+ }));
48
+ return hash(preimage.toXDR());
49
+ }
50
+ /**
51
+ * `sha256(signature_payload || xdr(context_rule_ids))`.
52
+ *
53
+ * OZ binds the selected rule ids into the digest so a signature for one rule
54
+ * cannot be replayed against another.
55
+ */
56
+ export function authDigest(payload, contextRuleIds) {
57
+ const idsXdr = vec(contextRuleIds.map(u32)).toXDR();
58
+ return hash(Buffer.concat([payload, idsXdr]));
59
+ }
60
+ /** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
61
+ export function authPayload(signerAddresses, contextRuleIds, signatureFor) {
62
+ return struct({
63
+ signers: xdr.ScVal.scvMap(signerAddresses
64
+ .map((a) => new xdr.ScMapEntry({ key: delegatedSigner(a), val: bytes(signatureFor(a)) }))
65
+ // Host maps must be sorted by key.
66
+ .sort((x, y) => Buffer.compare(x.key().toXDR(), y.key().toXDR()))),
67
+ context_rule_ids: vec(contextRuleIds.map(u32)),
68
+ });
69
+ }
70
+ /**
71
+ * The nested entry a `Delegated` signer needs.
72
+ *
73
+ * `require_auth_for_args` authorizes the CURRENT frame, which while
74
+ * `__check_auth` is running is the account contract executing `__check_auth`,
75
+ * with the digest as its single argument.
76
+ *
77
+ * The signer is the transaction source here, so source-account credentials
78
+ * carry it and no separate signature is required.
79
+ */
80
+ export function delegatedSignerEntry(accountId, digest) {
81
+ return new xdr.SorobanAuthorizationEntry({
82
+ credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(),
83
+ rootInvocation: new xdr.SorobanAuthorizedInvocation({
84
+ function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(new xdr.InvokeContractArgs({
85
+ contractAddress: new Address(accountId).toScAddress(),
86
+ functionName: '__check_auth',
87
+ args: [bytes(digest)],
88
+ })),
89
+ subInvocations: [],
90
+ }),
91
+ });
92
+ }
93
+ /** Rebuild the account's entry with the AuthPayload in the signature slot. */
94
+ export function accountEntry(original, signatureExpirationLedger, payload) {
95
+ const creds = original.credentials().address();
96
+ return new xdr.SorobanAuthorizationEntry({
97
+ credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(new xdr.SorobanAddressCredentials({
98
+ address: creds.address(),
99
+ nonce: creds.nonce(),
100
+ signatureExpirationLedger,
101
+ signature: payload,
102
+ })),
103
+ rootInvocation: original.rootInvocation(),
104
+ });
105
+ }
@@ -27,6 +27,7 @@ const stellar_sdk_1 = require("@stellar/stellar-sdk");
27
27
  Object.defineProperty(exports, "Contract", { enumerable: true, get: function () { return stellar_sdk_1.Contract; } });
28
28
  const build_add_context_rule_ts_1 = require("./build-add-context-rule.js");
29
29
  Object.defineProperty(exports, "DEFAULT_GRAMMAR_VERSION", { enumerable: true, get: function () { return build_add_context_rule_ts_1.DEFAULT_GRAMMAR_VERSION; } });
30
+ const oz_auth_ts_1 = require("./oz-auth.js");
30
31
  /** Convert a real rpc.Server into the InstallRpcClient surface. The
31
32
  * `getContractVersion` lookup uses `simulateTransaction` against
32
33
  * `contract.call('grammar_version')` and decodes the returned u32.
@@ -91,31 +92,28 @@ async function buildInstallPolicyXdr(args) {
91
92
  ...(args.oracleParams ? { oracleParams: args.oracleParams } : {}),
92
93
  ...(args.grammarVersion !== undefined ? { grammarVersion: args.grammarVersion } : {}),
93
94
  });
94
- // 2. Network pass: source account sequence + latest ledger.
95
+ // 2. Fetch the source sequence, then build the recording transaction with a
96
+ // bare host call. The recording pass assigns the smart-account auth nonce
97
+ // and root invocation needed to construct OZ's AuthPayload.
95
98
  const source = await args.rpc.getAccount(args.sourceAccount);
96
- const latest = await args.rpc.getLatestLedger();
97
- const validUntilLedger = latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS);
98
- // 3. Build the unsigned transaction envelope with the bare host call.
99
- const op = stellar_sdk_1.Operation.invokeHostFunction({
100
- func: stellar_sdk_1.xdr.HostFunction.hostFunctionTypeInvokeContract(new stellar_sdk_1.xdr.InvokeContractArgs({
101
- contractAddress: new stellar_sdk_1.Address(args.smartAccount).toScAddress(),
102
- functionName: 'add_context_rule',
103
- args: [...callArgs],
104
- })),
105
- auth: [],
106
- });
99
+ const hostFunction = stellar_sdk_1.xdr.HostFunction.hostFunctionTypeInvokeContract(new stellar_sdk_1.xdr.InvokeContractArgs({
100
+ contractAddress: new stellar_sdk_1.Address(args.smartAccount).toScAddress(),
101
+ functionName: 'add_context_rule',
102
+ args: [...callArgs],
103
+ }));
104
+ const makeOperation = (auth = []) => stellar_sdk_1.Operation.invokeHostFunction({ func: hostFunction, auth });
107
105
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : stellar_sdk_1.BASE_FEE;
108
- const tx = buildUnsignedTx({
106
+ const buildTx = (op) => buildUnsignedTx({
109
107
  sourceAccount: args.sourceAccount,
110
108
  sequence: source.sequenceNumber(),
111
109
  fee: baseFee,
112
110
  networkPassphrase: args.networkPassphrase,
113
111
  op,
114
112
  });
115
- // 4. Simulation pass. Returns the auth nonce + rootInvocation the wallet
116
- // signature binds to. We do NOT add the AuthPayload here - the wallet
117
- // injects auth entries using its own __check_auth flow.
118
- const recorded = await args.rpc.simulateTransaction(tx);
113
+ const recordingTx = buildTx(makeOperation());
114
+ // 3. Recording simulation: capture the smart account's nonce and invocation
115
+ // tree. Nothing is signed here.
116
+ const recorded = await args.rpc.simulateTransaction(recordingTx);
119
117
  if (stellar_sdk_1.rpc.Api.isSimulationError(recorded)) {
120
118
  // Short, stable reason. The full `simulateTransaction` error (which
121
119
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -129,14 +127,32 @@ async function buildInstallPolicyXdr(args) {
129
127
  if (!original) {
130
128
  throw new Error(`install_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
131
129
  }
132
- // 5. Decode the structured description FROM the unsigned XDR. The human
133
- // approval binds to the bytes the wallet will sign; deriving this from
134
- // the input args would re-describe the caller's intent rather than the
135
- // transaction. Re-parse the envelope we just built so the description
136
- // reflects what the wallet actually signs, not what we thought it would.
137
- const describes = decodeInstallCallDescribes(tx, op, args.installNonce);
130
+ // 4. `add_context_rule` is authorised by the deploy-time admin rule (rule 0).
131
+ // The delegated signer uses source-account credentials, so its signature
132
+ // bytes stay empty and the ordinary transaction-envelope signature covers
133
+ // the call when the consumer signs it.
134
+ const validUntilLedger = (await args.rpc.getLatestLedger()).sequence +
135
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS);
136
+ const contextRuleIds = [0];
137
+ const digest = (0, oz_auth_ts_1.authDigest)((0, oz_auth_ts_1.signaturePayload)(args.networkPassphrase, original.credentials().address().nonce(), validUntilLedger, original.rootInvocation()), contextRuleIds);
138
+ const authEntries = [
139
+ (0, oz_auth_ts_1.accountEntry)(original, validUntilLedger, (0, oz_auth_ts_1.authPayload)([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))),
140
+ ...contextRuleIds.map(() => (0, oz_auth_ts_1.delegatedSignerEntry)(args.smartAccount, digest)),
141
+ ];
142
+ // 5. Simulate again with the OZ entries already attached. The SDK preserves
143
+ // existing auth during assembly and adds the simulated Soroban footprint +
144
+ // resource fee, yielding a complete unsigned envelope.
145
+ const txWithAuth = buildTx(makeOperation(authEntries));
146
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth);
147
+ if (stellar_sdk_1.rpc.Api.isSimulationError(enforcing)) {
148
+ throw new Error('install_policy: auth simulateTransaction failed');
149
+ }
150
+ const finalTx = stellar_sdk_1.rpc.assembleTransaction(txWithAuth, enforcing).build();
151
+ // 6. Decode the structured description FROM the final assembled transaction.
152
+ // The human approval binds to the exact bytes the wallet will sign.
153
+ const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
138
154
  return {
139
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
155
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
140
156
  smartAccount: args.smartAccount,
141
157
  sourceAccount: args.sourceAccount,
142
158
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -165,25 +181,21 @@ async function buildInstallPolicyXdr(args) {
165
181
  * be guessing at a contract it cannot read, and the chain is the authority. */
166
182
  async function buildRevokePolicyXdr(args) {
167
183
  const source = await args.rpc.getAccount(args.sourceAccount);
168
- const latest = await args.rpc.getLatestLedger();
169
- const validUntilLedger = latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS);
170
- const op = stellar_sdk_1.Operation.invokeHostFunction({
171
- func: stellar_sdk_1.xdr.HostFunction.hostFunctionTypeInvokeContract(new stellar_sdk_1.xdr.InvokeContractArgs({
172
- contractAddress: new stellar_sdk_1.Address(args.smartAccount).toScAddress(),
173
- functionName: 'remove_context_rule',
174
- args: [stellar_sdk_1.xdr.ScVal.scvU32(args.ruleId)],
175
- })),
176
- auth: [],
177
- });
184
+ const hostFunction = stellar_sdk_1.xdr.HostFunction.hostFunctionTypeInvokeContract(new stellar_sdk_1.xdr.InvokeContractArgs({
185
+ contractAddress: new stellar_sdk_1.Address(args.smartAccount).toScAddress(),
186
+ functionName: 'remove_context_rule',
187
+ args: [stellar_sdk_1.xdr.ScVal.scvU32(args.ruleId)],
188
+ }));
189
+ const makeOperation = (auth = []) => stellar_sdk_1.Operation.invokeHostFunction({ func: hostFunction, auth });
178
190
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : stellar_sdk_1.BASE_FEE;
179
- const tx = buildUnsignedTx({
191
+ const buildTx = (op) => buildUnsignedTx({
180
192
  sourceAccount: args.sourceAccount,
181
193
  sequence: source.sequenceNumber(),
182
194
  fee: baseFee,
183
195
  networkPassphrase: args.networkPassphrase,
184
196
  op,
185
197
  });
186
- const recorded = await args.rpc.simulateTransaction(tx);
198
+ const recorded = await args.rpc.simulateTransaction(buildTx(makeOperation()));
187
199
  if (stellar_sdk_1.rpc.Api.isSimulationError(recorded)) {
188
200
  // Short, stable reason. The full `simulateTransaction` error (which
189
201
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -197,8 +209,27 @@ async function buildRevokePolicyXdr(args) {
197
209
  if (!original) {
198
210
  throw new Error(`revoke_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
199
211
  }
212
+ // Removal is master-only, so the deploy-time admin rule authorises it. The
213
+ // recorded rootInvocation still includes remove_context_rule(ruleId), binding
214
+ // the auth payload to the exact rule being removed. As with install,
215
+ // source-account credentials carry the delegated signer entry and the
216
+ // consumer supplies only the ordinary envelope signature.
217
+ const validUntilLedger = (await args.rpc.getLatestLedger()).sequence +
218
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS);
219
+ const contextRuleIds = [0];
220
+ const digest = (0, oz_auth_ts_1.authDigest)((0, oz_auth_ts_1.signaturePayload)(args.networkPassphrase, original.credentials().address().nonce(), validUntilLedger, original.rootInvocation()), contextRuleIds);
221
+ const authEntries = [
222
+ (0, oz_auth_ts_1.accountEntry)(original, validUntilLedger, (0, oz_auth_ts_1.authPayload)([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))),
223
+ ...contextRuleIds.map(() => (0, oz_auth_ts_1.delegatedSignerEntry)(args.smartAccount, digest)),
224
+ ];
225
+ const txWithAuth = buildTx(makeOperation(authEntries));
226
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth);
227
+ if (stellar_sdk_1.rpc.Api.isSimulationError(enforcing)) {
228
+ throw new Error('revoke_policy: auth simulateTransaction failed');
229
+ }
230
+ const finalTx = stellar_sdk_1.rpc.assembleTransaction(txWithAuth, enforcing).build();
200
231
  return {
201
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
232
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
202
233
  smartAccount: args.smartAccount,
203
234
  sourceAccount: args.sourceAccount,
204
235
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -208,7 +239,7 @@ async function buildRevokePolicyXdr(args) {
208
239
  };
209
240
  }
210
241
  /** ~25 minutes at 5s/ledger. */
211
- const DEFAULT_AUTH_VALID_LEDGERS = 300;
242
+ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
212
243
  // ---- internals ----
213
244
  /** Adapter: turn the install-policy wire `PolicyRef` shape into the core
214
245
  * `PolicyRef` shape `buildAddContextRuleArgs` expects. Keeps the wire
@@ -246,20 +277,24 @@ function buildUnsignedTx(args) {
246
277
  // its signature, and broadcasts. We do NOT sign here.
247
278
  return tx;
248
279
  }
249
- /** Decode the install call's structured fields directly out of the
250
- * built (transaction, operation) pair. The bytes are the source of
251
- * truth: a human reviewing the install wants to see what the wallet
252
- * will actually sign, not what we think it will. The decode is
253
- * deliberately strict - any shape we did not build ourselves throws
254
- * a ToolError, since that would mean the XDR was tampered with (or
280
+ /** Decode the install call's structured fields directly out of the final
281
+ * assembled transaction. The bytes are the source of truth: a human reviewing
282
+ * the install wants to see what the wallet will actually sign, not what we
283
+ * think it will. The decode is deliberately strict - any shape we did not
284
+ * build ourselves throws, since that would mean the XDR was tampered with (or
255
285
  * the encoder changed and this descriptor lags).
256
286
  *
257
287
  * `expectedInstallNonce` is a fallback for the rare case where the
258
288
  * install has no interpreter policy (no `install_nonce` to read); the
259
289
  * caller already knows it. */
260
- function decodeInstallCallDescribes(tx, op, expectedInstallNonce) {
290
+ function decodeInstallCallDescribes(tx, expectedInstallNonce) {
291
+ const operations = tx.toEnvelope().v1().tx().operations();
292
+ if (operations.length !== 1 || !operations[0]) {
293
+ throw new Error(`install_policy: final transaction has ${operations.length} operations, expected 1`);
294
+ }
295
+ const op = operations[0];
261
296
  const hostFn = op.body().invokeHostFunctionOp()?.hostFunction();
262
- if (!hostFn || hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
297
+ if (hostFn?.switch().name !== 'hostFunctionTypeInvokeContract') {
263
298
  throw new Error('install_policy: built op is not an invokeHostFunction(invokeContract(...)) call');
264
299
  }
265
300
  const invokeArgs = hostFn.invokeContract();
@@ -412,10 +447,6 @@ function decodeInstallCallDescribes(tx, op, expectedInstallNonce) {
412
447
  // `observedInstallNonce` is the nonce baked into whichever interpreter
413
448
  // policy is present; when none, fall back to the caller-supplied value.
414
449
  const installNonce = observedInstallNonce ?? expectedInstallNonce;
415
- // Touch `tx` so the linter does not flag it as unused - the parameter
416
- // documents that the caller built this Transaction; we re-parse the
417
- // operation off it as a sanity check.
418
- void tx;
419
450
  return {
420
451
  targetContract,
421
452
  fnName: 'add_context_rule',
@@ -0,0 +1,27 @@
1
+ import { xdr } from '@stellar/stellar-sdk';
2
+ /** `Signer::Delegated(addr)`. */
3
+ export declare const delegatedSigner: (a: string) => xdr.ScVal;
4
+ /** The standard Soroban authorization preimage hash for one entry. */
5
+ export declare function signaturePayload(networkPassphrase: string, nonce: xdr.Int64, signatureExpirationLedger: number, invocation: xdr.SorobanAuthorizedInvocation): Buffer;
6
+ /**
7
+ * `sha256(signature_payload || xdr(context_rule_ids))`.
8
+ *
9
+ * OZ binds the selected rule ids into the digest so a signature for one rule
10
+ * cannot be replayed against another.
11
+ */
12
+ export declare function authDigest(payload: Buffer, contextRuleIds: number[]): Buffer;
13
+ /** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
14
+ export declare function authPayload(signerAddresses: string[], contextRuleIds: number[], signatureFor: (addr: string) => Buffer): xdr.ScVal;
15
+ /**
16
+ * The nested entry a `Delegated` signer needs.
17
+ *
18
+ * `require_auth_for_args` authorizes the CURRENT frame, which while
19
+ * `__check_auth` is running is the account contract executing `__check_auth`,
20
+ * with the digest as its single argument.
21
+ *
22
+ * The signer is the transaction source here, so source-account credentials
23
+ * carry it and no separate signature is required.
24
+ */
25
+ export declare function delegatedSignerEntry(accountId: string, digest: Buffer): xdr.SorobanAuthorizationEntry;
26
+ /** Rebuild the account's entry with the AuthPayload in the signature slot. */
27
+ export declare function accountEntry(original: xdr.SorobanAuthorizationEntry, signatureExpirationLedger: number, payload: xdr.ScVal): xdr.SorobanAuthorizationEntry;
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ // Build the authorization entries OpenZeppelin's smart account requires.
3
+ //
4
+ // This is the piece that makes an end-to-end test of the account -> policy
5
+ // path possible. Without it, every attempt to exercise `__check_auth`
6
+ // produces a false positive: mocked auth skips `__check_auth` entirely,
7
+ // direct invocation is rejected by the host, and recording-mode simulation
8
+ // does not verify auth at all.
9
+ //
10
+ // Two entries are needed per call:
11
+ //
12
+ // 1. The ACCOUNT's entry. Its `signature` slot is not a signature - OZ puts
13
+ // an `AuthPayload { signers, context_rule_ids }` there, which
14
+ // `__check_auth` receives as its `signatures` argument.
15
+ //
16
+ // 2. One entry per DELEGATED signer. `do_check_auth` calls
17
+ // `addr.require_auth_for_args((auth_digest,))` for each, which is a
18
+ // nested authorization requirement the host will not record during
19
+ // simulation (it never runs `__check_auth` in recording mode), so it has
20
+ // to be constructed by hand.
21
+ //
22
+ // The digest OZ binds is NOT the raw auth payload hash:
23
+ //
24
+ // auth_digest = sha256(signature_payload || xdr(context_rule_ids))
25
+ //
26
+ // where `signature_payload` is the standard Soroban authorization preimage
27
+ // hash. See `do_check_auth` in
28
+ // stellar-contracts/packages/accounts/src/smart_account/storage.rs.
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.delegatedSigner = void 0;
31
+ exports.signaturePayload = signaturePayload;
32
+ exports.authDigest = authDigest;
33
+ exports.authPayload = authPayload;
34
+ exports.delegatedSignerEntry = delegatedSignerEntry;
35
+ exports.accountEntry = accountEntry;
36
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
37
+ const sym = (s) => stellar_sdk_1.xdr.ScVal.scvSymbol(s);
38
+ const u32 = (n) => stellar_sdk_1.xdr.ScVal.scvU32(n);
39
+ const vec = (i) => stellar_sdk_1.xdr.ScVal.scvVec(i);
40
+ const bytes = (b) => stellar_sdk_1.xdr.ScVal.scvBytes(b);
41
+ function struct(fields) {
42
+ return stellar_sdk_1.xdr.ScVal.scvMap(Object.keys(fields)
43
+ .sort()
44
+ .map((k) => new stellar_sdk_1.xdr.ScMapEntry({ key: sym(k), val: fields[k] })));
45
+ }
46
+ /** `Signer::Delegated(addr)`. */
47
+ const delegatedSigner = (a) => vec([sym('Delegated'), new stellar_sdk_1.Address(a).toScVal()]);
48
+ exports.delegatedSigner = delegatedSigner;
49
+ /** The standard Soroban authorization preimage hash for one entry. */
50
+ function signaturePayload(networkPassphrase, nonce, signatureExpirationLedger, invocation) {
51
+ const preimage = stellar_sdk_1.xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(new stellar_sdk_1.xdr.HashIdPreimageSorobanAuthorization({
52
+ networkId: (0, stellar_sdk_1.hash)(Buffer.from(networkPassphrase)),
53
+ nonce,
54
+ signatureExpirationLedger,
55
+ invocation,
56
+ }));
57
+ return (0, stellar_sdk_1.hash)(preimage.toXDR());
58
+ }
59
+ /**
60
+ * `sha256(signature_payload || xdr(context_rule_ids))`.
61
+ *
62
+ * OZ binds the selected rule ids into the digest so a signature for one rule
63
+ * cannot be replayed against another.
64
+ */
65
+ function authDigest(payload, contextRuleIds) {
66
+ const idsXdr = vec(contextRuleIds.map(u32)).toXDR();
67
+ return (0, stellar_sdk_1.hash)(Buffer.concat([payload, idsXdr]));
68
+ }
69
+ /** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
70
+ function authPayload(signerAddresses, contextRuleIds, signatureFor) {
71
+ return struct({
72
+ signers: stellar_sdk_1.xdr.ScVal.scvMap(signerAddresses
73
+ .map((a) => new stellar_sdk_1.xdr.ScMapEntry({ key: (0, exports.delegatedSigner)(a), val: bytes(signatureFor(a)) }))
74
+ // Host maps must be sorted by key.
75
+ .sort((x, y) => Buffer.compare(x.key().toXDR(), y.key().toXDR()))),
76
+ context_rule_ids: vec(contextRuleIds.map(u32)),
77
+ });
78
+ }
79
+ /**
80
+ * The nested entry a `Delegated` signer needs.
81
+ *
82
+ * `require_auth_for_args` authorizes the CURRENT frame, which while
83
+ * `__check_auth` is running is the account contract executing `__check_auth`,
84
+ * with the digest as its single argument.
85
+ *
86
+ * The signer is the transaction source here, so source-account credentials
87
+ * carry it and no separate signature is required.
88
+ */
89
+ function delegatedSignerEntry(accountId, digest) {
90
+ return new stellar_sdk_1.xdr.SorobanAuthorizationEntry({
91
+ credentials: stellar_sdk_1.xdr.SorobanCredentials.sorobanCredentialsSourceAccount(),
92
+ rootInvocation: new stellar_sdk_1.xdr.SorobanAuthorizedInvocation({
93
+ function: stellar_sdk_1.xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(new stellar_sdk_1.xdr.InvokeContractArgs({
94
+ contractAddress: new stellar_sdk_1.Address(accountId).toScAddress(),
95
+ functionName: '__check_auth',
96
+ args: [bytes(digest)],
97
+ })),
98
+ subInvocations: [],
99
+ }),
100
+ });
101
+ }
102
+ /** Rebuild the account's entry with the AuthPayload in the signature slot. */
103
+ function accountEntry(original, signatureExpirationLedger, payload) {
104
+ const creds = original.credentials().address();
105
+ return new stellar_sdk_1.xdr.SorobanAuthorizationEntry({
106
+ credentials: stellar_sdk_1.xdr.SorobanCredentials.sorobanCredentialsAddress(new stellar_sdk_1.xdr.SorobanAddressCredentials({
107
+ address: creds.address(),
108
+ nonce: creds.nonce(),
109
+ signatureExpirationLedger,
110
+ signature: payload,
111
+ })),
112
+ rootInvocation: original.rootInvocation(),
113
+ });
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "license": "MIT",
5
5
  "description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
6
6
  "type": "module",
@@ -31,6 +31,13 @@ import {
31
31
  xdr,
32
32
  } from '@stellar/stellar-sdk'
33
33
  import { buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION } from './build-add-context-rule.ts'
34
+ import {
35
+ accountEntry,
36
+ authDigest,
37
+ authPayload,
38
+ delegatedSignerEntry,
39
+ signaturePayload,
40
+ } from './oz-auth.ts'
34
41
 
35
42
  /** Minimal Soroban RPC surface the install pipeline needs. Test seams
36
43
  * inject a stub so the builder stays deterministic; production builds
@@ -265,36 +272,33 @@ export async function buildInstallPolicyXdr(
265
272
  }
266
273
  )
267
274
 
268
- // 2. Network pass: source account sequence + latest ledger.
275
+ // 2. Fetch the source sequence, then build the recording transaction with a
276
+ // bare host call. The recording pass assigns the smart-account auth nonce
277
+ // and root invocation needed to construct OZ's AuthPayload.
269
278
  const source = await args.rpc.getAccount(args.sourceAccount)
270
- const latest = await args.rpc.getLatestLedger()
271
- const validUntilLedger =
272
- latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS)
273
-
274
- // 3. Build the unsigned transaction envelope with the bare host call.
275
- const op = Operation.invokeHostFunction({
276
- func: xdr.HostFunction.hostFunctionTypeInvokeContract(
277
- new xdr.InvokeContractArgs({
278
- contractAddress: new Address(args.smartAccount).toScAddress(),
279
- functionName: 'add_context_rule',
280
- args: [...callArgs],
281
- })
282
- ),
283
- auth: [],
284
- })
279
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(
280
+ new xdr.InvokeContractArgs({
281
+ contractAddress: new Address(args.smartAccount).toScAddress(),
282
+ functionName: 'add_context_rule',
283
+ args: [...callArgs],
284
+ })
285
+ )
286
+ const makeOperation = (auth: xdr.SorobanAuthorizationEntry[] = []) =>
287
+ Operation.invokeHostFunction({ func: hostFunction, auth })
285
288
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE
286
- const tx = buildUnsignedTx({
287
- sourceAccount: args.sourceAccount,
288
- sequence: source.sequenceNumber(),
289
- fee: baseFee,
290
- networkPassphrase: args.networkPassphrase,
291
- op,
292
- })
293
-
294
- // 4. Simulation pass. Returns the auth nonce + rootInvocation the wallet
295
- // signature binds to. We do NOT add the AuthPayload here - the wallet
296
- // injects auth entries using its own __check_auth flow.
297
- const recorded = await args.rpc.simulateTransaction(tx)
289
+ const buildTx = (op: xdr.Operation) =>
290
+ buildUnsignedTx({
291
+ sourceAccount: args.sourceAccount,
292
+ sequence: source.sequenceNumber(),
293
+ fee: baseFee,
294
+ networkPassphrase: args.networkPassphrase,
295
+ op,
296
+ })
297
+ const recordingTx = buildTx(makeOperation())
298
+
299
+ // 3. Recording simulation: capture the smart account's nonce and invocation
300
+ // tree. Nothing is signed here.
301
+ const recorded = await args.rpc.simulateTransaction(recordingTx)
298
302
  if (rpc.Api.isSimulationError(recorded)) {
299
303
  // Short, stable reason. The full `simulateTransaction` error (which
300
304
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -314,15 +318,48 @@ export async function buildInstallPolicyXdr(
314
318
  )
315
319
  }
316
320
 
317
- // 5. Decode the structured description FROM the unsigned XDR. The human
318
- // approval binds to the bytes the wallet will sign; deriving this from
319
- // the input args would re-describe the caller's intent rather than the
320
- // transaction. Re-parse the envelope we just built so the description
321
- // reflects what the wallet actually signs, not what we thought it would.
322
- const describes = decodeInstallCallDescribes(tx, op, args.installNonce)
321
+ // 4. `add_context_rule` is authorised by the deploy-time admin rule (rule 0).
322
+ // The delegated signer uses source-account credentials, so its signature
323
+ // bytes stay empty and the ordinary transaction-envelope signature covers
324
+ // the call when the consumer signs it.
325
+ const validUntilLedger =
326
+ (await args.rpc.getLatestLedger()).sequence +
327
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS)
328
+ const contextRuleIds = [0]
329
+ const digest = authDigest(
330
+ signaturePayload(
331
+ args.networkPassphrase,
332
+ original.credentials().address().nonce(),
333
+ validUntilLedger,
334
+ original.rootInvocation()
335
+ ),
336
+ contextRuleIds
337
+ )
338
+ const authEntries = [
339
+ accountEntry(
340
+ original,
341
+ validUntilLedger,
342
+ authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))
343
+ ),
344
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
345
+ ]
346
+
347
+ // 5. Simulate again with the OZ entries already attached. The SDK preserves
348
+ // existing auth during assembly and adds the simulated Soroban footprint +
349
+ // resource fee, yielding a complete unsigned envelope.
350
+ const txWithAuth = buildTx(makeOperation(authEntries))
351
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth)
352
+ if (rpc.Api.isSimulationError(enforcing)) {
353
+ throw new Error('install_policy: auth simulateTransaction failed')
354
+ }
355
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build()
356
+
357
+ // 6. Decode the structured description FROM the final assembled transaction.
358
+ // The human approval binds to the exact bytes the wallet will sign.
359
+ const describes = decodeInstallCallDescribes(finalTx, args.installNonce)
323
360
 
324
361
  return {
325
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
362
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
326
363
  smartAccount: args.smartAccount,
327
364
  sourceAccount: args.sourceAccount,
328
365
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -360,30 +397,26 @@ export async function buildRevokePolicyXdr(args: {
360
397
  authValidUntilLedgers?: number
361
398
  }): Promise<BuildRevokePolicyResult> {
362
399
  const source = await args.rpc.getAccount(args.sourceAccount)
363
- const latest = await args.rpc.getLatestLedger()
364
- const validUntilLedger =
365
- latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS)
366
-
367
- const op = Operation.invokeHostFunction({
368
- func: xdr.HostFunction.hostFunctionTypeInvokeContract(
369
- new xdr.InvokeContractArgs({
370
- contractAddress: new Address(args.smartAccount).toScAddress(),
371
- functionName: 'remove_context_rule',
372
- args: [xdr.ScVal.scvU32(args.ruleId)],
373
- })
374
- ),
375
- auth: [],
376
- })
400
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(
401
+ new xdr.InvokeContractArgs({
402
+ contractAddress: new Address(args.smartAccount).toScAddress(),
403
+ functionName: 'remove_context_rule',
404
+ args: [xdr.ScVal.scvU32(args.ruleId)],
405
+ })
406
+ )
407
+ const makeOperation = (auth: xdr.SorobanAuthorizationEntry[] = []) =>
408
+ Operation.invokeHostFunction({ func: hostFunction, auth })
377
409
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE
378
- const tx = buildUnsignedTx({
379
- sourceAccount: args.sourceAccount,
380
- sequence: source.sequenceNumber(),
381
- fee: baseFee,
382
- networkPassphrase: args.networkPassphrase,
383
- op,
384
- })
385
-
386
- const recorded = await args.rpc.simulateTransaction(tx)
410
+ const buildTx = (op: xdr.Operation) =>
411
+ buildUnsignedTx({
412
+ sourceAccount: args.sourceAccount,
413
+ sequence: source.sequenceNumber(),
414
+ fee: baseFee,
415
+ networkPassphrase: args.networkPassphrase,
416
+ op,
417
+ })
418
+
419
+ const recorded = await args.rpc.simulateTransaction(buildTx(makeOperation()))
387
420
  if (rpc.Api.isSimulationError(recorded)) {
388
421
  // Short, stable reason. The full `simulateTransaction` error (which
389
422
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -403,8 +436,41 @@ export async function buildRevokePolicyXdr(args: {
403
436
  )
404
437
  }
405
438
 
439
+ // Removal is master-only, so the deploy-time admin rule authorises it. The
440
+ // recorded rootInvocation still includes remove_context_rule(ruleId), binding
441
+ // the auth payload to the exact rule being removed. As with install,
442
+ // source-account credentials carry the delegated signer entry and the
443
+ // consumer supplies only the ordinary envelope signature.
444
+ const validUntilLedger =
445
+ (await args.rpc.getLatestLedger()).sequence +
446
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS)
447
+ const contextRuleIds = [0]
448
+ const digest = authDigest(
449
+ signaturePayload(
450
+ args.networkPassphrase,
451
+ original.credentials().address().nonce(),
452
+ validUntilLedger,
453
+ original.rootInvocation()
454
+ ),
455
+ contextRuleIds
456
+ )
457
+ const authEntries = [
458
+ accountEntry(
459
+ original,
460
+ validUntilLedger,
461
+ authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))
462
+ ),
463
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
464
+ ]
465
+ const txWithAuth = buildTx(makeOperation(authEntries))
466
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth)
467
+ if (rpc.Api.isSimulationError(enforcing)) {
468
+ throw new Error('revoke_policy: auth simulateTransaction failed')
469
+ }
470
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build()
471
+
406
472
  return {
407
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
473
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
408
474
  smartAccount: args.smartAccount,
409
475
  sourceAccount: args.sourceAccount,
410
476
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -425,7 +491,7 @@ export interface BuildRevokePolicyResult {
425
491
  }
426
492
 
427
493
  /** ~25 minutes at 5s/ledger. */
428
- const DEFAULT_AUTH_VALID_LEDGERS = 300
494
+ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300
429
495
 
430
496
  // ---- internals ----
431
497
 
@@ -476,12 +542,11 @@ function buildUnsignedTx(args: {
476
542
  return tx
477
543
  }
478
544
 
479
- /** Decode the install call's structured fields directly out of the
480
- * built (transaction, operation) pair. The bytes are the source of
481
- * truth: a human reviewing the install wants to see what the wallet
482
- * will actually sign, not what we think it will. The decode is
483
- * deliberately strict - any shape we did not build ourselves throws
484
- * a ToolError, since that would mean the XDR was tampered with (or
545
+ /** Decode the install call's structured fields directly out of the final
546
+ * assembled transaction. The bytes are the source of truth: a human reviewing
547
+ * the install wants to see what the wallet will actually sign, not what we
548
+ * think it will. The decode is deliberately strict - any shape we did not
549
+ * build ourselves throws, since that would mean the XDR was tampered with (or
485
550
  * the encoder changed and this descriptor lags).
486
551
  *
487
552
  * `expectedInstallNonce` is a fallback for the rare case where the
@@ -489,11 +554,17 @@ function buildUnsignedTx(args: {
489
554
  * caller already knows it. */
490
555
  function decodeInstallCallDescribes(
491
556
  tx: Transaction,
492
- op: xdr.Operation,
493
557
  expectedInstallNonce: number
494
558
  ): InstallCallDescribes {
559
+ const operations = tx.toEnvelope().v1().tx().operations()
560
+ if (operations.length !== 1 || !operations[0]) {
561
+ throw new Error(
562
+ `install_policy: final transaction has ${operations.length} operations, expected 1`
563
+ )
564
+ }
565
+ const op = operations[0]
495
566
  const hostFn = op.body().invokeHostFunctionOp()?.hostFunction()
496
- if (!hostFn || hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
567
+ if (hostFn?.switch().name !== 'hostFunctionTypeInvokeContract') {
497
568
  throw new Error(
498
569
  'install_policy: built op is not an invokeHostFunction(invokeContract(...)) call'
499
570
  )
@@ -661,10 +732,6 @@ function decodeInstallCallDescribes(
661
732
  // `observedInstallNonce` is the nonce baked into whichever interpreter
662
733
  // policy is present; when none, fall back to the caller-supplied value.
663
734
  const installNonce = observedInstallNonce ?? expectedInstallNonce
664
- // Touch `tx` so the linter does not flag it as unused - the parameter
665
- // documents that the caller built this Transaction; we re-parse the
666
- // operation off it as a sanity check.
667
- void tx
668
735
  return {
669
736
  targetContract,
670
737
  fnName: 'add_context_rule',
@@ -0,0 +1,140 @@
1
+ // Build the authorization entries OpenZeppelin's smart account requires.
2
+ //
3
+ // This is the piece that makes an end-to-end test of the account -> policy
4
+ // path possible. Without it, every attempt to exercise `__check_auth`
5
+ // produces a false positive: mocked auth skips `__check_auth` entirely,
6
+ // direct invocation is rejected by the host, and recording-mode simulation
7
+ // does not verify auth at all.
8
+ //
9
+ // Two entries are needed per call:
10
+ //
11
+ // 1. The ACCOUNT's entry. Its `signature` slot is not a signature - OZ puts
12
+ // an `AuthPayload { signers, context_rule_ids }` there, which
13
+ // `__check_auth` receives as its `signatures` argument.
14
+ //
15
+ // 2. One entry per DELEGATED signer. `do_check_auth` calls
16
+ // `addr.require_auth_for_args((auth_digest,))` for each, which is a
17
+ // nested authorization requirement the host will not record during
18
+ // simulation (it never runs `__check_auth` in recording mode), so it has
19
+ // to be constructed by hand.
20
+ //
21
+ // The digest OZ binds is NOT the raw auth payload hash:
22
+ //
23
+ // auth_digest = sha256(signature_payload || xdr(context_rule_ids))
24
+ //
25
+ // where `signature_payload` is the standard Soroban authorization preimage
26
+ // hash. See `do_check_auth` in
27
+ // stellar-contracts/packages/accounts/src/smart_account/storage.rs.
28
+
29
+ import { Address, hash, xdr } from '@stellar/stellar-sdk'
30
+
31
+ const sym = (s: string) => xdr.ScVal.scvSymbol(s)
32
+ const u32 = (n: number) => xdr.ScVal.scvU32(n)
33
+ const vec = (i: xdr.ScVal[]) => xdr.ScVal.scvVec(i)
34
+ const bytes = (b: Buffer) => xdr.ScVal.scvBytes(b)
35
+
36
+ function struct(fields: Record<string, xdr.ScVal>): xdr.ScVal {
37
+ return xdr.ScVal.scvMap(
38
+ Object.keys(fields)
39
+ .sort()
40
+ .map((k) => new xdr.ScMapEntry({ key: sym(k), val: fields[k]! }))
41
+ )
42
+ }
43
+
44
+ /** `Signer::Delegated(addr)`. */
45
+ export const delegatedSigner = (a: string) => vec([sym('Delegated'), new Address(a).toScVal()])
46
+
47
+ /** The standard Soroban authorization preimage hash for one entry. */
48
+ export function signaturePayload(
49
+ networkPassphrase: string,
50
+ nonce: xdr.Int64,
51
+ signatureExpirationLedger: number,
52
+ invocation: xdr.SorobanAuthorizedInvocation
53
+ ): Buffer {
54
+ const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
55
+ new xdr.HashIdPreimageSorobanAuthorization({
56
+ networkId: hash(Buffer.from(networkPassphrase)),
57
+ nonce,
58
+ signatureExpirationLedger,
59
+ invocation,
60
+ })
61
+ )
62
+ return hash(preimage.toXDR())
63
+ }
64
+
65
+ /**
66
+ * `sha256(signature_payload || xdr(context_rule_ids))`.
67
+ *
68
+ * OZ binds the selected rule ids into the digest so a signature for one rule
69
+ * cannot be replayed against another.
70
+ */
71
+ export function authDigest(payload: Buffer, contextRuleIds: number[]): Buffer {
72
+ const idsXdr = vec(contextRuleIds.map(u32)).toXDR()
73
+ return hash(Buffer.concat([payload, idsXdr]))
74
+ }
75
+
76
+ /** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
77
+ export function authPayload(
78
+ signerAddresses: string[],
79
+ contextRuleIds: number[],
80
+ signatureFor: (addr: string) => Buffer
81
+ ): xdr.ScVal {
82
+ return struct({
83
+ signers: xdr.ScVal.scvMap(
84
+ signerAddresses
85
+ .map((a) => new xdr.ScMapEntry({ key: delegatedSigner(a), val: bytes(signatureFor(a)) }))
86
+ // Host maps must be sorted by key.
87
+ .sort((x, y) => Buffer.compare(x.key().toXDR(), y.key().toXDR()))
88
+ ),
89
+ context_rule_ids: vec(contextRuleIds.map(u32)),
90
+ })
91
+ }
92
+
93
+ /**
94
+ * The nested entry a `Delegated` signer needs.
95
+ *
96
+ * `require_auth_for_args` authorizes the CURRENT frame, which while
97
+ * `__check_auth` is running is the account contract executing `__check_auth`,
98
+ * with the digest as its single argument.
99
+ *
100
+ * The signer is the transaction source here, so source-account credentials
101
+ * carry it and no separate signature is required.
102
+ */
103
+ export function delegatedSignerEntry(
104
+ accountId: string,
105
+ digest: Buffer
106
+ ): xdr.SorobanAuthorizationEntry {
107
+ return new xdr.SorobanAuthorizationEntry({
108
+ credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(),
109
+ rootInvocation: new xdr.SorobanAuthorizedInvocation({
110
+ function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(
111
+ new xdr.InvokeContractArgs({
112
+ contractAddress: new Address(accountId).toScAddress(),
113
+ functionName: '__check_auth',
114
+ args: [bytes(digest)],
115
+ })
116
+ ),
117
+ subInvocations: [],
118
+ }),
119
+ })
120
+ }
121
+
122
+ /** Rebuild the account's entry with the AuthPayload in the signature slot. */
123
+ export function accountEntry(
124
+ original: xdr.SorobanAuthorizationEntry,
125
+ signatureExpirationLedger: number,
126
+ payload: xdr.ScVal
127
+ ): xdr.SorobanAuthorizationEntry {
128
+ const creds = original.credentials().address()
129
+ return new xdr.SorobanAuthorizationEntry({
130
+ credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(
131
+ new xdr.SorobanAddressCredentials({
132
+ address: creds.address(),
133
+ nonce: creds.nonce(),
134
+ signatureExpirationLedger,
135
+ signature: payload,
136
+ })
137
+ ),
138
+ rootInvocation: original.rootInvocation(),
139
+ })
140
+ }