diff --git a/packages/core/src/artifacts/artifact.schema.test.ts b/packages/core/src/artifacts/artifact.schema.test.ts new file mode 100644 index 00000000..abbbdb1d --- /dev/null +++ b/packages/core/src/artifacts/artifact.schema.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + ContractArtifactHistoryEntrySchema, + ContractArtifactSchema, +} from "./artifact.schema.js"; + +/** A well-formed contract strkey: `C` + 55 base32 (A-Z2-7) characters. */ +const VALID_CONTRACT_ID = `C${"A".repeat(55)}`; +/** A well-formed wasm hash: 64 lowercase hex characters. */ +const VALID_WASM_HASH = "a".repeat(64); + +function baseArtifact(overrides: Record = {}) { + return { + contractId: VALID_CONTRACT_ID, + wasmHash: VALID_WASM_HASH, + deployedAt: "2026-05-12T00:00:00.000Z", + sourcePath: "./contracts/token", + wasmPath: "./contracts/token.wasm", + ...overrides, + }; +} + +function baseHistoryEntry(overrides: Record = {}) { + return { + contractId: VALID_CONTRACT_ID, + wasmHash: VALID_WASM_HASH, + deployedAt: "2026-05-12T00:00:00.000Z", + supersededAt: "2026-05-13T00:00:00.000Z", + ...overrides, + }; +} + +describe("ContractArtifactSchema contractId/wasmHash validation", () => { + it("accepts a well-formed contract strkey and lowercase-hex wasm hash", () => { + const parsed = ContractArtifactSchema.parse(baseArtifact()); + expect(parsed.contractId).toBe(VALID_CONTRACT_ID); + expect(parsed.wasmHash).toBe(VALID_WASM_HASH); + }); + + it("accepts a realistic base32 contract strkey", () => { + const parsed = ContractArtifactSchema.parse( + baseArtifact({ contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" }) + ); + expect(parsed.contractId).toBe("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"); + }); + + it("rejects a contractId with the wrong prefix (G instead of C)", () => { + expect(() => ContractArtifactSchema.parse(baseArtifact({ contractId: `G${"A".repeat(55)}` }))).toThrow(); + }); + + it("rejects a contractId of the wrong length", () => { + expect(() => ContractArtifactSchema.parse(baseArtifact({ contractId: `C${"A".repeat(54)}` }))).toThrow(); + expect(() => ContractArtifactSchema.parse(baseArtifact({ contractId: "C123" }))).toThrow(); + }); + + it("rejects a lowercase contractId", () => { + expect(() => + ContractArtifactSchema.parse(baseArtifact({ contractId: `C${"a".repeat(55)}` })) + ).toThrow(); + }); + + it("rejects a contractId containing base32-invalid digits 0/1/8/9", () => { + for (const digit of ["0", "1", "8", "9"]) { + const bad = `C${digit}${"A".repeat(54)}`; + expect(() => ContractArtifactSchema.parse(baseArtifact({ contractId: bad }))).toThrow(); + } + }); + + it("rejects an uppercase wasmHash", () => { + expect(() => + ContractArtifactSchema.parse(baseArtifact({ wasmHash: "A".repeat(64) })) + ).toThrow(); + }); + + it("rejects a wasmHash of the wrong length", () => { + expect(() => ContractArtifactSchema.parse(baseArtifact({ wasmHash: "a".repeat(63) }))).toThrow(); + expect(() => ContractArtifactSchema.parse(baseArtifact({ wasmHash: "abc" }))).toThrow(); + }); + + it("rejects a non-hex wasmHash", () => { + expect(() => + ContractArtifactSchema.parse(baseArtifact({ wasmHash: `g${"a".repeat(63)}` })) + ).toThrow(); + }); +}); + +describe("ContractArtifactHistoryEntrySchema contractId/wasmHash validation", () => { + it("accepts a well-formed history entry", () => { + const parsed = ContractArtifactHistoryEntrySchema.parse(baseHistoryEntry()); + expect(parsed.contractId).toBe(VALID_CONTRACT_ID); + expect(parsed.wasmHash).toBe(VALID_WASM_HASH); + }); + + it("rejects a malformed contractId in the history entry", () => { + expect(() => + ContractArtifactHistoryEntrySchema.parse(baseHistoryEntry({ contractId: "C123" })) + ).toThrow(); + }); + + it("rejects a malformed wasmHash in the history entry", () => { + expect(() => + ContractArtifactHistoryEntrySchema.parse(baseHistoryEntry({ wasmHash: "A".repeat(64) })) + ).toThrow(); + }); +}); diff --git a/packages/core/src/artifacts/artifact.schema.ts b/packages/core/src/artifacts/artifact.schema.ts index 5a190fa5..3726814c 100644 --- a/packages/core/src/artifacts/artifact.schema.ts +++ b/packages/core/src/artifacts/artifact.schema.ts @@ -1,4 +1,9 @@ import { z } from "zod"; +import { CONTRACT_ID_REGEX, WASM_HASH_REGEX } from "../stellar-cli/strkey.js"; + +const CONTRACT_ID_MESSAGE = + "contractId must be a Stellar contract strkey: a `C` prefix followed by 55 base32 characters (A-Z2-7)."; +const WASM_HASH_MESSAGE = "wasmHash must be 64 lowercase hex characters."; export const ArtifactSupersedeReasonSchema = z.enum(["upgrade", "rollback", "force-redeploy"]); @@ -24,8 +29,8 @@ export const ContractMetadataSchema = z.object({ export type ContractMetadata = z.infer; export const ContractArtifactHistoryEntrySchema = z.object({ - contractId: z.string().min(1), - wasmHash: z.string().min(1), + contractId: z.string().regex(CONTRACT_ID_REGEX, CONTRACT_ID_MESSAGE), + wasmHash: z.string().regex(WASM_HASH_REGEX, WASM_HASH_MESSAGE), deployedAt: z.string().datetime(), supersededAt: z.string().datetime(), reason: ArtifactSupersedeReasonSchema.optional(), @@ -36,8 +41,8 @@ export const ContractArtifactHistoryEntrySchema = z.object({ export type ContractArtifactHistoryEntry = z.infer; export const ContractArtifactSchema = z.object({ - contractId: z.string().min(1), - wasmHash: z.string().min(1), + contractId: z.string().regex(CONTRACT_ID_REGEX, CONTRACT_ID_MESSAGE), + wasmHash: z.string().regex(WASM_HASH_REGEX, WASM_HASH_MESSAGE), deployedAt: z.string().datetime(), sourcePath: z.string().min(1), wasmPath: z.string().min(1), diff --git a/packages/core/src/artifacts/artifacts-lock.test.ts b/packages/core/src/artifacts/artifacts-lock.test.ts index ef855af6..8dd366a9 100644 --- a/packages/core/src/artifacts/artifacts-lock.test.ts +++ b/packages/core/src/artifacts/artifacts-lock.test.ts @@ -18,7 +18,7 @@ afterEach(async () => { function contractRecord(contractId: string) { return { contractId, - wasmHash: "hash", + wasmHash: "a".repeat(64), deployedAt: "2026-06-25T00:00:00.000Z", sourcePath: "./contracts/x", wasmPath: "./target/x.wasm", @@ -43,11 +43,13 @@ describe("withArtifactsLock", () => { return writeArtifacts(next, cwd); }); - await Promise.all([deploy("alpha", "CALPHA"), deploy("beta", "CBETA")]); + const alphaId = "C".padEnd(56, "A"); + const betaId = "C".padEnd(56, "B"); + await Promise.all([deploy("alpha", alphaId), deploy("beta", betaId)]); const artifacts = await readArtifacts(cwd); - expect(artifacts.networks.testnet?.contracts.alpha?.contractId).toBe("CALPHA"); - expect(artifacts.networks.testnet?.contracts.beta?.contractId).toBe("CBETA"); + expect(artifacts.networks.testnet?.contracts.alpha?.contractId).toBe(alphaId); + expect(artifacts.networks.testnet?.contracts.beta?.contractId).toBe(betaId); }); it("releases the lock when the callback throws", async () => { diff --git a/packages/core/src/artifacts/project-status.test.ts b/packages/core/src/artifacts/project-status.test.ts index da78158c..f8b1ad5e 100644 --- a/packages/core/src/artifacts/project-status.test.ts +++ b/packages/core/src/artifacts/project-status.test.ts @@ -46,7 +46,7 @@ async function writeDeployedCounter(tmpDir: string): Promise { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-06-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -87,7 +87,7 @@ describe("collectProjectStatus", () => { expect(counter).toMatchObject({ deployed: true, contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), }); const token = testnet.contracts.find((entry) => entry.name === "token"); @@ -108,7 +108,7 @@ describe("collectProjectStatus", () => { await writeBindingMarker(outputDir, { version: 1, contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), network: "testnet", generatedAt: "2026-06-11T12:00:00.000Z", }); diff --git a/packages/core/src/artifacts/read-write-artifacts.test.ts b/packages/core/src/artifacts/read-write-artifacts.test.ts index c6e26d44..c8807c50 100644 --- a/packages/core/src/artifacts/read-write-artifacts.test.ts +++ b/packages/core/src/artifacts/read-write-artifacts.test.ts @@ -75,7 +75,7 @@ describe("writeArtifacts and readArtifacts", () => { contracts: { token: { contractId: "C".padEnd(56, "A"), - wasmHash: "hash-token", + wasmHash: "a".repeat(64), deployedAt: "2026-05-12T00:00:00.000Z", sourcePath: "./contracts/token", wasmPath: "./contracts/token.wasm", @@ -83,7 +83,7 @@ describe("writeArtifacts and readArtifacts", () => { }, marketplace: { contractId: "C".padEnd(56, "B"), - wasmHash: "hash-marketplace", + wasmHash: "b".repeat(64), deployedAt: "2026-05-12T00:00:00.000Z", sourcePath: "./contracts/marketplace", wasmPath: "./contracts/marketplace.wasm", diff --git a/packages/core/src/compat/fixtures/artifacts-v1.json b/packages/core/src/compat/fixtures/artifacts-v1.json index 488b0e9c..d6807d5e 100644 --- a/packages/core/src/compat/fixtures/artifacts-v1.json +++ b/packages/core/src/compat/fixtures/artifacts-v1.json @@ -6,7 +6,7 @@ "contracts": { "counter": { "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "wasmHash": "abc123hash", + "wasmHash": "3f786850e387550fdab836ed7e6dc881de23001b3f786850e387550fdab836ed", "deployedAt": "2025-06-01T12:00:00.000Z", "sourcePath": "./contracts/counter", "wasmPath": "./contracts/counter.wasm", diff --git a/packages/core/src/compat/fixtures/artifacts-v2-full-metadata.json b/packages/core/src/compat/fixtures/artifacts-v2-full-metadata.json index 29bf292c..74b3607d 100644 --- a/packages/core/src/compat/fixtures/artifacts-v2-full-metadata.json +++ b/packages/core/src/compat/fixtures/artifacts-v2-full-metadata.json @@ -6,7 +6,7 @@ "contracts": { "token": { "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", - "wasmHash": "hash-token-v2", + "wasmHash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", "deployedAt": "2026-05-12T00:00:00.000Z", "sourcePath": "./contracts/token", "wasmPath": "./contracts/token.wasm", @@ -22,7 +22,7 @@ }, "vault": { "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC5", - "wasmHash": "hash-vault-v2", + "wasmHash": "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", "deployedAt": "2026-05-12T00:01:00.000Z", "sourcePath": "./contracts/vault", "wasmPath": "./contracts/vault.wasm", diff --git a/packages/core/src/contracts/estimate-deploy-cost.test.ts b/packages/core/src/contracts/estimate-deploy-cost.test.ts index fe707971..f96ec69d 100644 --- a/packages/core/src/contracts/estimate-deploy-cost.test.ts +++ b/packages/core/src/contracts/estimate-deploy-cost.test.ts @@ -50,7 +50,7 @@ describe("estimateDeployCost", () => { const artifacts = createInitialArtifacts("app", { networks: ["testnet"] }); artifacts.networks.testnet!.contracts.counter = { contractId: `C${"A".repeat(55)}`, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-06-21T00:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./contracts/counter/target/wasm32v1-none/release/counter.wasm", diff --git a/packages/core/src/contracts/generate-bindings-graph.test.ts b/packages/core/src/contracts/generate-bindings-graph.test.ts index 64825e6d..efa5d3e6 100644 --- a/packages/core/src/contracts/generate-bindings-graph.test.ts +++ b/packages/core/src/contracts/generate-bindings-graph.test.ts @@ -51,7 +51,7 @@ const baseConfig: CaatingaConfig = { function deployedArtifact(contractId: string) { return { contractId, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/x", wasmPath: "./rel/x.wasm", diff --git a/packages/core/src/contracts/generate-bindings.test.ts b/packages/core/src/contracts/generate-bindings.test.ts index 8af01740..2343da1f 100644 --- a/packages/core/src/contracts/generate-bindings.test.ts +++ b/packages/core/src/contracts/generate-bindings.test.ts @@ -101,7 +101,7 @@ describe("generateBindings", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -126,7 +126,7 @@ describe("generateBindings", () => { expect(result.marker).toMatchObject({ version: 1, contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), network: "testnet", }); await expect(readBindingMarker(result.outputDir)).resolves.toEqual(result.marker); @@ -166,7 +166,7 @@ describe("generateBindings", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -215,7 +215,7 @@ describe("generateBindings", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -251,7 +251,7 @@ describe("generateBindings", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -294,7 +294,7 @@ describe("generateBindings", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", diff --git a/packages/core/src/contracts/invoke-contract.test.ts b/packages/core/src/contracts/invoke-contract.test.ts index 295f9484..853e7da9 100644 --- a/packages/core/src/contracts/invoke-contract.test.ts +++ b/packages/core/src/contracts/invoke-contract.test.ts @@ -81,7 +81,7 @@ describe("invokeContract", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -131,7 +131,7 @@ describe("invokeContract", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -173,7 +173,7 @@ describe("invokeContract", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -218,7 +218,7 @@ describe("invokeContract", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", @@ -264,7 +264,7 @@ describe("invokeContract", () => { contracts: { counter: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", diff --git a/packages/core/src/contracts/read-contract.test.ts b/packages/core/src/contracts/read-contract.test.ts index 68d81614..3fb958b3 100644 --- a/packages/core/src/contracts/read-contract.test.ts +++ b/packages/core/src/contracts/read-contract.test.ts @@ -56,7 +56,7 @@ describe("readContract", () => { contracts: { app: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/app", wasmPath: "./rel/app.wasm", @@ -104,7 +104,7 @@ describe("readContract", () => { contracts: { app: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/app", wasmPath: "./rel/app.wasm", @@ -147,7 +147,7 @@ describe("readContract", () => { contracts: { app: { contractId: CONTRACT_ID, - wasmHash: "abc", + wasmHash: "a".repeat(64), deployedAt: "2026-05-11T12:00:00.000Z", sourcePath: "./contracts/app", wasmPath: "./rel/app.wasm", diff --git a/packages/core/src/contracts/resolve-method-args.ts b/packages/core/src/contracts/resolve-method-args.ts index c6d1d618..367f1c60 100644 --- a/packages/core/src/contracts/resolve-method-args.ts +++ b/packages/core/src/contracts/resolve-method-args.ts @@ -1,10 +1,9 @@ import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; +import { STELLAR_ADDRESS_REGEX } from "../stellar-cli/strkey.js"; import { formatNamedCliArgs } from "./format-cli-args.js"; import { resolveSourceAddress } from "./resolve-source-address.js"; import type { DeployArgValue } from "./resolve-deploy-args.js"; -const STELLAR_ADDRESS_REGEX = /^G[A-Z2-7]{55}$/; - export type ResolveMethodArgsOptions = { args: Record; source?: string; diff --git a/packages/core/src/contracts/resolve-source-address.ts b/packages/core/src/contracts/resolve-source-address.ts index bb42c762..e577128f 100644 --- a/packages/core/src/contracts/resolve-source-address.ts +++ b/packages/core/src/contracts/resolve-source-address.ts @@ -1,10 +1,9 @@ import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; import { checkBinary } from "../shell/check-binary.js"; import { runCommand } from "../shell/run-command.js"; +import { STELLAR_ADDRESS_REGEX } from "../stellar-cli/strkey.js"; import { assertSafeSourceAccount } from "./source-account.js"; -const STELLAR_ADDRESS_REGEX = /^G[A-Z2-7]{55}$/; - export async function resolveSourceAddress(options: { source: string; cwd?: string; diff --git a/packages/core/src/contracts/run-post-deploy.test.ts b/packages/core/src/contracts/run-post-deploy.test.ts index 0f6d0413..d109bc91 100644 --- a/packages/core/src/contracts/run-post-deploy.test.ts +++ b/packages/core/src/contracts/run-post-deploy.test.ts @@ -64,7 +64,7 @@ describe("runPostDeployHooks", () => { "coin", { contractId: CONTRACT_ID, - wasmHash: "abc123", + wasmHash: "a".repeat(64), deployedAt: new Date().toISOString(), sourcePath: "./contracts/coin", wasmPath: "./rel/coin.wasm", diff --git a/packages/core/src/frontend/evaluate-env-drift.test.ts b/packages/core/src/frontend/evaluate-env-drift.test.ts index 0703ee5c..30134fc2 100644 --- a/packages/core/src/frontend/evaluate-env-drift.test.ts +++ b/packages/core/src/frontend/evaluate-env-drift.test.ts @@ -51,8 +51,8 @@ describe("evaluateEnvDrift", () => { testnet: { contracts: { counter: { - contractId: "CCOUNTERCONTRACTID", - wasmHash: "abc123", + contractId: `C${"C".repeat(55)}`, + wasmHash: "a".repeat(64), deployedAt: "2026-07-04T00:00:00.000Z", sourcePath: "./contracts/counter", wasmPath: "./rel/counter.wasm", diff --git a/packages/core/src/stellar-cli/parse-contract-id.ts b/packages/core/src/stellar-cli/parse-contract-id.ts index e22bce1f..68260aaa 100644 --- a/packages/core/src/stellar-cli/parse-contract-id.ts +++ b/packages/core/src/stellar-cli/parse-contract-id.ts @@ -1,16 +1,19 @@ import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; +import { STRKEY_BODY } from "./strkey.js"; /** * Stellar strkeys are RFC4648 base32, so the alphabet is A-Z2-7 — digits 0, 1, 8 - * and 9 never appear. Matches the source-account check in recover-deploy-contract-id. + * and 9 never appear. Built from the shared {@link STRKEY_BODY} so the scan stays + * in sync with the anchored contract-strkey check in the artifacts schema, while + * keeping the unanchored word-boundary semantics this stdout scan needs. */ -const CONTRACT_ID_REGEX_GLOBAL = /\bC[A-Z2-7]{55}\b/g; +const CONTRACT_ID_REGEX_GLOBAL = new RegExp(`\\bC${STRKEY_BODY}\\b`, "g"); /** e.g. `Contract ID: C...`, `contract_id = "C..."` */ const LABELED_LINE_REGEX = /contract[\s_-]*id\s*[:=]/i; /** A line holding nothing but the ID, optionally quoted — the CLI's own stdout shape. */ -const STANDALONE_LINE_REGEX = /^\s*["']?(C[A-Z2-7]{55})["']?\s*$/; +const STANDALONE_LINE_REGEX = new RegExp(`^\\s*["']?(C${STRKEY_BODY})["']?\\s*$`); function lastMatch(line: string): string | undefined { const matches = line.match(CONTRACT_ID_REGEX_GLOBAL); diff --git a/packages/core/src/stellar-cli/recover-deploy-contract-id.ts b/packages/core/src/stellar-cli/recover-deploy-contract-id.ts index 61a98d3e..312c7326 100644 --- a/packages/core/src/stellar-cli/recover-deploy-contract-id.ts +++ b/packages/core/src/stellar-cli/recover-deploy-contract-id.ts @@ -4,6 +4,7 @@ import { NETWORK_METADATA_BY_PASSPHRASE } from "../networks/network-metadata.js" import { runCommand } from "../shell/run-command.js"; import { buildStellarNetworkArgsFromConfig } from "./build-stellar-network-args.js"; import { parseContractId } from "./parse-contract-id.js"; +import { STELLAR_ADDRESS_REGEX } from "./strkey.js"; const TX_HASH_REGEX = /Transaction hash is ([a-f0-9]{64})/i; @@ -25,7 +26,7 @@ type HorizonOperationsResponse = { }; export function isLikelyPublicKeySource(source: string): boolean { - return /^G[A-Z2-7]{55}$/.test(source); + return STELLAR_ADDRESS_REGEX.test(source); } export function decimalSaltToHex(salt: string): string { diff --git a/packages/core/src/stellar-cli/strkey.ts b/packages/core/src/stellar-cli/strkey.ts new file mode 100644 index 00000000..3e2a5d4c --- /dev/null +++ b/packages/core/src/stellar-cli/strkey.ts @@ -0,0 +1,29 @@ +/** + * Shared Stellar strkey / wasm-hash patterns — the single source of truth so the + * artifacts schema and the CLI parsers cannot drift apart. + * + * Stellar strkeys are RFC4648 base32, so the alphabet is A-Z2-7 — the digits 0, + * 1, 8 and 9 never appear. A contract strkey is a `C` prefix plus 55 base32 + * characters; an account (source) strkey is a `G` prefix plus 55 base32 + * characters. `hashWasm` (and the Stellar CLI) emit the wasm hash as 64 + * lowercase hex characters. + * + * These values cross a trust boundary: the contractId / wasmHash read from + * caatinga.artifacts.json flow directly into signed Stellar CLI transactions + * (invoke / upgrade / rollback) and into generated frontend config. A wrong, + * truncated or malicious key that only passed a non-emptiness check would be + * signed against or shipped to the frontend, so keeping the patterns here in one + * place stops any spot from silently accepting a malformed key. + */ + +/** Base32 strkey body shared by every strkey type: 55 chars after the type prefix. */ +export const STRKEY_BODY = "[A-Z2-7]{55}"; + +/** Anchored contract strkey (`C` prefix), e.g. `caatinga.artifacts.json` contractId. */ +export const CONTRACT_ID_REGEX = new RegExp(`^C${STRKEY_BODY}$`); + +/** Anchored account (source) strkey (`G` prefix), e.g. a deploy source address. */ +export const STELLAR_ADDRESS_REGEX = new RegExp(`^G${STRKEY_BODY}$`); + +/** wasm hash as emitted by `hashWasm` / the Stellar CLI: 64 lowercase hex chars. */ +export const WASM_HASH_REGEX = /^[a-f0-9]{64}$/;