@crediolabs/policy-synth 1.1.0 → 1.1.1

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.
@@ -112,6 +112,21 @@ export interface InstallCallDescribes {
112
112
  export interface BuildInstallPolicyResult {
113
113
  /** Unsigned Soroban transaction envelope, base64 XDR. */
114
114
  unsignedXdr: string;
115
+ /** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
116
+ * prove it arrived whole.
117
+ *
118
+ * This envelope runs to several thousand characters, and the only route
119
+ * from a tool result onto disk is the caller re-emitting it. A truncated
120
+ * copy is not obviously wrong - it fails later as
121
+ * "failed to decode XDR: xdr value invalid", which reads like a malformed
122
+ * transaction rather than a transport problem. Observed in practice: one of
123
+ * two envelopes written in the same session lost its tail and its base64
124
+ * length went from a multiple of four to `len % 4 == 3`.
125
+ *
126
+ * Check both before signing. They are cheap, and they turn a silent,
127
+ * fatal truncation into a retry. */
128
+ unsignedXdrLength: number;
129
+ unsignedXdrSha256: string;
115
130
  /** Smart account contract address (echo). */
116
131
  smartAccount: string;
117
132
  /** Source account (echo) - the address that must sign. */
@@ -168,6 +183,11 @@ export declare function buildRevokePolicyXdr(args: {
168
183
  }): Promise<BuildRevokePolicyResult>;
169
184
  export interface BuildRevokePolicyResult {
170
185
  unsignedXdr: string;
186
+ /** Same integrity pair as the install result, for the same reason: a revoke
187
+ * envelope also has to reach a signer intact, and a truncated copy fails as
188
+ * a malformed transaction rather than as a transport error. */
189
+ unsignedXdrLength: number;
190
+ unsignedXdrSha256: string;
171
191
  smartAccount: string;
172
192
  sourceAccount: string;
173
193
  call: {
@@ -62,6 +62,14 @@ export function rpcClientFromServer(server, networkPassphrase) {
62
62
  },
63
63
  };
64
64
  }
65
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
66
+ function xdrIntegrity(unsignedXdr) {
67
+ return {
68
+ unsignedXdr,
69
+ unsignedXdrLength: unsignedXdr.length,
70
+ unsignedXdrSha256: createHash('sha256').update(unsignedXdr, 'utf8').digest('hex'),
71
+ };
72
+ }
65
73
  /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
66
74
  * The output XDR is signed by the wallet, not by us. */
67
75
  export async function buildInstallPolicyXdr(args) {
@@ -90,7 +98,7 @@ export async function buildInstallPolicyXdr(args) {
90
98
  // The human approval binds to the exact bytes the wallet will sign.
91
99
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
92
100
  return {
93
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
101
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
94
102
  smartAccount: args.smartAccount,
95
103
  sourceAccount: args.sourceAccount,
96
104
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -119,7 +127,7 @@ export async function buildRevokePolicyXdr(args) {
119
127
  // consumer supplies only the ordinary envelope signature.
120
128
  const { finalTx, original, validUntilLedger } = await buildAuthorisedSmartAccountTx(args, 'remove_context_rule', [xdr.ScVal.scvU32(args.ruleId)], 'revoke_policy');
121
129
  return {
122
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
130
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
123
131
  smartAccount: args.smartAccount,
124
132
  sourceAccount: args.sourceAccount,
125
133
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -2849,10 +2849,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
2849
2849
  predicate?: unknown;
2850
2850
  }>, "many">>;
2851
2851
  /** The smart account contract address (C...) that will receive the rule. */
2852
- smartAccount: z.ZodString;
2852
+ smartAccount: z.ZodEffects<z.ZodString, string, string>;
2853
2853
  /** The signer that authorises the install (G... wallet). Used only for
2854
2854
  * sequence number + auth nonce simulation; never persisted, never signed. */
2855
- sourceAccount: z.ZodString;
2855
+ sourceAccount: z.ZodEffects<z.ZodString, string, string>;
2856
2856
  /** Target network for the install. Selects which interpreter pin and
2857
2857
  * which RPC URL are valid by default. Defaults to `testnet` so the
2858
2858
  * pre-mainnet callers keep working: they were always pointing at
@@ -4233,12 +4233,12 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4233
4233
  export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>;
4234
4234
  export declare const RevokePolicyInputSchema: z.ZodEffects<z.ZodObject<{
4235
4235
  /** The smart account contract address (C...). */
4236
- smartAccount: z.ZodString;
4236
+ smartAccount: z.ZodEffects<z.ZodString, string, string>;
4237
4237
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
4238
4238
  * accepts that signer; this schema does not assert a rule it cannot
4239
4239
  * verify, since the account's source is not in this repo. Proven on
4240
4240
  * testnet: the account's deployer can revoke. */
4241
- sourceAccount: z.ZodString;
4241
+ sourceAccount: z.ZodEffects<z.ZodString, string, string>;
4242
4242
  /** Target network for the revoke. Same `testnet`-default as install,
4243
4243
  * so pre-mainnet callers keep working without an explicit flag. */
4244
4244
  network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
@@ -14,6 +14,7 @@
14
14
  // This module is the SINGLE source of truth for these shapes. The MCP package
15
15
  // imports them here so its tool-shape bindings stay in step; the CLI imports
16
16
  // them here so it can build the same args envelope the MCP transport builds.
17
+ import { StrKey } from '@stellar/stellar-sdk';
17
18
  import { z } from 'zod';
18
19
  import { isStellarAddress } from "../synth/address.js";
19
20
  /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
@@ -471,6 +472,20 @@ export const NETWORK_PASSPHRASES = {
471
472
  // `sourceAccount` is the signing wallet (G...).
472
473
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/;
473
474
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/;
475
+ // The regexes above check SHAPE only. A wrong-but-well-formed address - the
476
+ // classic case being one an agent reproduced from memory - passes them and then
477
+ // fails the SDK's StrKey decoder deep inside the build, where the throw is
478
+ // caught by the tool envelope and reported as a bare "invalid checksum" naming
479
+ // no field. A caller holding several addresses then cannot tell which one is
480
+ // wrong. Validating the checksum HERE keeps the field name attached.
481
+ const contractAddress = (field) => z
482
+ .string()
483
+ .regex(STELLAR_CONTRACT_ADDRESS, `${field} must be a Stellar contract address (C...)`)
484
+ .refine(StrKey.isValidContract, `${field} is not a valid contract address: the checksum does not match, so this address does not exist`);
485
+ const accountAddress = (field) => z
486
+ .string()
487
+ .regex(STELLAR_ACCOUNT_ADDRESS, `${field} must be a Stellar account address (G...)`)
488
+ .refine(StrKey.isValidEd25519PublicKey, `${field} is not a valid account address: the checksum does not match, so this address does not exist`);
474
489
  // ===== declare_policy =====
475
490
  //
476
491
  // The declarative front-end: the constraint stated outright, with no
@@ -532,14 +547,10 @@ export const InstallPolicyInputSchema = z
532
547
  * result says so rather than reporting "no overlaps found". */
533
548
  existingRules: z.array(ObservedRuleSchema).optional(),
534
549
  /** The smart account contract address (C...) that will receive the rule. */
535
- smartAccount: z
536
- .string()
537
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
550
+ smartAccount: contractAddress('smartAccount'),
538
551
  /** The signer that authorises the install (G... wallet). Used only for
539
552
  * sequence number + auth nonce simulation; never persisted, never signed. */
540
- sourceAccount: z
541
- .string()
542
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
553
+ sourceAccount: accountAddress('sourceAccount'),
543
554
  /** Target network for the install. Selects which interpreter pin and
544
555
  * which RPC URL are valid by default. Defaults to `testnet` so the
545
556
  * pre-mainnet callers keep working: they were always pointing at
@@ -673,16 +684,12 @@ export const InstallPolicyInputSchema = z
673
684
  export const RevokePolicyInputSchema = z
674
685
  .object({
675
686
  /** The smart account contract address (C...). */
676
- smartAccount: z
677
- .string()
678
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
687
+ smartAccount: contractAddress('smartAccount'),
679
688
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
680
689
  * accepts that signer; this schema does not assert a rule it cannot
681
690
  * verify, since the account's source is not in this repo. Proven on
682
691
  * testnet: the account's deployer can revoke. */
683
- sourceAccount: z
684
- .string()
685
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
692
+ sourceAccount: accountAddress('sourceAccount'),
686
693
  /** Target network for the revoke. Same `testnet`-default as install,
687
694
  * so pre-mainnet callers keep working without an explicit flag. */
688
695
  network: NetworkSchema.optional(),
@@ -112,6 +112,21 @@ export interface InstallCallDescribes {
112
112
  export interface BuildInstallPolicyResult {
113
113
  /** Unsigned Soroban transaction envelope, base64 XDR. */
114
114
  unsignedXdr: string;
115
+ /** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
116
+ * prove it arrived whole.
117
+ *
118
+ * This envelope runs to several thousand characters, and the only route
119
+ * from a tool result onto disk is the caller re-emitting it. A truncated
120
+ * copy is not obviously wrong - it fails later as
121
+ * "failed to decode XDR: xdr value invalid", which reads like a malformed
122
+ * transaction rather than a transport problem. Observed in practice: one of
123
+ * two envelopes written in the same session lost its tail and its base64
124
+ * length went from a multiple of four to `len % 4 == 3`.
125
+ *
126
+ * Check both before signing. They are cheap, and they turn a silent,
127
+ * fatal truncation into a retry. */
128
+ unsignedXdrLength: number;
129
+ unsignedXdrSha256: string;
115
130
  /** Smart account contract address (echo). */
116
131
  smartAccount: string;
117
132
  /** Source account (echo) - the address that must sign. */
@@ -168,6 +183,11 @@ export declare function buildRevokePolicyXdr(args: {
168
183
  }): Promise<BuildRevokePolicyResult>;
169
184
  export interface BuildRevokePolicyResult {
170
185
  unsignedXdr: string;
186
+ /** Same integrity pair as the install result, for the same reason: a revoke
187
+ * envelope also has to reach a signer intact, and a truncated copy fails as
188
+ * a malformed transaction rather than as a transport error. */
189
+ unsignedXdrLength: number;
190
+ unsignedXdrSha256: string;
171
191
  smartAccount: string;
172
192
  sourceAccount: string;
173
193
  call: {
@@ -68,6 +68,14 @@ function rpcClientFromServer(server, networkPassphrase) {
68
68
  },
69
69
  };
70
70
  }
71
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
72
+ function xdrIntegrity(unsignedXdr) {
73
+ return {
74
+ unsignedXdr,
75
+ unsignedXdrLength: unsignedXdr.length,
76
+ unsignedXdrSha256: (0, node_crypto_1.createHash)('sha256').update(unsignedXdr, 'utf8').digest('hex'),
77
+ };
78
+ }
71
79
  /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
72
80
  * The output XDR is signed by the wallet, not by us. */
73
81
  async function buildInstallPolicyXdr(args) {
@@ -96,7 +104,7 @@ async function buildInstallPolicyXdr(args) {
96
104
  // The human approval binds to the exact bytes the wallet will sign.
97
105
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
98
106
  return {
99
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
107
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
100
108
  smartAccount: args.smartAccount,
101
109
  sourceAccount: args.sourceAccount,
102
110
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -125,7 +133,7 @@ async function buildRevokePolicyXdr(args) {
125
133
  // consumer supplies only the ordinary envelope signature.
126
134
  const { finalTx, original, validUntilLedger } = await buildAuthorisedSmartAccountTx(args, 'remove_context_rule', [stellar_sdk_1.xdr.ScVal.scvU32(args.ruleId)], 'revoke_policy');
127
135
  return {
128
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
136
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
129
137
  smartAccount: args.smartAccount,
130
138
  sourceAccount: args.sourceAccount,
131
139
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -2849,10 +2849,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
2849
2849
  predicate?: unknown;
2850
2850
  }>, "many">>;
2851
2851
  /** The smart account contract address (C...) that will receive the rule. */
2852
- smartAccount: z.ZodString;
2852
+ smartAccount: z.ZodEffects<z.ZodString, string, string>;
2853
2853
  /** The signer that authorises the install (G... wallet). Used only for
2854
2854
  * sequence number + auth nonce simulation; never persisted, never signed. */
2855
- sourceAccount: z.ZodString;
2855
+ sourceAccount: z.ZodEffects<z.ZodString, string, string>;
2856
2856
  /** Target network for the install. Selects which interpreter pin and
2857
2857
  * which RPC URL are valid by default. Defaults to `testnet` so the
2858
2858
  * pre-mainnet callers keep working: they were always pointing at
@@ -4233,12 +4233,12 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4233
4233
  export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>;
4234
4234
  export declare const RevokePolicyInputSchema: z.ZodEffects<z.ZodObject<{
4235
4235
  /** The smart account contract address (C...). */
4236
- smartAccount: z.ZodString;
4236
+ smartAccount: z.ZodEffects<z.ZodString, string, string>;
4237
4237
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
4238
4238
  * accepts that signer; this schema does not assert a rule it cannot
4239
4239
  * verify, since the account's source is not in this repo. Proven on
4240
4240
  * testnet: the account's deployer can revoke. */
4241
- sourceAccount: z.ZodString;
4241
+ sourceAccount: z.ZodEffects<z.ZodString, string, string>;
4242
4242
  /** Target network for the revoke. Same `testnet`-default as install,
4243
4243
  * so pre-mainnet callers keep working without an explicit flag. */
4244
4244
  network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
@@ -17,6 +17,7 @@
17
17
  // them here so it can build the same args envelope the MCP transport builds.
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.ToolErrorSchema = exports.GetInterpreterInfoInputSchema = exports.RevokePolicyInputSchema = exports.InstallPolicyInputSchema = exports.DeclarePolicyInputSchema = exports.NETWORK_PASSPHRASES = exports.PINNED_OZ_POLICY_WASM_SHA256 = exports.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK = exports.PINNED_OZ_STELLAR_CONTRACTS_TAG = exports.RPC_URL_BY_NETWORK = exports.PINNED_INTERPRETER_ADDRESS_BY_NETWORK = exports.MAINNET_RPC_URL = exports.TESTNET_RPC_URL = exports.PINNED_INTERPRETER_GRAMMAR_VERSION = exports.PINNED_INTERPRETER_WASM_SHA256 = exports.PINNED_INTERPRETER_MAINNET_ADDRESS = exports.PINNED_INTERPRETER_TESTNET_ADDRESS = exports.ObservedRuleSchema = exports.VerifyPolicyInputSchema = exports.SimulatePolicyInputSchema = exports.PredicateNodeSchema = exports.PredicateLeafSchema = exports.SynthesizePolicyInputSchema = exports.InterpreterOptionsSchema = exports.RecordTransactionInputSchema = exports.ComposeUserResponsesSchema = exports.RecordedTransactionSchema = exports.ParseConfidenceSchema = exports.OnChainEventSchema = exports.TokenMovementSchema = exports.ContractInvocationSchema = exports.ScValSchema = exports.NetworkSchema = void 0;
20
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
20
21
  const zod_1 = require("zod");
21
22
  const address_ts_1 = require("../synth/address.js");
22
23
  /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
@@ -474,6 +475,20 @@ exports.NETWORK_PASSPHRASES = {
474
475
  // `sourceAccount` is the signing wallet (G...).
475
476
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/;
476
477
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/;
478
+ // The regexes above check SHAPE only. A wrong-but-well-formed address - the
479
+ // classic case being one an agent reproduced from memory - passes them and then
480
+ // fails the SDK's StrKey decoder deep inside the build, where the throw is
481
+ // caught by the tool envelope and reported as a bare "invalid checksum" naming
482
+ // no field. A caller holding several addresses then cannot tell which one is
483
+ // wrong. Validating the checksum HERE keeps the field name attached.
484
+ const contractAddress = (field) => zod_1.z
485
+ .string()
486
+ .regex(STELLAR_CONTRACT_ADDRESS, `${field} must be a Stellar contract address (C...)`)
487
+ .refine(stellar_sdk_1.StrKey.isValidContract, `${field} is not a valid contract address: the checksum does not match, so this address does not exist`);
488
+ const accountAddress = (field) => zod_1.z
489
+ .string()
490
+ .regex(STELLAR_ACCOUNT_ADDRESS, `${field} must be a Stellar account address (G...)`)
491
+ .refine(stellar_sdk_1.StrKey.isValidEd25519PublicKey, `${field} is not a valid account address: the checksum does not match, so this address does not exist`);
477
492
  // ===== declare_policy =====
478
493
  //
479
494
  // The declarative front-end: the constraint stated outright, with no
@@ -535,14 +550,10 @@ exports.InstallPolicyInputSchema = zod_1.z
535
550
  * result says so rather than reporting "no overlaps found". */
536
551
  existingRules: zod_1.z.array(exports.ObservedRuleSchema).optional(),
537
552
  /** The smart account contract address (C...) that will receive the rule. */
538
- smartAccount: zod_1.z
539
- .string()
540
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
553
+ smartAccount: contractAddress('smartAccount'),
541
554
  /** The signer that authorises the install (G... wallet). Used only for
542
555
  * sequence number + auth nonce simulation; never persisted, never signed. */
543
- sourceAccount: zod_1.z
544
- .string()
545
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
556
+ sourceAccount: accountAddress('sourceAccount'),
546
557
  /** Target network for the install. Selects which interpreter pin and
547
558
  * which RPC URL are valid by default. Defaults to `testnet` so the
548
559
  * pre-mainnet callers keep working: they were always pointing at
@@ -676,16 +687,12 @@ exports.InstallPolicyInputSchema = zod_1.z
676
687
  exports.RevokePolicyInputSchema = zod_1.z
677
688
  .object({
678
689
  /** The smart account contract address (C...). */
679
- smartAccount: zod_1.z
680
- .string()
681
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
690
+ smartAccount: contractAddress('smartAccount'),
682
691
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
683
692
  * accepts that signer; this schema does not assert a rule it cannot
684
693
  * verify, since the account's source is not in this repo. Proven on
685
694
  * testnet: the account's deployer can revoke. */
686
- sourceAccount: zod_1.z
687
- .string()
688
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
695
+ sourceAccount: accountAddress('sourceAccount'),
689
696
  /** Target network for the revoke. Same `testnet`-default as install,
690
697
  * so pre-mainnet callers keep working without an explicit flag. */
691
698
  network: exports.NetworkSchema.optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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",
@@ -182,12 +182,40 @@ export interface InstallCallDescribes {
182
182
  installNonce: number
183
183
  }
184
184
 
185
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
186
+ function xdrIntegrity(unsignedXdr: string): {
187
+ unsignedXdr: string
188
+ unsignedXdrLength: number
189
+ unsignedXdrSha256: string
190
+ } {
191
+ return {
192
+ unsignedXdr,
193
+ unsignedXdrLength: unsignedXdr.length,
194
+ unsignedXdrSha256: createHash('sha256').update(unsignedXdr, 'utf8').digest('hex'),
195
+ }
196
+ }
197
+
185
198
  /** Output of the install-policy build. The unsigned XDR is the wallet's
186
199
  * input; the captured auth nonce + invocation root make the response
187
200
  * self-describing for callers that want to inspect what they signed. */
188
201
  export interface BuildInstallPolicyResult {
189
202
  /** Unsigned Soroban transaction envelope, base64 XDR. */
190
203
  unsignedXdr: string
204
+ /** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
205
+ * prove it arrived whole.
206
+ *
207
+ * This envelope runs to several thousand characters, and the only route
208
+ * from a tool result onto disk is the caller re-emitting it. A truncated
209
+ * copy is not obviously wrong - it fails later as
210
+ * "failed to decode XDR: xdr value invalid", which reads like a malformed
211
+ * transaction rather than a transport problem. Observed in practice: one of
212
+ * two envelopes written in the same session lost its tail and its base64
213
+ * length went from a multiple of four to `len % 4 == 3`.
214
+ *
215
+ * Check both before signing. They are cheap, and they turn a silent,
216
+ * fatal truncation into a retry. */
217
+ unsignedXdrLength: number
218
+ unsignedXdrSha256: string
191
219
  /** Smart account contract address (echo). */
192
220
  smartAccount: string
193
221
  /** Source account (echo) - the address that must sign. */
@@ -258,7 +286,7 @@ export async function buildInstallPolicyXdr(
258
286
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce)
259
287
 
260
288
  return {
261
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
289
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
262
290
  smartAccount: args.smartAccount,
263
291
  sourceAccount: args.sourceAccount,
264
292
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -302,7 +330,7 @@ export async function buildRevokePolicyXdr(args: {
302
330
  )
303
331
 
304
332
  return {
305
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
333
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
306
334
  smartAccount: args.smartAccount,
307
335
  sourceAccount: args.sourceAccount,
308
336
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -314,6 +342,11 @@ export async function buildRevokePolicyXdr(args: {
314
342
 
315
343
  export interface BuildRevokePolicyResult {
316
344
  unsignedXdr: string
345
+ /** Same integrity pair as the install result, for the same reason: a revoke
346
+ * envelope also has to reach a signer intact, and a truncated copy fails as
347
+ * a malformed transaction rather than as a transport error. */
348
+ unsignedXdrLength: number
349
+ unsignedXdrSha256: string
317
350
  smartAccount: string
318
351
  sourceAccount: string
319
352
  call: { contract: string; fn: 'remove_context_rule'; ruleId: number }
@@ -15,6 +15,7 @@
15
15
  // imports them here so its tool-shape bindings stay in step; the CLI imports
16
16
  // them here so it can build the same args envelope the MCP transport builds.
17
17
 
18
+ import { StrKey } from '@stellar/stellar-sdk'
18
19
  import { z } from 'zod'
19
20
  import { isStellarAddress } from '../synth/address.ts'
20
21
 
@@ -550,6 +551,29 @@ export const NETWORK_PASSPHRASES: Record<Network, string> = {
550
551
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/
551
552
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/
552
553
 
554
+ // The regexes above check SHAPE only. A wrong-but-well-formed address - the
555
+ // classic case being one an agent reproduced from memory - passes them and then
556
+ // fails the SDK's StrKey decoder deep inside the build, where the throw is
557
+ // caught by the tool envelope and reported as a bare "invalid checksum" naming
558
+ // no field. A caller holding several addresses then cannot tell which one is
559
+ // wrong. Validating the checksum HERE keeps the field name attached.
560
+ const contractAddress = (field: string) =>
561
+ z
562
+ .string()
563
+ .regex(STELLAR_CONTRACT_ADDRESS, `${field} must be a Stellar contract address (C...)`)
564
+ .refine(
565
+ StrKey.isValidContract,
566
+ `${field} is not a valid contract address: the checksum does not match, so this address does not exist`
567
+ )
568
+ const accountAddress = (field: string) =>
569
+ z
570
+ .string()
571
+ .regex(STELLAR_ACCOUNT_ADDRESS, `${field} must be a Stellar account address (G...)`)
572
+ .refine(
573
+ StrKey.isValidEd25519PublicKey,
574
+ `${field} is not a valid account address: the checksum does not match, so this address does not exist`
575
+ )
576
+
553
577
  // ===== declare_policy =====
554
578
  //
555
579
  // The declarative front-end: the constraint stated outright, with no
@@ -613,14 +637,10 @@ export const InstallPolicyInputSchema = z
613
637
  * result says so rather than reporting "no overlaps found". */
614
638
  existingRules: z.array(ObservedRuleSchema).optional(),
615
639
  /** The smart account contract address (C...) that will receive the rule. */
616
- smartAccount: z
617
- .string()
618
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
640
+ smartAccount: contractAddress('smartAccount'),
619
641
  /** The signer that authorises the install (G... wallet). Used only for
620
642
  * sequence number + auth nonce simulation; never persisted, never signed. */
621
- sourceAccount: z
622
- .string()
623
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
643
+ sourceAccount: accountAddress('sourceAccount'),
624
644
  /** Target network for the install. Selects which interpreter pin and
625
645
  * which RPC URL are valid by default. Defaults to `testnet` so the
626
646
  * pre-mainnet callers keep working: they were always pointing at
@@ -760,16 +780,12 @@ export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>
760
780
  export const RevokePolicyInputSchema = z
761
781
  .object({
762
782
  /** The smart account contract address (C...). */
763
- smartAccount: z
764
- .string()
765
- .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
783
+ smartAccount: contractAddress('smartAccount'),
766
784
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
767
785
  * accepts that signer; this schema does not assert a rule it cannot
768
786
  * verify, since the account's source is not in this repo. Proven on
769
787
  * testnet: the account's deployer can revoke. */
770
- sourceAccount: z
771
- .string()
772
- .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
788
+ sourceAccount: accountAddress('sourceAccount'),
773
789
  /** Target network for the revoke. Same `testnet`-default as install,
774
790
  * so pre-mainnet callers keep working without an explicit flag. */
775
791
  network: NetworkSchema.optional(),