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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,21 @@ for a client without the wallet handle.
> so a policy-governed payment needs a facilitator configured with a higher
> ceiling (self-hosted, or a hosted one that allows it).

### Network Policy

The SDK uses the `Network` type (`"testnet"` or `"mainnet"`) to ensure operations
happen on the correct Stellar network. The `WalletConnector` and related components
enforce network matching:

- **`createWallet(network)`** and **`connectWallet(network)`** require the network
to match the connector's configured network. A mismatch throws
`WalletNetworkMismatchError`.
- **`signTransaction(input)`** also validates the network before signing.
- The network is a fundamental part of the wallet's identity — changing networks
requires re-deploying the smart account or using a different key.

This policy ensures callers operate on the intended network and prevents
accidental mainnet deployment from a testnet-configured wallet, or vice versa.
#### Security: signed requests to the facilitator

The payment payload itself is already signed (the smart-wallet auth entry).
Expand Down
35 changes: 35 additions & 0 deletions src/passkeykit-connector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,41 @@ describe("connectWallet", () => {
});
});

describe("network handling", () => {
function kitWithMismatch() {
const kit = fakeKit();
return { kit, connector: createPasskeyKitConnector({
kit,
backend: fakeBackend(),
network: "testnet",
appName: "Vellar",
now: () => new Date("2026-07-16T15:00:00.000Z"),
}) };
}

it("rejects createWallet with network mismatch", async () => {
const { kit, connector } = kitWithMismatch();
await expect(connector.createWallet({ network: "mainnet" })).rejects.toBeInstanceOf(
WalletNetworkMismatchError,
);
expect(kit.createWallet).not.toHaveBeenCalled();
});

it("rejects connectWallet with network mismatch", async () => {
await expect(connector().connectWallet("mainnet")).rejects.toBeInstanceOf(
WalletNetworkMismatchError,
);
});

it("rejects signTransaction with network mismatch", async () => {
const { kit, connector } = kitWithMismatch();
await expect(
connector.signTransaction({ xdr: "tx-xdr", network: "mainnet" }),
).rejects.toBeInstanceOf(WalletNetworkMismatchError);
expect(kit.sign).not.toHaveBeenCalled();
});
});

