@oceanalt/crossmint 0.0.0-stage → 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OceanAlt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # @oceanalt/crossmint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An OceanAlt check for [Crossmint](https://www.crossmint.com) wallets (`@crossmint/wallets-sdk`, EVM chains), run before Crossmint creates or approves a transfer or signature. Each check returns one of three results:
4
+
5
+ | Result | Meaning |
6
+ |---|---|
7
+ | `allow` | No blocking findings. It's a screening result over public data, not a guarantee. |
8
+ | `review` | A human has to agree first. OceanAlt returned `review` or an `uncertain` verdict, the check couldn't run, or the guard couldn't read what is being signed. |
9
+ | `decline` | Don't sign. |
10
+
11
+ If a check can't run (network error, timeout, HTTP error, unrecognised answer), the result is `review`. It is never `allow`. The package holds no keys and moves no funds. Not affiliated with Crossmint.
12
+
13
+ Tested with `@crossmint/wallets-sdk` 1.20.0 and viem 2.56.9, including a real `EVMWallet` built on a stubbed API client.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { createCrossmint, CrossmintWallets, EVMWallet } from "@crossmint/wallets-sdk";
19
+ import { guardCrossmintWallet } from "@oceanalt/crossmint";
20
+
21
+ const wallets = CrossmintWallets.from(createCrossmint({ apiKey }));
22
+ const wallet = guardCrossmintWallet(EVMWallet.from(await wallets.getWallet(walletLocator, { chain: "base" })), {
23
+ onReview: async (result, request) => askAHuman(result, request), // optional; return true only after a human agreed
24
+ });
25
+
26
+ await wallet.send(payeeAddress, "usdc", "2.50"); // payee and amount are screened first
27
+ ```
28
+
29
+ `guardCrossmintWallet` changes the wallet object in place and returns it. The checked methods can't be reassigned.
30
+
31
+ - `decline`: throws `OceanAltDeclinedError` (`code: "OCEANALT_DECLINED"`, `result` holds the OceanAlt answer). Crossmint is not called.
32
+ - `review`: calls `onReview(result, request)`. Crossmint is called only if that returns exactly `true`. Without `onReview`, or if it throws or returns anything else, the guard throws `OceanAltReviewRequiredError` (`code: "OCEANALT_REVIEW_REQUIRED"`).
33
+ - `allow`: the original method runs with the same arguments.
34
+
35
+ ## What is checked
36
+
37
+ | Method | What the guard does |
38
+ |---|---|
39
+ | `send(to, token, amount, options?)` | Screens `to`. The amount is sent as `amountUsdc` when `token` is `"usdc"`, `"<chain>:usdc"` or the USDC contract on the wallet's chain. A user locator (email, phone, userId, social handle) can't be screened, so it is `review`. |
40
+ | `sendTransaction({ to, data \| abi + functionName + args, value })` | Decodes the call. Native transfer: screens `to`. ERC-20 `transfer` / `transferFrom`: screens the recipient in the calldata. `approve` / `increaseAllowance` / `permit` / `setApprovalForAll`: screens the spender. |
41
+ | `sendTransaction({ transaction })` | Parses the serialized transaction with viem, then decodes it as above. |
42
+ | `signTypedData({ domain, types, primaryType, message, chain })` | EIP-3009 `TransferWithAuthorization` / `ReceiveWithAuthorization` (the x402 `exact` scheme): screens `message.to`. EIP-2612 `Permit` and Permit2: screens the spender. A domain `chainId` that differs from `chain` is `review`. |
43
+ | `signMessage` | `review` by default (not decoded). `signMessage: "allow"` lets it through. |
44
+ | `addSigner`, `addRecoveryMethod` | Always `review`. Another signer gets control of the wallet. |
45
+ | `approve`, `approveTransaction` | Pass only for a `transactionId` / `signatureId` that this guard prepared (`prepareOnly`) after checking it. Any other id is `review`. |
46
+
47
+ The network is sent as `eip155:<chainId>`, from the wallet's chain name (see `CHAIN_IDS`; pass `chainId` for a chain not listed). An unlimited approval that OceanAlt allows is turned into `review` (`reason_code: "approval.unlimited"`). A contract call that is not a transfer or approval is `review` (`calldata.unrecognised`); with `unknownCalls: "screen-target"` the contract address is screened instead.
48
+
49
+ ## Options
50
+
51
+ `onReview(result, request)`, `onResult(result, request)` (observer, errors ignored), `purpose` (sent to OceanAlt), `chainId`, `signMessage` (`"review"` | `"allow"`), `unknownCalls` (`"review"` | `"screen-target"`), plus `baseUrl`, `fetch`, `apiKey` (optional; sent as `x-api-key`, only to the OceanAlt host), `timeoutMs` (default 10000), `client` (an `OceanAltClient` from `@oceanalt/core`). `request.results` holds one result per decoded call, each with a `decoded` field.
52
+
53
+ `guardViemAccount(account, opts)` is also exported, for a viem signer you use next to the Crossmint wallet. `checkEndpoint(client, url)` and `decidePayment(client, { to, amountUsdc, network, purpose })` are exported for checks outside the wallet.
54
+
55
+ ## Limits
56
+
57
+ - Only the wallet object you pass is guarded. `EVMWallet.from(wallet)`, `getWallet()` and `createWallet()` return new, unguarded objects, so guard the one you hand to your agent.
58
+ - Crossmint's REST API, the dashboard, other SDK instances, and operations created outside this guard are not checked. Approving them through the guarded wallet is `review`.
59
+ - EVM wallets only. Solana and Stellar wallets are rejected with a `TypeError`.
60
+ - `"usdxm"` (Crossmint's test token) and other tokens are screened without an amount.
61
+ - Calldata is read for ERC-20 transfer, transferFrom, approve, increaseAllowance, permit and setApprovalForAll. Calls through routers or multicall contracts are not unpacked; they count as unknown contract calls.
62
+ - Fail closed means an OceanAlt outage stops payments unless your `onReview` lets them through.
63
+ - Crossmint signer scopes and policies still apply on Crossmint's side. This guard runs in your process before the request is sent; it doesn't replace them.
64
+ - The allow path was tested against a real `EVMWallet` with `prepareOnly` and a stubbed API client. A full send with a live signer was not run.
65
+ - OceanAlt screens the payee address against public data. It doesn't check whether the seller delivers, and an `allow` is not a guarantee.
66
+
67
+ ## Data sent to OceanAlt
68
+
69
+ The decoded payee or spender address, and when known, the USDC amount, `eip155:<chainId>` network and your `purpose` (cut to 120 characters). Wallet locators, user locators, signatures and Crossmint API keys are not sent. There's no telemetry.
70
+
71
+ ## Requirements
72
+
73
+ Node 20+, ESM. Peer dependencies: `@crossmint/wallets-sdk` 1.20+ and `viem` 2 (used for ABI-encoded and serialized transactions). Depends on `@oceanalt/core` ^0.2.0.
74
+
75
+ ## Tests
76
+
77
+ `npm test` runs the unit tests with a mocked fetch (no network), including a real `EVMWallet` on a stubbed API client.
78
+
79
+ MIT licensed.
package/index.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { WalletGuardOptions, SigningRequest } from "./lib/wallet-guard.mjs";
2
+
3
+ export { createClient, decidePayment, checkEndpoint, parseUsdc, DECISION, OceanAltDeclinedError, OceanAltReviewRequiredError } from "./lib/decide.mjs";
4
+ export type { Decision, ClientOptions, PaymentIntent, PayeeResult, EndpointResult } from "./lib/decide.mjs";
5
+ export { decodeTransaction, decodeTypedData } from "./lib/evm.mjs";
6
+ export type { Decoded, DecodedKind } from "./lib/evm.mjs";
7
+ export type { WalletGuardOptions, SigningRequest } from "./lib/wallet-guard.mjs";
8
+
9
+ export declare const CHAIN_IDS: Readonly<Record<string, number>>;
10
+
11
+ export type CrossmintGuardOptions = Omit<WalletGuardOptions, "nonEvm"> & {
12
+ /** Chain id to use when the wallet's chain name is not in CHAIN_IDS. */
13
+ chainId?: number | string;
14
+ };
15
+
16
+ /** The part of a Crossmint Wallet / EVMWallet the guard touches. */
17
+ export type CrossmintWalletLike = {
18
+ chain: string;
19
+ address: string;
20
+ send(...args: any[]): Promise<any>;
21
+ sendTransaction?(params: any): Promise<any>;
22
+ signTypedData?(params: any): Promise<any>;
23
+ signMessage?(params: any): Promise<any>;
24
+ approve?(params: any): Promise<any>;
25
+ approveTransaction?(params: any): Promise<any>;
26
+ addSigner?(...args: any[]): Promise<any>;
27
+ addRecoveryMethod?(...args: any[]): Promise<any>;
28
+ };
29
+
30
+ /**
31
+ * Guard one Crossmint Wallet or EVMWallet (EVM chains only) in place and return it. send, sendTransaction,
32
+ * signTypedData, signMessage, addSigner and addRecoveryMethod are checked before Crossmint is called; approve and
33
+ * approveTransaction pass only for ids prepared through this guard.
34
+ */
35
+ export declare function guardCrossmintWallet<W extends CrossmintWalletLike>(wallet: W, opts?: CrossmintGuardOptions): W;
36
+ export declare function isGuarded(wallet: unknown): boolean;
37
+ export declare function isUsdcToken(token: string, chainId: number | null | undefined): boolean;
38
+ /** Turn a Crossmint wallet call into the request the guard checks. */
39
+ export declare function crossmintRequest(method: string, args: unknown[], chainId: number | null): Promise<SigningRequest>;
40
+ /** Guard a viem LocalAccount. Returns a new account; the original stays unguarded. */
41
+ export declare function guardViemAccount<A extends { address: string }>(account: A, opts?: WalletGuardOptions): A;
package/index.mjs ADDED
@@ -0,0 +1,123 @@
1
+ // @oceanalt/crossmint: OceanAlt pre-payment checks for Crossmint wallets (@crossmint/wallets-sdk, EVM).
2
+ //
3
+ // guardCrossmintWallet(wallet, opts) checks send / sendTransaction / signTypedData / signMessage / approve on one
4
+ // Wallet or EVMWallet instance before Crossmint creates or approves the operation
5
+ //
6
+ // Fail closed: a network error, timeout, HTTP error, unrecognised answer, `uncertain` verdict, or a request this
7
+ // guard can't decode is "review", never "allow". Not affiliated with or endorsed by Crossmint.
8
+ import { createGate, defineGuarded, guardViemAccount as guardAccount } from "./lib/wallet-guard.mjs";
9
+ import { isAddress, isUsdc, calldataOf, parseSerialized, chainIdOf } from "./lib/evm.mjs";
10
+
11
+ export { createClient, decidePayment, checkEndpoint, parseUsdc, DECISION, OceanAltDeclinedError, OceanAltReviewRequiredError } from "./lib/decide.mjs";
12
+ export { decodeTransaction, decodeTypedData } from "./lib/evm.mjs";
13
+
14
+ const UA = "oceanalt-crossmint/0.1.0";
15
+ const GUARDED = Symbol.for("oceanalt.crossmint.guarded");
16
+
17
+ /** Crossmint EVM chain names → chain ids (the ones OceanAlt screens, plus common testnets). Others: pass opts.chainId. */
18
+ export const CHAIN_IDS = Object.freeze({
19
+ ethereum: 1, "ethereum-sepolia": 11155111, base: 8453, "base-sepolia": 84532, polygon: 137, "polygon-amoy": 80002,
20
+ arbitrum: 42161, "arbitrum-sepolia": 421614, optimism: 10, "optimism-sepolia": 11155420, avalanche: 43114,
21
+ "avalanche-fuji": 43113, bsc: 56, arc: 5042, "arc-testnet": 5042002,
22
+ });
23
+
24
+ const lc = (v) => (typeof v === "string" ? v.toLowerCase() : v);
25
+
26
+ /** Is the token argument of wallet.send() Circle USDC? Accepts "usdc", "<chain>:usdc", or the USDC address on that chain. */
27
+ export function isUsdcToken(token, chainId) {
28
+ const t = lc(String(token || ""));
29
+ if (t === "usdc" || t.endsWith(":usdc")) return true;
30
+ const addr = t.includes(":") ? t.split(":").pop() : t;
31
+ return isAddress(addr) && isUsdc(chainId, addr);
32
+ }
33
+
34
+ /** Turn a Crossmint wallet call into the request the guard checks. */
35
+ export async function crossmintRequest(method, args, chainId) {
36
+ const base = { provider: "crossmint", method, chainId, input: args };
37
+ switch (method) {
38
+ case "send": {
39
+ const [to, token, amount] = args;
40
+ if (typeof to !== "string" || !isAddress(to)) {
41
+ return { ...base, kind: "payment", payment: { to: null }, detail: "send() to a user locator (email, phone, userId, social handle) can't be screened; only 0x addresses can." };
42
+ }
43
+ return { ...base, kind: "payment", payment: { to, amountUsdc: isUsdcToken(token, chainId) ? amount : undefined } };
44
+ }
45
+ case "sendTransaction": {
46
+ const p = args[0] || {};
47
+ if (typeof p.transaction === "string") {
48
+ const r = await parseSerialized(p.transaction);
49
+ if (r.error) return { ...base, kind: "raw", reason_code: "calldata.undecodable", detail: `Could not decode the serialized transaction: ${r.error}.` };
50
+ return { ...base, kind: "transaction", chainId: r.tx.chainId ?? chainId, calls: [r.tx] };
51
+ }
52
+ const c = await calldataOf(p);
53
+ if (c.error) return { ...base, kind: "raw", reason_code: "calldata.undecodable", detail: c.error };
54
+ return { ...base, kind: "transaction", calls: [{ to: p.to, data: c.data, value: p.value }] };
55
+ }
56
+ case "signTypedData": {
57
+ const p = args[0] || {};
58
+ return { ...base, kind: "typed-data", chainId: CHAIN_IDS[p.chain] ?? chainId, typedData: { domain: p.domain, types: p.types, primaryType: p.primaryType, message: p.message } };
59
+ }
60
+ case "signMessage":
61
+ return { ...base, kind: "message" };
62
+ case "addSigner":
63
+ case "addRecoveryMethod":
64
+ return { ...base, kind: "raw", reason_code: "wallet.add_signer", detail: `${method}() gives another key control over this wallet.` };
65
+ default:
66
+ return { ...base, kind: "raw", reason_code: "method.unsupported", detail: `${method} is not decoded.` };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Guard one Crossmint Wallet or EVMWallet instance in place and return it.
72
+ * Checked: send(to, token, amount), sendTransaction({ to, data | abi+functionName+args, value } | { transaction }),
73
+ * signTypedData, signMessage, addSigner, addRecoveryMethod; approve / approveTransaction only pass for ids this guard
74
+ * prepared (prepareOnly) and checked itself.
75
+ * Options: { onReview?, onResult?, purpose?, chainId?, signMessage?: "review"|"allow", unknownCalls?: "review"|"screen-target",
76
+ * client?, baseUrl?, fetch?, apiKey?, timeoutMs? }
77
+ */
78
+ export function guardCrossmintWallet(wallet, opts = {}) {
79
+ if (!wallet || typeof wallet !== "object" || typeof wallet.send !== "function") throw new TypeError("guardCrossmintWallet(): expected a Wallet or EVMWallet from @crossmint/wallets-sdk");
80
+ if (wallet[GUARDED]) return wallet;
81
+ if (!isAddress(wallet.address) || wallet.chain === "solana" || wallet.chain === "stellar") {
82
+ throw new TypeError(`guardCrossmintWallet(): only EVM wallets are supported (chain '${wallet.chain}')`);
83
+ }
84
+ const chainId = chainIdOf(opts.chainId) ?? CHAIN_IDS[wallet.chain] ?? null;
85
+ const gate = createGate(opts, { userAgent: opts.userAgent || UA });
86
+ const prepared = new Set(); // transaction / signature ids created by a checked prepareOnly call
87
+ const remember = (out) => {
88
+ for (const id of [out?.transactionId, out?.signatureId]) if (typeof id === "string" && id) prepared.add(id);
89
+ return out;
90
+ };
91
+
92
+ const m = {};
93
+ for (const name of ["send", "sendTransaction", "signTypedData", "signMessage", "addSigner", "addRecoveryMethod"]) {
94
+ if (typeof wallet[name] !== "function") continue;
95
+ const orig = wallet[name].bind(wallet);
96
+ m[name] = async (...args) => {
97
+ await gate.check(await crossmintRequest(name, args, chainId));
98
+ return remember(await orig(...args));
99
+ };
100
+ }
101
+ for (const name of ["approve", "approveTransaction"]) {
102
+ if (typeof wallet[name] !== "function") continue;
103
+ const orig = wallet[name].bind(wallet);
104
+ m[name] = async (params = {}, ...rest) => {
105
+ const id = params?.transactionId ?? params?.signatureId;
106
+ const req = prepared.has(id)
107
+ ? { provider: "crossmint", method: name, chainId, input: params, kind: "pass" }
108
+ : { provider: "crossmint", method: name, chainId, input: params, kind: "raw", reason_code: "approval.unknown_id", detail: `Approving ${id ? `'${id}'` : "an operation"} that was not prepared and checked through this guard.` };
109
+ await gate.check(req);
110
+ return orig(params, ...rest);
111
+ };
112
+ }
113
+ defineGuarded(wallet, m);
114
+ Object.defineProperty(wallet, GUARDED, { value: { client: gate.client, chainId }, enumerable: false });
115
+ return wallet;
116
+ }
117
+
118
+ export function isGuarded(w) { return !!w?.[GUARDED]; }
119
+
120
+ /** Guard a viem account. Crossmint wallets are smart wallets, so this is for viem signers you use next to them. */
121
+ export function guardViemAccount(account, opts = {}) {
122
+ return guardAccount(account, { provider: "crossmint", userAgent: UA, ...opts });
123
+ }
@@ -0,0 +1,74 @@
1
+ import type { OceanAltClient } from "@oceanalt/core";
2
+
3
+ export type Decision = "allow" | "review" | "decline";
4
+ export declare const DECISION: Readonly<{ ALLOW: "allow"; REVIEW: "review"; DECLINE: "decline" }>;
5
+ export declare const DEFAULT_TIMEOUT_MS: number;
6
+
7
+ export type ClientOptions = {
8
+ baseUrl?: string;
9
+ fetch?: typeof fetch;
10
+ /** Optional. Sent as x-api-key, only to the OceanAlt host. The free endpoints work without it. */
11
+ apiKey?: string | null;
12
+ /** Per-request timeout. Default 10000. A timeout counts as "review". */
13
+ timeoutMs?: number;
14
+ userAgent?: string;
15
+ onRecord?: (r: unknown) => void;
16
+ };
17
+
18
+ /** What getPayment(args) returns: the payment the wrapped tool is about to make. */
19
+ export type PaymentIntent = { to: string; amountUsdc?: number | string; amount?: number | string; network?: string; purpose?: string };
20
+
21
+ export type PayeeResult = {
22
+ decision: Decision;
23
+ /** true only when decision is "allow". */
24
+ allow: boolean;
25
+ instruction: string;
26
+ detail: string;
27
+ to?: string;
28
+ verdict?: string | null;
29
+ reason_code: string | null;
30
+ retry?: unknown;
31
+ advice?: unknown;
32
+ signal_keys?: string[];
33
+ evidence?: unknown;
34
+ /** true when OceanAlt gave no usable answer (error, timeout, missing input, unrecognised or uncertain answer). */
35
+ failClosed?: boolean;
36
+ };
37
+
38
+ export type EndpointResult = {
39
+ decision: Decision;
40
+ allow: boolean;
41
+ instruction: string;
42
+ detail: string;
43
+ url?: string;
44
+ verdict?: string | null;
45
+ reason_code: string | null;
46
+ signals?: unknown;
47
+ failClosed?: boolean;
48
+ };
49
+
50
+ export declare function createClient(opts?: ClientOptions): OceanAltClient;
51
+ export declare function parseUsdc(v: unknown): number | undefined;
52
+ export declare function decidePayment(client: OceanAltClient, input: Partial<PaymentIntent>): Promise<PayeeResult>;
53
+ export declare function checkEndpoint(client: OceanAltClient, url: string): Promise<EndpointResult>;
54
+
55
+ export declare class OceanAltDeclinedError extends Error {
56
+ readonly code: "OCEANALT_DECLINED";
57
+ readonly result: PayeeResult;
58
+ }
59
+ export declare class OceanAltReviewRequiredError extends Error {
60
+ readonly code: "OCEANALT_REVIEW_REQUIRED";
61
+ readonly result: PayeeResult;
62
+ }
63
+
64
+ export type GuardOptions<A = any> = {
65
+ /** Return the payee and amount of the payment the wrapped tool is about to make, from its arguments. */
66
+ getPayment: (args: A) => PaymentIntent | null | undefined | Promise<PaymentIntent | null | undefined>;
67
+ /** Called on "review". Return exactly true (after a human agreed) to run the tool anyway. Default: refuse. */
68
+ onReview?: (result: PayeeResult, args: A) => boolean | Promise<boolean>;
69
+ /** Observer for every decision. Errors thrown here are ignored. */
70
+ onResult?: (result: PayeeResult, args: A) => void;
71
+ };
72
+
73
+ export declare function guardedRun<A, R>(opts: GuardOptions<A> & { client: OceanAltClient }, args: A, run: () => R | Promise<R>): Promise<R>;
74
+ export declare function assertGuardOptions(opts: unknown, who: string): void;
package/lib/decide.mjs ADDED
@@ -0,0 +1,149 @@
1
+ // Framework-free OceanAlt calls and the guard logic shared by @oceanalt/vercel-ai and @oceanalt/langchain
2
+ // (identical copies; keep in sync).
3
+ // Outcome vocabulary is OceanAlt's own: allow | review | decline. Fail closed: a network error, an HTTP error,
4
+ // a timeout, an unrecognised answer or an `uncertain` verdict is "review", never "allow".
5
+ import { OceanAltClient } from "@oceanalt/core";
6
+
7
+ export const DECISION = Object.freeze({ ALLOW: "allow", REVIEW: "review", DECLINE: "decline" });
8
+ export const DEFAULT_TIMEOUT_MS = 10_000;
9
+
10
+ /**
11
+ * An OceanAltClient whose fetch has a timeout and, when apiKey is set, sends x-api-key to the OceanAlt host only.
12
+ * Options: { baseUrl?, fetch?, apiKey?, timeoutMs?, userAgent?, onRecord? }
13
+ */
14
+ export function createClient({ baseUrl, fetch: f, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS, userAgent, onRecord } = {}) {
15
+ const raw = f || globalThis.fetch;
16
+ const host = new URL(baseUrl || "https://oceanalt.com").host;
17
+ const wrapped = (url, init = {}) => {
18
+ let same = false; try { same = new URL(String(url)).host === host; } catch { same = false; }
19
+ const headers = apiKey && same ? { ...(init.headers || {}), "x-api-key": apiKey } : init.headers;
20
+ const signal = init.signal || (timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined);
21
+ return raw(url, { ...init, ...(headers ? { headers } : {}), ...(signal ? { signal } : {}) });
22
+ };
23
+ return new OceanAltClient({ baseUrl, fetch: wrapped, onRecord, userAgent: userAgent || "oceanalt-adapter/0.1.0" });
24
+ }
25
+
26
+ /** Parse a human USDC amount ("2.5", 2.5, "$2.50", "3 USDC"). undefined when absent, NaN when present but invalid. */
27
+ export function parseUsdc(v) {
28
+ if (v == null || v === "") return undefined;
29
+ const n = typeof v === "number" ? v : Number(String(v).replace(/^\s*\$/, "").replace(/\s*usdc\s*$/i, "").trim());
30
+ return Number.isFinite(n) && n >= 0 ? n : NaN;
31
+ }
32
+
33
+ const INSTRUCTION = {
34
+ allow: "No blocking findings. This is a screening result over public data, not a guarantee.",
35
+ review: "Do not pay yet. Ask a human to confirm, and show them the reason.",
36
+ decline: "Do not pay.",
37
+ };
38
+
39
+ function result(decision, fields) {
40
+ return { decision, allow: decision === DECISION.ALLOW, instruction: INSTRUCTION[decision], ...fields };
41
+ }
42
+
43
+ /**
44
+ * POST /api/decide {to, amountUsdc, purpose, network} and normalise the answer.
45
+ * allow only when decision === "allow", allow !== false and verdict !== "uncertain". decline only on "decline".
46
+ * Everything else, including errors, is review (failClosed: true when OceanAlt did not give a usable answer).
47
+ */
48
+ export async function decidePayment(client, input = {}) {
49
+ const to = typeof input.to === "string" ? input.to.trim() : "";
50
+ if (!to) return result(DECISION.REVIEW, { reason_code: "input.missing_payee", detail: "No payee address given, so nothing was screened.", failClosed: true });
51
+ const amountUsdc = parseUsdc(input.amountUsdc ?? input.amount);
52
+ if (Number.isNaN(amountUsdc)) return result(DECISION.REVIEW, { to, reason_code: "input.invalid_amount", detail: `Amount '${input.amountUsdc ?? input.amount}' is not a USDC number.`, failClosed: true });
53
+ const body = {
54
+ to,
55
+ amountUsdc: amountUsdc,
56
+ purpose: input.purpose ? String(input.purpose).slice(0, 120) : undefined,
57
+ network: input.network || undefined,
58
+ };
59
+ let r;
60
+ try {
61
+ r = await client._json("/api/decide", { method: "POST", body: JSON.stringify(body) });
62
+ } catch (e) {
63
+ return result(DECISION.REVIEW, { to, reason_code: "oceanalt.unavailable", detail: `OceanAlt could not be reached or returned an error (${e?.message || e}). Not treated as safe.`, failClosed: true });
64
+ }
65
+ const fields = {
66
+ to,
67
+ verdict: r?.verdict ?? null,
68
+ reason_code: r?.reason_code ?? null,
69
+ retry: r?.retry ?? null,
70
+ advice: r?.advice ?? null,
71
+ signal_keys: Array.isArray(r?.signal_keys) ? r.signal_keys : [],
72
+ evidence: r?.evidence ?? null,
73
+ };
74
+ const d = r?.decision;
75
+ if (d === DECISION.DECLINE) return result(DECISION.DECLINE, { ...fields, detail: `OceanAlt declined ${to}.` });
76
+ if (d === DECISION.ALLOW && r?.allow !== false && r?.verdict !== "uncertain") return result(DECISION.ALLOW, { ...fields, detail: `OceanAlt allowed ${to}.` });
77
+ const why = d === DECISION.ALLOW ? `decision 'allow' with verdict '${r?.verdict}' and allow=${r?.allow}` : `decision '${d ?? "nothing"}'`;
78
+ return result(DECISION.REVIEW, { ...fields, reason_code: fields.reason_code || `decide.${d || "unknown"}`, detail: `OceanAlt returned ${why} for ${to}; a human should look.`, failClosed: d !== DECISION.REVIEW });
79
+ }
80
+
81
+ /** GET /api/endpoint?url=. risky → decline, clear → allow, anything else or an error → review. */
82
+ export async function checkEndpoint(client, url) {
83
+ if (!url || typeof url !== "string") return result(DECISION.REVIEW, { reason_code: "input.missing_url", detail: "No URL given.", failClosed: true });
84
+ let r;
85
+ try {
86
+ r = await client.endpoint(url);
87
+ } catch (e) {
88
+ return result(DECISION.REVIEW, { url, verdict: null, reason_code: "oceanalt.unavailable", signals: [], detail: `Endpoint screening could not run (${e?.message || e}). Not treated as safe.`, failClosed: true });
89
+ }
90
+ const fields = { url, verdict: r?.verdict ?? null, reason_code: r?.reason_code ?? null, signals: r?.signals ?? [] };
91
+ if (r?.verdict === "risky") return result(DECISION.DECLINE, { ...fields, detail: `${url} is listed as risky.` });
92
+ if (r?.verdict === "clear") return result(DECISION.ALLOW, { ...fields, detail: "Endpoint not on OceanAlt's lists. Absence from a list is not proof of safety." });
93
+ return result(DECISION.REVIEW, { ...fields, reason_code: fields.reason_code || `endpoint.${r?.verdict || "unknown"}`, detail: `Endpoint screening returned '${r?.verdict ?? "nothing"}'.`, failClosed: true });
94
+ }
95
+
96
+ export class OceanAltDeclinedError extends Error {
97
+ constructor(res) {
98
+ super(`OceanAlt declined this payment (${res.reason_code || "decline"}). The payment tool was not run.`);
99
+ this.name = "OceanAltDeclinedError";
100
+ this.code = "OCEANALT_DECLINED";
101
+ this.result = res;
102
+ }
103
+ }
104
+
105
+ export class OceanAltReviewRequiredError extends Error {
106
+ constructor(res) {
107
+ super(`OceanAlt returned review for this payment (${res.reason_code || "review"}). A human has to approve it first. The payment tool was not run.`);
108
+ this.name = "OceanAltReviewRequiredError";
109
+ this.code = "OCEANALT_REVIEW_REQUIRED";
110
+ this.result = res;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The guard. Runs `run()` only when OceanAlt allows the payment, or when onReview returns exactly true on review.
116
+ * getPayment(args) must return { to, amountUsdc?|amount?, network?, purpose? }. If it throws or returns no `to`,
117
+ * the result is review (fail closed).
118
+ * opts: { client, getPayment, onReview?, onResult? }
119
+ */
120
+ export async function guardedRun(opts, args, run) {
121
+ let payment = null;
122
+ let extractError = "";
123
+ try {
124
+ payment = await opts.getPayment(args);
125
+ } catch (e) {
126
+ extractError = e?.message || String(e);
127
+ }
128
+ let res;
129
+ if (!payment || typeof payment !== "object" || !payment.to) {
130
+ res = result(DECISION.REVIEW, { reason_code: "input.payment_not_extracted", detail: `getPayment() did not return a payee${extractError ? ` (${extractError})` : ""}.`, failClosed: true });
131
+ } else {
132
+ res = await decidePayment(opts.client, payment);
133
+ }
134
+ try { opts.onResult?.(res, args); } catch { /* observer only */ }
135
+ if (res.decision === DECISION.DECLINE) throw new OceanAltDeclinedError(res);
136
+ if (res.decision === DECISION.REVIEW) {
137
+ let approved = false;
138
+ if (typeof opts.onReview === "function") {
139
+ try { approved = (await opts.onReview(res, args)) === true; } catch { approved = false; }
140
+ }
141
+ if (!approved) throw new OceanAltReviewRequiredError(res);
142
+ }
143
+ return run();
144
+ }
145
+
146
+ export function assertGuardOptions(opts, who) {
147
+ if (!opts || typeof opts.getPayment !== "function") throw new TypeError(`${who}: opts.getPayment(args) is required. It must return { to, amountUsdc?, network?, purpose? } for the payment the tool is about to make.`);
148
+ if (opts.onReview != null && typeof opts.onReview !== "function") throw new TypeError(`${who}: opts.onReview must be a function`);
149
+ }
package/lib/evm.d.mts ADDED
@@ -0,0 +1,36 @@
1
+ export declare const USDC: Readonly<Record<number, string>>;
2
+ export declare function isAddress(v: unknown): v is `0x${string}`;
3
+ export declare function big(v: unknown): bigint | null;
4
+ export declare function chainIdOf(v: unknown): number | null;
5
+ export declare function networkOf(chainId: number | null | undefined): string | undefined;
6
+ export declare function isUsdc(chainId: number | null | undefined, token: string | null | undefined): boolean;
7
+ export declare function usdcString(atomic: unknown): string | undefined;
8
+
9
+ export type DecodedKind =
10
+ | "native-transfer" | "erc20-transfer" | "erc20-transferFrom" | "erc20-approve" | "erc20-increaseAllowance"
11
+ | "erc20-permit" | "setApprovalForAll" | "contract-call"
12
+ | "eip3009" | "permit" | "permit2-allowance" | "permit2-transfer"
13
+ | "unknown";
14
+
15
+ /** What a transaction or signature authorizes. Amounts are atomic units as decimal strings. */
16
+ export type Decoded = {
17
+ kind: DecodedKind;
18
+ payment?: boolean;
19
+ approval?: boolean;
20
+ payee?: string | null;
21
+ spender?: string | null;
22
+ amount?: string | null;
23
+ unlimited?: boolean;
24
+ token?: string | null;
25
+ contract?: string;
26
+ selector?: string;
27
+ primaryType?: string;
28
+ domainChainId?: number | null;
29
+ reason?: string;
30
+ [k: string]: unknown;
31
+ };
32
+
33
+ export declare function decodeTypedData(typedData: unknown): Decoded;
34
+ export declare function decodeTransaction(tx: { to?: string | null; data?: string; value?: unknown } | null | undefined): Decoded;
35
+ export declare function calldataOf(tx: { to?: string; data?: string; abi?: unknown; functionName?: string; args?: unknown[] }): Promise<{ data: string } | { error: string }>;
36
+ export declare function parseSerialized(serialized: string): Promise<{ tx: { to: string | null; data: string; value: bigint; chainId: number | null } } | { error: string }>;
package/lib/evm.mjs ADDED
@@ -0,0 +1,176 @@
1
+ // EVM decoding shared by @oceanalt/privy, @oceanalt/turnkey and @oceanalt/crossmint (identical copies; keep in sync).
2
+ // decodeTransaction / decodeTypedData are adapted from @oceanalt/goat-plugin lib/guard.mjs.
3
+ //
4
+ // Pure functions, no network. They read what a signature or transaction actually authorizes (the real recipient
5
+ // and amount inside ERC-20 calldata or an EIP-3009 authorization), not what the caller says it is for.
6
+
7
+ const ADDR = /^0x[0-9a-fA-F]{40}$/;
8
+ const UNLIMITED_FLOOR = 1n << 255n; // an approval this large is treated as unlimited
9
+
10
+ // Circle USDC (6 decimals). Only for these is an amount sent to OceanAlt as amountUsdc.
11
+ export const USDC = Object.freeze({
12
+ 1: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
13
+ 8453: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
14
+ 84532: "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
15
+ 42161: "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
16
+ 421614: "0x75faf114eafb1bdbe2f0316df893fd58ce46aa4d",
17
+ 10: "0x0b2c639c533813f4aa9d7837caf62653d097ff85",
18
+ 11155420: "0x5fd84259d66cd46123540766be93dfe6d43130d7",
19
+ 137: "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359",
20
+ 80002: "0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582",
21
+ 43114: "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e",
22
+ 11155111: "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238",
23
+ });
24
+
25
+ const lc = (v) => (typeof v === "string" ? v.toLowerCase() : v);
26
+ export const isAddress = (v) => typeof v === "string" && ADDR.test(v);
27
+
28
+ /** BigInt from a number, decimal string or 0x hex string. null when absent or invalid. */
29
+ export function big(v) {
30
+ if (v == null || v === "") return null;
31
+ try {
32
+ if (typeof v === "bigint") return v;
33
+ if (typeof v === "number") return Number.isSafeInteger(v) ? BigInt(v) : null;
34
+ return BigInt(String(v));
35
+ } catch { return null; }
36
+ }
37
+
38
+ /** chain id from a number, "0x2105", "8453" or "eip155:8453". null otherwise. */
39
+ export function chainIdOf(v) {
40
+ if (v == null || v === "") return null;
41
+ const s = String(v);
42
+ const m = /^eip155:(\d+)$/i.exec(s);
43
+ const n = m ? Number(m[1]) : Number(big(s));
44
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
45
+ }
46
+
47
+ export const networkOf = (chainId) => (chainId ? `eip155:${chainId}` : undefined);
48
+ export const isUsdc = (chainId, token) => !!chainId && !!token && USDC[chainId] === lc(token);
49
+
50
+ /** Exact atomic USDC (6 decimals) to a decimal string, e.g. 2500000n → "2.5". */
51
+ export function usdcString(atomic) {
52
+ const a = big(atomic);
53
+ if (a == null || a < 0n) return undefined;
54
+ const whole = a / 1_000_000n;
55
+ const frac = (a % 1_000_000n).toString().padStart(6, "0").replace(/0+$/, "");
56
+ return frac ? `${whole}.${frac}` : `${whole}`;
57
+ }
58
+
59
+ /**
60
+ * Decode EIP-712 typed data. Accepts viem's shape (primaryType) and Privy's (primary_type), as an object or JSON string.
61
+ * kinds: eip3009 (TransferWithAuthorization / ReceiveWithAuthorization, the x402 "exact" EVM scheme), permit (EIP-2612),
62
+ * permit2-allowance, permit2-transfer, unknown.
63
+ */
64
+ export function decodeTypedData(typedData) {
65
+ let td = typedData;
66
+ if (typeof td === "string") { try { td = JSON.parse(td); } catch { return { kind: "unknown", reason: "typed data is not valid JSON" }; } }
67
+ if (!td || typeof td !== "object") return { kind: "unknown", reason: "typed data is empty" };
68
+ const pt = String(td.primaryType ?? td.primary_type ?? "");
69
+ const m = td.message && typeof td.message === "object" ? td.message : {};
70
+ const d = td.domain && typeof td.domain === "object" ? td.domain : {};
71
+ const base = { primaryType: pt, token: lc(d.verifyingContract) || null, domainChainId: chainIdOf(d.chainId) };
72
+
73
+ if (pt === "TransferWithAuthorization" || pt === "ReceiveWithAuthorization") {
74
+ if (!isAddress(m.to)) return { ...base, kind: "unknown", reason: `${pt} without a valid 'to'` };
75
+ return { ...base, kind: "eip3009", payment: true, approval: false, payee: lc(m.to), from: lc(m.from), amount: big(m.value)?.toString() ?? null };
76
+ }
77
+ if (pt === "Permit" && m.spender) {
78
+ const v = big(m.value);
79
+ return { ...base, kind: "permit", payment: false, approval: true, spender: lc(m.spender), amount: v?.toString() ?? null, unlimited: v != null && v >= UNLIMITED_FLOOR };
80
+ }
81
+ if (pt === "PermitSingle" || pt === "PermitBatch") {
82
+ const details = Array.isArray(m.details) ? m.details : [m.details || {}];
83
+ const amounts = details.map((x) => big(x?.amount));
84
+ return { ...base, kind: "permit2-allowance", payment: false, approval: true, spender: lc(m.spender), token: lc(details[0]?.token) || base.token, amount: amounts[0]?.toString() ?? null, unlimited: amounts.some((a) => a != null && a >= (1n << 159n)) };
85
+ }
86
+ if (pt === "PermitTransferFrom" || pt === "PermitWitnessTransferFrom" || pt === "PermitBatchTransferFrom" || pt === "PermitBatchWitnessTransferFrom") {
87
+ const perm = Array.isArray(m.permitted) ? m.permitted[0] : m.permitted;
88
+ return { ...base, kind: "permit2-transfer", payment: false, approval: true, spender: lc(m.spender), token: lc(perm?.token) || base.token, amount: big(perm?.amount)?.toString() ?? null, unlimited: false };
89
+ }
90
+ return { ...base, kind: "unknown", reason: pt ? `unrecognised primaryType '${pt}'` : "no primaryType" };
91
+ }
92
+
93
+ const SEL = {
94
+ a9059cbb: "transfer", "23b872dd": "transferFrom", "095ea7b3": "approve", "39509351": "increaseAllowance",
95
+ a22cb465: "setApprovalForAll", d505accf: "permit",
96
+ };
97
+ const word = (hex, i) => hex.slice(8 + i * 64, 8 + (i + 1) * 64);
98
+ const wordAddr = (hex, i) => { const w = word(hex, i); return w.length === 64 ? "0x" + w.slice(24) : null; };
99
+ const wordInt = (hex, i) => { const w = word(hex, i); return w.length === 64 ? BigInt("0x" + w) : null; };
100
+
101
+ /** Decode an EVM call {to, data, value}: native transfer, ERC-20 transfer / transferFrom / approve / permit, or an opaque contract call. */
102
+ export function decodeTransaction(tx) {
103
+ const to = lc(tx?.to);
104
+ if (!isAddress(to)) return { kind: "unknown", reason: "transaction has no valid 'to' (contract creation or malformed)" };
105
+ const data = String(tx?.data || "0x");
106
+ if (!/^0x[0-9a-fA-F]*$/.test(data)) return { kind: "unknown", reason: "calldata is not hex" };
107
+ const value = big(tx?.value) ?? 0n;
108
+ if (data === "0x") return { kind: "native-transfer", payment: true, approval: false, payee: to, amount: value.toString(), token: null };
109
+ const hex = data.slice(2);
110
+ const name = SEL[hex.slice(0, 8).toLowerCase()];
111
+ const out = { token: to, value: value.toString() };
112
+ const bad = (k) => ({ ...out, kind: "unknown", reason: `${k} calldata is truncated` });
113
+ switch (name) {
114
+ case "transfer": { const p = wordAddr(hex, 0), a = wordInt(hex, 1); return p && a != null ? { ...out, kind: "erc20-transfer", payment: true, approval: false, payee: p, amount: a.toString() } : bad(name); }
115
+ case "transferFrom": { const p = wordAddr(hex, 1), a = wordInt(hex, 2); return p && a != null ? { ...out, kind: "erc20-transferFrom", payment: true, approval: false, payee: p, amount: a.toString() } : bad(name); }
116
+ case "approve":
117
+ case "increaseAllowance": { const s = wordAddr(hex, 0), a = wordInt(hex, 1); return s && a != null ? { ...out, kind: `erc20-${name}`, payment: false, approval: true, spender: s, amount: a.toString(), unlimited: a >= UNLIMITED_FLOOR } : bad(name); }
118
+ case "setApprovalForAll": { const s = wordAddr(hex, 0), on = wordInt(hex, 1); return s && on != null ? { ...out, kind: "setApprovalForAll", payment: false, approval: on === 1n, spender: s, unlimited: on === 1n } : bad(name); }
119
+ case "permit": { const s = wordAddr(hex, 1), a = wordInt(hex, 2); return s && a != null ? { ...out, kind: "erc20-permit", payment: false, approval: true, spender: s, amount: a.toString(), unlimited: a >= UNLIMITED_FLOOR } : bad(name); }
120
+ default: return { ...out, kind: "contract-call", payment: false, approval: false, contract: to, selector: "0x" + hex.slice(0, 8) };
121
+ }
122
+ }
123
+
124
+ // Known ERC-20 functions, so { abi, functionName, args } calls are encoded without viem.
125
+ const KNOWN = {
126
+ "transfer(address,uint256)": "a9059cbb",
127
+ "approve(address,uint256)": "095ea7b3",
128
+ "transferFrom(address,address,uint256)": "23b872dd",
129
+ "increaseAllowance(address,uint256)": "39509351",
130
+ };
131
+ const pad = (hex) => hex.padStart(64, "0");
132
+ function encodeArg(type, v) {
133
+ if (type === "address") { if (!isAddress(String(v || ""))) throw new Error("bad address"); return pad(String(v).slice(2).toLowerCase()); }
134
+ if (type === "uint256") { const n = BigInt(v); if (n < 0n || n >= 1n << 256n) throw new Error("uint256 out of range"); return pad(n.toString(16)); }
135
+ throw new Error(`unsupported type ${type}`);
136
+ }
137
+
138
+ /**
139
+ * Hex calldata for { to, data } or { to, abi, functionName, args }. Returns { data } or { error }.
140
+ * Never guesses: an ABI call that can't be encoded is reported, not treated as a plain transfer.
141
+ */
142
+ export async function calldataOf(tx) {
143
+ if (!tx?.abi) return { data: tx?.data || "0x" };
144
+ if (!tx.functionName) return { error: "ABI given without functionName" };
145
+ const fns = (Array.isArray(tx.abi) ? tx.abi : []).filter((x) => x?.type === "function" && x?.name === tx.functionName);
146
+ if (fns.length === 1) {
147
+ const sig = `${tx.functionName}(${(fns[0].inputs || []).map((i) => i.type).join(",")})`;
148
+ const sel = KNOWN[sig];
149
+ if (sel) {
150
+ try { return { data: "0x" + sel + (fns[0].inputs || []).map((inp, i) => encodeArg(inp.type, tx.args?.[i])).join("") }; }
151
+ catch (e) { return { error: `could not encode ${sig}: ${e.message}` }; }
152
+ }
153
+ }
154
+ try {
155
+ const viem = await import("viem");
156
+ return { data: viem.encodeFunctionData({ abi: tx.abi, functionName: tx.functionName, args: tx.args || [] }) };
157
+ } catch (e) {
158
+ return { error: `could not encode ${tx.functionName} (${e?.message || e})` };
159
+ }
160
+ }
161
+
162
+ /** Parse a serialized (RLP) unsigned transaction with viem. Returns { tx: { to, data, value, chainId } } or { error }. */
163
+ export async function parseSerialized(serialized) {
164
+ const s = String(serialized || "");
165
+ const hex = s.startsWith("0x") ? s : `0x${s}`;
166
+ if (!/^0x[0-9a-fA-F]+$/.test(hex)) return { error: "serialized transaction is not hex" };
167
+ let viem;
168
+ try { viem = await import("viem"); } catch { return { error: "viem is not installed, so the serialized transaction could not be decoded" }; }
169
+ try {
170
+ const t = viem.parseTransaction(hex);
171
+ if (t.authorizationList?.length) return { error: "EIP-7702 transaction with an authorization list" };
172
+ return { tx: { to: t.to ?? null, data: t.data ?? "0x", value: t.value ?? 0n, chainId: t.chainId ?? null } };
173
+ } catch (e) {
174
+ return { error: `could not parse the serialized transaction (${e?.shortMessage || e?.message || e})` };
175
+ }
176
+ }
@@ -0,0 +1,59 @@
1
+ import type { OceanAltClient } from "@oceanalt/core";
2
+ import type { ClientOptions, PayeeResult, PaymentIntent } from "./decide.mjs";
3
+ import type { Decoded } from "./evm.mjs";
4
+
5
+ export type RequestKind = "transaction" | "typed-data" | "payment" | "message" | "non-evm" | "raw" | "batch" | "pass";
6
+
7
+ /** One signing call, as the guard sees it. Passed to onReview / onResult. */
8
+ export type SigningRequest = {
9
+ provider: string;
10
+ method: string;
11
+ kind: RequestKind;
12
+ chainId?: number | string | null;
13
+ calls?: Array<{ to?: string | null; data?: string; value?: unknown }>;
14
+ typedData?: unknown;
15
+ parts?: SigningRequest[];
16
+ reason_code?: string;
17
+ detail?: string;
18
+ /** The original arguments of the guarded method. */
19
+ input?: unknown;
20
+ /** Set on the copy passed to callbacks: one result per decoded call or signature. */
21
+ results?: Array<PayeeResult & { decoded: Decoded | null }>;
22
+ [k: string]: unknown;
23
+ };
24
+
25
+ export type WalletGuardOptions = ClientOptions & {
26
+ client?: OceanAltClient;
27
+ /** Called on "review". Return exactly true (after a human agreed) to sign anyway. Default: refuse. */
28
+ onReview?: (result: PayeeResult & { decoded: Decoded | null }, request: SigningRequest) => boolean | Promise<boolean>;
29
+ /** Observer for every decision. Errors thrown here are ignored. */
30
+ onResult?: (result: PayeeResult & { decoded: Decoded | null }, request: SigningRequest) => void;
31
+ /** Sent to OceanAlt as `purpose` (cut to 120 characters). */
32
+ purpose?: string;
33
+ /** personal_sign / signMessage. Default "review": messages are not decoded. */
34
+ signMessage?: "review" | "allow";
35
+ /** Contract calls that are not a transfer or approval. "screen-target" screens the contract address. Default "review". */
36
+ unknownCalls?: "review" | "screen-target";
37
+ /** Requests for non-EVM chains (Solana, Tron, Spark, ...). Default "review": they are not decoded. */
38
+ nonEvm?: "review" | "allow";
39
+ };
40
+
41
+ export declare const POLICY_DEFAULTS: Readonly<{ signMessage: "review"; unknownCalls: "review"; nonEvm: "review" }>;
42
+ export declare function localReview(reason_code: string, detail: string, extra?: object): PayeeResult;
43
+ export declare function alreadyChecked(): boolean;
44
+ export declare function runChecked<T>(fn: () => T): T;
45
+ export declare function itemFromDecoded(decoded: Decoded, chainId: number | null | undefined, opts?: WalletGuardOptions): {
46
+ decoded: Decoded;
47
+ payment?: PaymentIntent;
48
+ result?: PayeeResult;
49
+ unlimited?: boolean;
50
+ skip?: true;
51
+ };
52
+ export declare function createGate(opts?: WalletGuardOptions, defaults?: { userAgent?: string }): {
53
+ client: OceanAltClient;
54
+ /** Resolves when the request may be signed; throws OceanAltDeclinedError or OceanAltReviewRequiredError otherwise. */
55
+ check(request: SigningRequest): Promise<PayeeResult | null>;
56
+ };
57
+ export declare function defineGuarded(target: object, methods: Record<string, Function>): void;
58
+ export declare function guardViemAccount<A extends { address: string }>(account: A, opts?: WalletGuardOptions & { provider?: string }): A;
59
+ export declare function isGuardedAccount(account: unknown): boolean;
@@ -0,0 +1,202 @@
1
+ // Wallet-level signing gate shared by @oceanalt/privy, @oceanalt/turnkey and @oceanalt/crossmint (identical copies; keep in sync).
2
+ //
3
+ // A provider adapter turns each signing call into a request:
4
+ // { provider, method, kind, chainId?, calls?, typedData?, parts?, reason_code?, detail?, input? }
5
+ // kind: "transaction" (one or more calls {to, data, value}) | "typed-data" | "payment" | "message" | "non-evm" | "raw" | "batch" | "pass"
6
+ // The gate decodes it (real recipient and amount from ERC-20 calldata or EIP-3009 typed data), asks OceanAlt
7
+ // POST /api/decide for each payee, and then:
8
+ // decline → throws OceanAltDeclinedError, nothing is signed
9
+ // review → onReview(result, request) must return exactly true, otherwise throws OceanAltReviewRequiredError
10
+ // allow → the original method runs
11
+ // Anything it can't decode (raw hashes, EIP-7702 authorizations, user operations, unknown contract calls, other chains)
12
+ // is review, never allow, unless an explicit policy option says otherwise.
13
+ import { AsyncLocalStorage } from "node:async_hooks";
14
+ import { createClient, decidePayment, DECISION, OceanAltDeclinedError, OceanAltReviewRequiredError } from "./decide.mjs";
15
+ import { decodeTransaction, decodeTypedData, isUsdc, usdcString, networkOf, chainIdOf, isAddress } from "./evm.mjs";
16
+
17
+ const INSTRUCTION = {
18
+ allow: "No blocking findings. This is a screening result over public data, not a guarantee.",
19
+ review: "Do not pay yet. Ask a human to confirm, and show them the reason.",
20
+ decline: "Do not pay.",
21
+ };
22
+ const local = (decision, fields) => ({ decision, allow: decision === DECISION.ALLOW, instruction: INSTRUCTION[decision], ...fields });
23
+ export const localReview = (reason_code, detail, extra = {}) => local(DECISION.REVIEW, { reason_code, detail, failClosed: true, ...extra });
24
+
25
+ // One store per process, shared by every copy of this file. When an outer guard (a guarded viem account) has already
26
+ // checked a call, the provider-level guard underneath (e.g. Turnkey signRawPayload) lets it through instead of asking twice.
27
+ const ALS_KEY = Symbol.for("oceanalt.wallet-guard.checked");
28
+ const als = (globalThis[ALS_KEY] ||= new AsyncLocalStorage());
29
+ const CHECKED = Symbol.for("oceanalt.wallet-guard.checked.marker");
30
+ export const alreadyChecked = () => als.getStore() === CHECKED;
31
+ export const runChecked = (fn) => als.run(CHECKED, fn);
32
+
33
+ export const POLICY_DEFAULTS = Object.freeze({ signMessage: "review", unknownCalls: "review", nonEvm: "review" });
34
+
35
+ function policyError(opts) {
36
+ const ok = { signMessage: ["review", "allow"], unknownCalls: ["review", "screen-target"], nonEvm: ["review", "allow"] };
37
+ for (const [k, vals] of Object.entries(ok)) {
38
+ if (opts[k] != null && !vals.includes(opts[k])) return `opts.${k} must be one of ${vals.map((v) => `"${v}"`).join(", ")}`;
39
+ }
40
+ if (opts.onReview != null && typeof opts.onReview !== "function") return "opts.onReview must be a function";
41
+ if (opts.onResult != null && typeof opts.onResult !== "function") return "opts.onResult must be a function";
42
+ return "";
43
+ }
44
+
45
+ /** One decoded call or signature → what to send to OceanAlt, or a local result. */
46
+ export function itemFromDecoded(decoded, chainId, opts = {}) {
47
+ const purpose = opts.purpose ? String(opts.purpose) : undefined;
48
+ if (decoded.kind === "unknown") return { decoded, result: localReview("calldata.undecodable", `Could not decode what this signs: ${decoded.reason}.`) };
49
+ if (decoded.domainChainId && chainId && decoded.domainChainId !== chainId) {
50
+ return { decoded, result: localReview("typed_data.chain_mismatch", `Typed data is for chain ${decoded.domainChainId} but the request names chain ${chainId}.`) };
51
+ }
52
+ const chain = decoded.domainChainId || chainId || null;
53
+ const network = networkOf(chain);
54
+ if (decoded.kind === "contract-call") {
55
+ if (opts.unknownCalls !== "screen-target") return { decoded, result: localReview("calldata.unrecognised", `Contract call ${decoded.selector} to ${decoded.contract} is not a transfer or approval this guard can read.`) };
56
+ return { decoded, payment: { to: decoded.contract, network, purpose } };
57
+ }
58
+ if (decoded.kind === "setApprovalForAll" && !decoded.approval) return { decoded, skip: true }; // a revocation
59
+ const to = decoded.payment ? decoded.payee : decoded.spender;
60
+ if (!isAddress(to)) return { decoded, result: localReview("calldata.undecodable", "Decoded call has no valid recipient or spender.") };
61
+ const amountUsdc = decoded.kind !== "native-transfer" && !decoded.unlimited && decoded.amount != null && isUsdc(chain, decoded.token) ? usdcString(decoded.amount) : undefined;
62
+ return { decoded, payment: { to, amountUsdc, network, purpose }, unlimited: !!decoded.unlimited };
63
+ }
64
+
65
+ function itemsOf(req, opts) {
66
+ switch (req.kind) {
67
+ case "pass": return [];
68
+ case "transaction": {
69
+ const calls = Array.isArray(req.calls) ? req.calls : [];
70
+ if (!calls.length) return [{ result: localReview("input.no_calls", "The request has no calls to check.") }];
71
+ return calls.map((c) => itemFromDecoded(decodeTransaction(c), chainIdOf(req.chainId), opts));
72
+ }
73
+ case "typed-data": return [itemFromDecoded(decodeTypedData(req.typedData), chainIdOf(req.chainId), opts)];
74
+ case "message":
75
+ return opts.signMessage === "allow" ? [] : [{ result: localReview("sign.message_not_decoded", "Message signatures are not decoded. A signed message can authorize things this guard can't see.") }];
76
+ case "non-evm":
77
+ return opts.nonEvm === "allow" ? [] : [{ result: localReview("chain.not_supported", req.detail || "Only EVM requests are decoded. This one was not checked.") }];
78
+ case "batch": return (req.parts || []).flatMap((p) => itemsOf(p, opts));
79
+ case "payment": { // a high-level transfer whose payee and amount the provider SDK states directly
80
+ const p = req.payment || {};
81
+ if (!isAddress(p.to)) return [{ result: localReview("payee.not_an_address", req.detail || "The payee is not an EVM address, so it can't be screened.") }];
82
+ return [{ decoded: req.decoded ?? null, payment: { to: p.to, amountUsdc: p.amountUsdc, network: networkOf(chainIdOf(req.chainId)) || p.network, purpose: opts.purpose ? String(opts.purpose) : undefined } }];
83
+ }
84
+ default: return [{ result: localReview(req.reason_code || "sign.not_decoded", req.detail || "This signing method is not decoded, so it was not checked.") }];
85
+ }
86
+ }
87
+
88
+ const RANK = { allow: 0, review: 1, decline: 2 };
89
+
90
+ // Same error classes as the tool adapters; only the last sentence is about signing instead of a tool.
91
+ function signingError(e) {
92
+ e.message = e.message.replace("The payment tool was not run.", "Nothing was signed.");
93
+ return e;
94
+ }
95
+
96
+ /**
97
+ * Build the gate. opts: { client?, baseUrl?, fetch?, apiKey?, timeoutMs?, onReview?, onResult?, purpose?,
98
+ * signMessage?, unknownCalls?, nonEvm? }. Returns { client, check(request) }.
99
+ */
100
+ export function createGate(opts = {}, { userAgent } = {}) {
101
+ const err = policyError(opts);
102
+ if (err) throw new TypeError(err);
103
+ const client = opts.client || createClient({ baseUrl: opts.baseUrl, fetch: opts.fetch, apiKey: opts.apiKey, timeoutMs: opts.timeoutMs, userAgent });
104
+
105
+ async function check(request) {
106
+ if (alreadyChecked()) return null;
107
+ let items;
108
+ try { items = itemsOf(request, opts); } catch (e) { items = [{ result: localReview("input.unreadable", `Could not read the request (${e?.message || e}).`) }]; }
109
+ const results = [];
110
+ for (const it of items) {
111
+ let r;
112
+ if (it.result) r = it.result;
113
+ else if (it.skip) r = local(DECISION.ALLOW, { reason_code: "local.revocation", detail: "Revoking an approval; nothing to screen." });
114
+ else {
115
+ r = await decidePayment(client, it.payment);
116
+ if (it.unlimited && r.decision === DECISION.ALLOW) {
117
+ r = { ...r, decision: DECISION.REVIEW, allow: false, instruction: INSTRUCTION.review, reason_code: "approval.unlimited", detail: `OceanAlt allowed ${it.payment.to}, but this is an unlimited approval, so a human should confirm.` };
118
+ }
119
+ }
120
+ results.push({ ...r, decoded: it.decoded ?? null });
121
+ }
122
+ if (!results.length) return null; // nothing to check under the chosen policy
123
+ const worst = results.reduce((w, x) => (RANK[x.decision] > RANK[w.decision] ? x : w), results[0]);
124
+ const req = { ...request, results };
125
+ try { opts.onResult?.(worst, req); } catch { /* observer only */ }
126
+ if (worst.decision === DECISION.DECLINE) throw signingError(new OceanAltDeclinedError(worst));
127
+ if (worst.decision === DECISION.REVIEW) {
128
+ let approved = false;
129
+ if (typeof opts.onReview === "function") {
130
+ try { approved = (await opts.onReview(worst, req)) === true; } catch { approved = false; }
131
+ }
132
+ if (!approved) throw signingError(new OceanAltReviewRequiredError(worst));
133
+ }
134
+ return worst;
135
+ }
136
+ return { client, check };
137
+ }
138
+
139
+ /** Replace methods on an object in place with non-writable guarded versions. */
140
+ export function defineGuarded(target, methods) {
141
+ for (const [name, fn] of Object.entries(methods)) {
142
+ Object.defineProperty(target, name, { value: fn, writable: false, configurable: false, enumerable: false });
143
+ }
144
+ }
145
+
146
+ const VIEM_GUARDED = Symbol.for("oceanalt.viem-account.guarded");
147
+
148
+ /**
149
+ * Wrap a viem LocalAccount (e.g. Privy createViemAccount, Turnkey createAccount, privateKeyToAccount).
150
+ * Returns a new account object; the original stays unguarded.
151
+ * signTransaction decoded (native / ERC-20 transfer, approve, permit; other calls per unknownCalls)
152
+ * signTypedData decoded (EIP-3009 TransferWithAuthorization for x402, EIP-2612 Permit, Permit2)
153
+ * signMessage per opts.signMessage (default review)
154
+ * sign, signAuthorization always review (raw hash / EIP-7702 delegation)
155
+ */
156
+ export function guardViemAccount(account, opts = {}) {
157
+ if (!account || typeof account !== "object" || !isAddress(account.address)) throw new TypeError("guardViemAccount(): expected a viem account with an address");
158
+ if (account.type && account.type !== "local") throw new TypeError(`guardViemAccount(): only local accounts are supported (got type '${account.type}')`);
159
+ if (account[VIEM_GUARDED]) return account;
160
+ const gate = createGate(opts, { userAgent: opts.userAgent || "oceanalt-viem-account/0.1.0" });
161
+ const provider = opts.provider || "viem";
162
+ const out = { ...account };
163
+ const m = {};
164
+ if (typeof account.signTransaction === "function") {
165
+ m.signTransaction = async (tx, o) => {
166
+ const req = tx?.authorizationList?.length
167
+ ? { provider, method: "signTransaction", kind: "raw", reason_code: "eip7702.authorization", detail: "Transaction carries an EIP-7702 authorization list (account delegation).", input: tx }
168
+ : { provider, method: "signTransaction", kind: "transaction", chainId: tx?.chainId, calls: [{ to: tx?.to, data: tx?.data ?? tx?.input ?? "0x", value: tx?.value }], input: tx };
169
+ await gate.check(req);
170
+ return runChecked(() => account.signTransaction(tx, o));
171
+ };
172
+ }
173
+ if (typeof account.signTypedData === "function") {
174
+ m.signTypedData = async (td) => {
175
+ await gate.check({ provider, method: "signTypedData", kind: "typed-data", typedData: td, input: td });
176
+ return runChecked(() => account.signTypedData(td));
177
+ };
178
+ }
179
+ if (typeof account.signMessage === "function") {
180
+ m.signMessage = async (p) => {
181
+ await gate.check({ provider, method: "signMessage", kind: "message", input: p });
182
+ return runChecked(() => account.signMessage(p));
183
+ };
184
+ }
185
+ if (typeof account.sign === "function") {
186
+ m.sign = async (p) => {
187
+ await gate.check({ provider, method: "sign", kind: "raw", reason_code: "sign.raw_hash", detail: "Signing a raw hash. What it authorizes can't be read.", input: p });
188
+ return runChecked(() => account.sign(p));
189
+ };
190
+ }
191
+ if (typeof account.signAuthorization === "function") {
192
+ m.signAuthorization = async (p) => {
193
+ await gate.check({ provider, method: "signAuthorization", kind: "raw", reason_code: "eip7702.authorization", detail: "EIP-7702 authorization: delegates this account to contract code.", input: p });
194
+ return runChecked(() => account.signAuthorization(p));
195
+ };
196
+ }
197
+ defineGuarded(out, m);
198
+ Object.defineProperty(out, VIEM_GUARDED, { value: { client: gate.client }, enumerable: false });
199
+ return out;
200
+ }
201
+
202
+ export const isGuardedAccount = (a) => !!a?.[VIEM_GUARDED];
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@oceanalt/crossmint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Guard for Crossmint wallets: ask OceanAlt about the real payee (allow / review / decline) before a Crossmint EVM wallet sends, signs an x402 authorization or approves. Fails closed. No keys, no custody. Not affiliated with Crossmint.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./index.mjs",
8
+ "types": "./index.d.ts",
9
+ "exports": { ".": { "types": "./index.d.ts", "import": "./index.mjs", "default": "./index.mjs" } },
10
+ "files": ["index.mjs", "index.d.ts", "lib/decide.mjs", "lib/decide.d.mts", "lib/evm.mjs", "lib/evm.d.mts", "lib/wallet-guard.mjs", "lib/wallet-guard.d.mts", "README.md", "LICENSE"],
11
+ "scripts": {
12
+ "test": "node --test test/*.test.mjs"
13
+ },
14
+ "engines": { "node": ">=20" },
15
+ "dependencies": {
16
+ "@oceanalt/core": "^0.2.0"
17
+ },
18
+ "peerDependencies": {
19
+ "@crossmint/wallets-sdk": ">=1.20.0 <2.0.0",
20
+ "viem": "^2.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@crossmint/wallets-sdk": "^1.20.0",
24
+ "viem": "^2.56.9"
25
+ },
26
+ "homepage": "https://oceanalt.com/en/api-docs",
27
+ "bugs": { "email": "business@oceanalt.com" },
28
+ "keywords": ["crossmint", "agent-wallets", "smart-wallets", "x402", "eip-3009", "agent-payments", "pre-signing", "aml"]
29
+ }