describe("signTransaction", () => {
it("signs and returns the XDR", async () => {
const kit = fakeKit();
Expand Down
36 changes: 36 additions & 0 deletions src/passkeykit-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,42 @@ export class PasskeyBrowserRequiredError extends Error {
}
}

/** Rate limit error thrown when too many authentication attempts are made. */
export class RateLimitError extends Error {
constructor(attempt: number, maxAttempts: number) {
super(
`Rate limit exceeded: ${attempt} authentication attempt${attempt !== 1 ? "s" : ""} made, maximum is ${maxAttempts}`,
);
this.name = "RateLimitError";
}
}

/** Maximum authentication attempts before rate limiting activates. */
const MAX_AUTH_ATTEMPTS = 5;

/** Tracks authentication attempt counts keyed on connector identifier. */
export const attemptCache = new Map<string, number>();

/** Resets the attempt counter for a given connector identifier. */
export function resetAttemptCount(key: string): void {
attemptCache.set(key, 0);
}

/** Increments and returns the current attempt count for a connector. */
export function incrementAttemptCount(key: string): number {
const current = (attemptCache.get(key) ?? 0) + 1;
attemptCache.set(key, current);
return current;
}

/** Checks if the connector has exceeded the rate limit for authentication attempts. */
export function checkRateLimit(key: string): void {
const count = incrementAttemptCount(key);
if (count > MAX_AUTH_ATTEMPTS) {
throw new RateLimitError(count, MAX_AUTH_ATTEMPTS);
}
}

/**
* Passkey ceremonies die deep inside the kit with a raw WebAuthnError when run
* outside a browser; this guard fails first, with the actionable message.
Expand Down
257 changes: 257 additions & 0 deletions src/policy-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { describe, expect, it } from "vitest";
import {
SpendingConstructor,
VerifiedRecipientConstructor,
Enforcement,
PolicyTemplateInfo,
ValidationResult,
GeneratedPolicy,
SimulateResult,
DeployPolicyResult,
enforcementLabel,
stroopsToXlm,
PolicyApiError,
} from "./policy-types";

function spending(dailyLimitStroops = "1000000", windowSeconds = 86400): SpendingConstructor {
return { dailyLimitStroops, windowSeconds };
}

function verifiedRecipient(registry = "registry.example"): VerifiedRecipientConstructor {
return { registry };
}

describe("SpendingConstructor", () => {
it("accepts valid dailyLimitStroops and windowSeconds", () => {
const s: SpendingConstructor = spending();
expect(s.dailyLimitStroops).toBe("1000000");
expect(s.windowSeconds).toBe(86400);
});

it("accepts custom dailyLimitStroops", () => {
const s: SpendingConstructor = { dailyLimitStroops: "5000000", windowSeconds: 3600 };
expect(s.dailyLimitStroops).toBe("5000000");
expect(s.windowSeconds).toBe(3600);
});

it("accepts windowSeconds of 0", () => {
const s: SpendingConstructor = { dailyLimitStroops: "100", windowSeconds: 0 };
expect(s.windowSeconds).toBe(0);
});

it("accepts large windowSeconds value", () => {
const s: SpendingConstructor = { dailyLimitStroops: "1000000", windowSeconds: 999999999 };
expect(s.windowSeconds).toBe(999999999);
});

it("accepts empty string dailyLimitStroops", () => {
const s: SpendingConstructor = { dailyLimitStroops: "", windowSeconds: 86400 };
expect(s.dailyLimitStroops).toBe("");
});
});

describe("VerifiedRecipientConstructor", () => {
it("accepts valid registry", () => {
const v: VerifiedRecipientConstructor = verifiedRecipient();
expect(v.registry).toBe("registry.example");
});

it("accepts custom registry", () => {
const v: VerifiedRecipientConstructor = { registry: "my-registry.stellar" };
expect(v.registry).toBe("my-registry.stellar");
});

it("accepts registry with subdomain", () => {
const v: VerifiedRecipientConstructor = { registry: "sub.registry.example" };
expect(v.registry).toBe("sub.registry.example");
});

it("accepts registry with numeric characters", () => {
const v: VerifiedRecipientConstructor = { registry: "registry123.example" };
expect(v.registry).toBe("registry123.example");
});
});

describe("Enforcement", () => {
it("accepts policy-contract kind", () => {
const e: Enforcement = { kind: "policy-contract", wasmHash: "abc123" };
expect(e.kind).toBe("policy-contract");
});

it("accepts policy-contract with constructorArgs", () => {
const e: Enforcement = {
kind: "policy-contract",
wasmHash: "abc123",
constructorArgs: spending(),
};
expect(e.constructorArgs?.dailyLimitStroops).toBe("1000000");
});

it("accepts signer-limits kind", () => {
const e: Enforcement = { kind: "signer-limits" };
expect(e.kind).toBe("signer-limits");
});

it("accepts none kind", () => {
const e: Enforcement = { kind: "none" };
expect(e.kind).toBe("none");
});

it("accepts custom-contract-pending kind", () => {
const e: Enforcement = { kind: "custom-contract-pending" };
expect(e.kind).toBe("custom-contract-pending");
});
});

describe("PolicyTemplateInfo", () => {
it("accepts valid template info", () => {
const t: PolicyTemplateInfo = {
type: "spending",
title: "Daily Spend Limit",
description: "A daily spending limit policy",
enforcement: { kind: "policy-contract", wasmHash: "abc123" },
};
expect(t.type).toBe("spending");
expect(t.title).toBe("Daily Spend Limit");
expect(t.description).toBe("A daily spending limit policy");
});
});

describe("ValidationResult", () => {
it("accepts valid result", () => {
const v: ValidationResult = { valid: true, errors: [] };
expect(v.valid).toBe(true);
expect(v.errors).toEqual([]);
});

it("accepts invalid result with errors", () => {
const v: ValidationResult = { valid: false, errors: ["err1", "err2"] };
expect(v.valid).toBe(false);
expect(v.errors).toEqual(["err1", "err2"]);
});
});

describe("GeneratedPolicy", () => {
it("accepts valid generated policy", () => {
const g: GeneratedPolicy = {
id: "p1",
createdAt: "2024-01-01T00:00:00Z",
status: "generated",
definition: { type: "spending", title: "Test", description: "Desc", enforcement: { kind: "none" } },
policyHash: "hash123",
manifest: { template: "spending", enforcement: { kind: "none" }, network: "testnet" },
};
expect(g.id).toBe("p1");
expect(g.status).toBe("generated");
});
});

describe("SimulateResult", () => {
it("accepts valid simulate result", () => {
const s: SimulateResult = { ok: true, minResourceFee: "100" };
expect(s.ok).toBe(true);
expect(s.minResourceFee).toBe("100");
});

it("accepts simulate result with error", () => {
const s: SimulateResult = { ok: false, error: "insufficient funds" };
expect(s.ok).toBe(false);
expect(s.error).toBe("insufficient funds");
});
});

describe("DeployPolicyResult", () => {
it("accepts valid deploy result", () => {
const d: DeployPolicyResult = {
policy: {
id: "p1",
createdAt: "2024-01-01T00:00:00Z",
status: "generated",
definition: { type: "spending", title: "Test", description: "Desc", enforcement: { kind: "none" } },
policyHash: "hash123",
manifest: { template: "spending", enforcement: { kind: "none" }, network: "testnet" },
},
contractId: "C123",
attachTxHash: "tx123",
};
expect(d.policy.id).toBe("p1");
expect(d.contractId).toBe("C123");
expect(d.attachTxHash).toBe("tx123");
});
});

describe("enforcementLabel", () => {
it("returns correct label for policy-contract", () => {
const label = enforcementLabel({ kind: "policy-contract", wasmHash: "abc" });
expect(label).toContain("Enforced on-chain");
});

it("returns correct label for signer-limits", () => {
const label = enforcementLabel({ kind: "signer-limits" });
expect(label).toContain("Enforced by the smart wallet's native signer limits");
});

it("returns correct label for none", () => {
const label = enforcementLabel({ kind: "none" });
expect(label).toContain("Default single-owner behaviour");
});

it("returns correct label for custom-contract-pending", () => {
const label = enforcementLabel({ kind: "custom-contract-pending" });
expect(label).toContain("Requires a custom policy contract");
});
});

describe("stroopsToXlm", () => {
it("formats stroops as XLM string", () => {
expect(stroopsToXlm("1000000000")).toBe("100");
});

it("formats stroops with fractional part", () => {
expect(stroopsToXlm("1000000500")).toBe("100.00005");
});

it("formats round stroops without fractional part", () => {
expect(stroopsToXlm("100000000")).toBe("10");
});
});

describe("PolicyApiError", () => {
it("creates error with retryable true for 5xx", () => {
const e = new PolicyApiError("msg", 500);
expect(e.status).toBe(500);
expect(e.retryable).toBe(true);
expect(e.errors).toBeUndefined();
});

it("creates error with retryable true for 408", () => {
const e = new PolicyApiError("msg", 408);
expect(e.retryable).toBe(true);
});

it("creates error with retryable true for 429", () => {
const e = new PolicyApiError("msg", 429);
expect(e.retryable).toBe(true);
});

it("creates error with retryable false for 400", () => {
const e = new PolicyApiError("msg", 400);
expect(e.retryable).toBe(false);
});

it("creates error with retryable false for 200", () => {
const e = new PolicyApiError("msg", 200);
expect(e.retryable).toBe(false);
});

it("includes errors array when provided", () => {
const e = new PolicyApiError("msg", 422, ["mismatch"]);
expect(e.errors).toEqual(["mismatch"]);
});

it("includes status and message", () => {
const e = new PolicyApiError("deploy failed", 403);
expect(e.message).toBe("deploy failed");
expect(e.status).toBe(403);
});
});
Loading
Loading