@algopayoracle/oracle-sdk 1.0.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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * AlgoPay Oracle SDK — Razorpay Adapter
3
+ *
4
+ * Implements the PaymentAdapter interface for Razorpay.
5
+ * Normalizes Razorpay events into PaymentEvents consumed by AlgoPayClient.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const crypto = require("crypto");
11
+ const https = require("https");
12
+ const { ProviderAuthError, ConfigError } = require("../errors");
13
+
14
+ class RazorpayAdapter {
15
+ /**
16
+ * @param {object} opts
17
+ * @param {string} opts.keySecret - Razorpay key secret
18
+ * @param {string} [opts.keyId] - Razorpay key ID (required for createOrder)
19
+ * @param {object} [opts.orderStore] - shared order store (set/get/consume/delete async interface)
20
+ * @param {string} [opts.defaultAction]
21
+ */
22
+ constructor({ keySecret, keyId, orderStore, defaultAction = "unlock" } = {}) {
23
+ if (!keySecret) throw new ConfigError("RazorpayAdapter: keySecret is required");
24
+ this.keySecret = keySecret;
25
+ this.keyId = keyId;
26
+ this.orderStore = orderStore || null;
27
+ this.defaultAction = defaultAction;
28
+ }
29
+
30
+ // ── parseWebhook ───────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Parse and verify a Razorpay server-side webhook.
34
+ * Implements PaymentAdapter.parseWebhook.
35
+ * Returns null on any failure (invalid sig, wrong event, bad body).
36
+ *
37
+ * @param {Buffer|string} rawBody
38
+ * @param {string} signature - X-Razorpay-Signature header
39
+ * @returns {import("../index").PaymentEvent|null}
40
+ */
41
+ parseWebhook(rawBody, signature) {
42
+ if (!signature) return null;
43
+
44
+ const expected = crypto.createHmac("sha256", this.keySecret).update(rawBody).digest();
45
+ const received = Buffer.from(signature, "hex");
46
+ if (expected.length !== received.length) return null;
47
+ if (!crypto.timingSafeEqual(expected, received)) return null;
48
+
49
+ let body;
50
+ try { body = JSON.parse(rawBody.toString()); } catch { return null; }
51
+
52
+ if (body.event !== "payment.captured") return null;
53
+
54
+ const p = body?.payload?.payment?.entity;
55
+ if (!p?.id || !p?.amount) return null;
56
+
57
+ return {
58
+ payment_id: p.id,
59
+ amount: Math.round(p.amount / 100),
60
+ currency: (p.currency || "INR").toUpperCase(),
61
+ action: this.defaultAction,
62
+ provider: "razorpay",
63
+ };
64
+ }
65
+
66
+ // ── parseClientPayment ────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Verify a Razorpay client-side payment and return a PaymentEvent.
70
+ * Amount is resolved from the server-side orderStore — never from the request.
71
+ *
72
+ * @param {object} opts
73
+ * @param {string} opts.razorpay_order_id
74
+ * @param {string} opts.razorpay_payment_id
75
+ * @param {string} opts.razorpay_signature
76
+ * @param {string} [opts.action]
77
+ * @throws {ProviderAuthError} on invalid signature or missing order
78
+ * @returns {import("../index").PaymentEvent}
79
+ */
80
+ async parseClientPayment({ razorpay_order_id, razorpay_payment_id, razorpay_signature, action }) {
81
+ if (!razorpay_order_id || !razorpay_payment_id || !razorpay_signature) {
82
+ throw new ProviderAuthError("razorpay", "missing required payment fields");
83
+ }
84
+
85
+ const expected = crypto
86
+ .createHmac("sha256", this.keySecret)
87
+ .update(`${razorpay_order_id}|${razorpay_payment_id}`)
88
+ .digest();
89
+ const received = Buffer.from(razorpay_signature, "hex");
90
+
91
+ if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
92
+ throw new ProviderAuthError("razorpay", "payment signature mismatch");
93
+ }
94
+
95
+ if (!this.orderStore) {
96
+ throw new ConfigError("RazorpayAdapter: orderStore is required for parseClientPayment — amount cannot be trusted from client");
97
+ }
98
+
99
+ // Amount MUST come from the server-side order record, not the request body
100
+ // This is a synchronous path so we return a promise that resolves the event
101
+ // Callers must await this if orderStore.consume is async
102
+ if (!this.orderStore) {
103
+ throw new ConfigError("RazorpayAdapter: orderStore is required for parseClientPayment — amount cannot be trusted from client");
104
+ }
105
+ const record = await this.orderStore.consume(razorpay_order_id);
106
+ if (!record) {
107
+ throw new ProviderAuthError("razorpay", `order "${razorpay_order_id}" not found or already used — call createOrder first`);
108
+ }
109
+ return {
110
+ payment_id: razorpay_payment_id,
111
+ amount: record.amount,
112
+ currency: record.currency,
113
+ action: action || this.defaultAction,
114
+ provider: "razorpay",
115
+ };
116
+ }
117
+
118
+
119
+ // ── createOrder ───────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Create a Razorpay order and store the authoritative amount server-side.
123
+ *
124
+ * @param {object} opts
125
+ * @param {number} opts.amount - integer, in rupees
126
+ * @param {string} [opts.currency]
127
+ * @returns {Promise<{ order_id, amount, currency, key_id }>}
128
+ */
129
+ async createOrder({ amount, currency = "INR" }) {
130
+ if (!this.keyId) throw new ConfigError("RazorpayAdapter: keyId is required for createOrder");
131
+ if (!Number.isInteger(amount) || amount <= 0) throw new ConfigError("RazorpayAdapter: amount must be a positive integer");
132
+
133
+ const body = JSON.stringify({
134
+ amount: amount * 100,
135
+ currency: currency.toUpperCase(),
136
+ receipt: "algopay_" + Date.now(),
137
+ });
138
+
139
+ const auth = Buffer.from(`${this.keyId}:${this.keySecret}`).toString("base64");
140
+
141
+ const order = await new Promise((resolve, reject) => {
142
+ const req = https.request({
143
+ hostname: "api.razorpay.com",
144
+ path: "/v1/orders",
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
147
+ }, res => {
148
+ let data = "";
149
+ res.on("data", c => (data += c));
150
+ res.on("end", () => {
151
+ try { resolve(JSON.parse(data)); }
152
+ catch { reject(new ConfigError("RazorpayAdapter: failed to parse order creation response")); }
153
+ });
154
+ });
155
+ req.on("error", err => reject(new ConfigError(`RazorpayAdapter: order request failed — ${err.message}`)));
156
+ req.write(body);
157
+ req.end();
158
+ });
159
+
160
+ if (!order.id) {
161
+ throw new ProviderAuthError("razorpay", `order creation failed: ${JSON.stringify(order)}`);
162
+ }
163
+
164
+ if (this.orderStore) {
165
+ await this.orderStore.set(order.id, { amount, currency: currency.toUpperCase() });
166
+ }
167
+
168
+ return {
169
+ order_id: order.id,
170
+ amount,
171
+ currency: currency.toUpperCase(),
172
+ key_id: this.keyId,
173
+ };
174
+ }
175
+ }
176
+
177
+ module.exports = { RazorpayAdapter };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * AlgoPay Oracle SDK — Stripe Adapter
3
+ *
4
+ * Bridges Stripe payment events to AlgoPay PaymentEvents.
5
+ * The credential layer (APC-1 / oracle signing) is provider-agnostic.
6
+ * This adapter is the provider-specific translation layer for Stripe.
7
+ *
8
+ * Requires the 'stripe' npm package:
9
+ * npm install stripe
10
+ *
11
+ * Usage:
12
+ * const adapter = new StripeAdapter({ webhookSecret: process.env.STRIPE_WEBHOOK_SECRET });
13
+ * const event = adapter.parseWebhook(req.rawBody, req.headers["stripe-signature"]);
14
+ * if (!event) return res.status(401).end();
15
+ * const result = await client.verifyAndCommit(event);
16
+ */
17
+
18
+ const { ProviderAuthError, ConfigError } = require("../errors");
19
+
20
+ class StripeAdapter {
21
+ /**
22
+ * @param {object} opts
23
+ * @param {string} opts.webhookSecret - Stripe webhook signing secret (whsec_...)
24
+ * @param {string} [opts.secretKey] - Stripe secret key (sk_test_... or sk_live_...)
25
+ * @param {string} [opts.defaultAction]
26
+ */
27
+ constructor({ webhookSecret, secretKey, defaultAction = "unlock" } = {}) {
28
+ if (!webhookSecret) throw new ConfigError("StripeAdapter: webhookSecret is required");
29
+ this.webhookSecret = webhookSecret;
30
+ this.defaultAction = defaultAction;
31
+
32
+ // Lazy-load stripe — not a hard SDK dependency
33
+ try {
34
+ const Stripe = require("stripe");
35
+ this.stripe = new Stripe(secretKey || process.env.STRIPE_SECRET_KEY || "");
36
+ } catch {
37
+ this.stripe = null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Parse and verify a Stripe webhook event.
43
+ * Stripe's SDK uses a timing-safe comparison internally.
44
+ * Returns null on non-payment events, throws ProviderAuthError on bad sig.
45
+ *
46
+ * @param {Buffer} rawBody - raw request body (Buffer, not parsed)
47
+ * @param {string} signature - stripe-signature header value
48
+ * @returns {object|null} PaymentEvent or null
49
+ */
50
+ parseWebhook(rawBody, signature) {
51
+ if (!this.stripe) throw new ConfigError("StripeAdapter: install 'stripe' npm package");
52
+
53
+ let event;
54
+ try {
55
+ // Stripe constructEvent uses timing-safe HMAC internally
56
+ event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
57
+ } catch (e) {
58
+ throw new ProviderAuthError("stripe", e.message);
59
+ }
60
+
61
+ // Only handle successful payment intents
62
+ if (event.type !== "payment_intent.succeeded") return null;
63
+
64
+ const intent = event.data.object;
65
+ if (!intent?.id || !intent?.amount) return null;
66
+
67
+ return {
68
+ payment_id: intent.id,
69
+ amount: Math.round(intent.amount / 100), // cents → base unit
70
+ currency: (intent.currency || "usd").toUpperCase(),
71
+ action: this.defaultAction,
72
+ provider: "stripe",
73
+ };
74
+ }
75
+ }
76
+
77
+ module.exports = { StripeAdapter };
package/src/apc1.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * AlgoPay Credential Standard v1 (APC-1)
3
+ *
4
+ * APC-1 is the standardized proof format produced by AlgoPay Oracle.
5
+ * It is provider-agnostic: the same credential format is produced regardless
6
+ * of whether the payment came from Razorpay, Stripe, UPI, or any other gateway.
7
+ *
8
+ * canonical_id is the replay key used on-chain (provider:payment_id).
9
+ * It must appear in the credential so verifiers can reconstruct the signed message.
10
+ *
11
+ * Schema (v1):
12
+ * apc "1" — format version
13
+ * payment_id string — original provider-issued ID (display only)
14
+ * canonical_id string — namespaced replay key: "provider:payment_id"
15
+ * amount integer — fiat amount in currency base units
16
+ * currency string — ISO 4217 (INR, USD, EUR...)
17
+ * action string — intended Web3 action
18
+ * timestamp integer — unix seconds when oracle signed
19
+ * oracle_address string — Algorand address of signing oracle
20
+ * signature string — base64 Ed25519 signature over canonical message
21
+ * chain "algorand"
22
+ * network "localnet"|"testnet"|"mainnet"
23
+ * app_id integer|null — deployed AlgoPayOracle App ID
24
+ * provider string — payment rail label
25
+ */
26
+
27
+ "use strict";
28
+
29
+ const APC_VERSION = "1";
30
+ const SUPPORTED_APC = new Set(["1"]);
31
+
32
+ /**
33
+ * Wrap an internal signed proof as an APC-1 credential.
34
+ * canonical_id is always included — it is the on-chain replay key.
35
+ *
36
+ * @param {object} proof - output of OracleSigner.sign()
37
+ * @param {object} [meta] - { network, appId, provider }
38
+ * @returns {object} APC-1 credential
39
+ */
40
+ function toAPC1(proof, { network = "testnet", appId = null, provider } = {}) {
41
+ return {
42
+ apc: APC_VERSION,
43
+ payment_id: proof.payment_id,
44
+ canonical_id: proof.canonical_id, // the signed replay key — MUST be present
45
+ amount: proof.amount,
46
+ currency: proof.currency,
47
+ action: proof.action,
48
+ timestamp: proof.timestamp,
49
+ oracle_address: proof.oracle_address,
50
+ signature: proof.signature,
51
+ chain: "algorand",
52
+ network,
53
+ app_id: appId ?? null,
54
+ provider: provider ?? proof.provider ?? "unknown",
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Validate APC-1 credential structure and field types.
60
+ * Does NOT verify the cryptographic signature — use ProofVerifier for that.
61
+ *
62
+ * @param {object} cred
63
+ * @returns {{ valid: boolean, errors: string[] }}
64
+ */
65
+ function validateAPC1Structure(cred) {
66
+ const errors = [];
67
+
68
+ if (!SUPPORTED_APC.has(String(cred.apc))) errors.push(`unsupported apc version: "${cred.apc}" (supported: ${[...SUPPORTED_APC].join(", ")})`);
69
+ if (!cred.payment_id || typeof cred.payment_id !== "string")
70
+ errors.push("payment_id must be a non-empty string");
71
+ if (!cred.canonical_id || typeof cred.canonical_id !== "string")
72
+ errors.push("canonical_id must be a non-empty string");
73
+ if (!Number.isInteger(cred.amount) || cred.amount <= 0)
74
+ errors.push("amount must be a positive integer");
75
+ if (typeof cred.currency !== "string" || cred.currency.length !== 3)
76
+ errors.push("currency must be a 3-character ISO code");
77
+ if (!cred.action || typeof cred.action !== "string")
78
+ errors.push("action must be a non-empty string");
79
+ if (!Number.isInteger(cred.timestamp)) errors.push("timestamp must be an integer");
80
+ if (typeof cred.oracle_address !== "string" || cred.oracle_address.length !== 58)
81
+ errors.push("oracle_address must be a valid Algorand address (58 chars)");
82
+ if (!cred.signature || typeof cred.signature !== "string")
83
+ errors.push("signature must be a base64 string");
84
+ if (cred.chain !== "algorand") errors.push("chain must be 'algorand'");
85
+ if (!["localnet", "testnet", "mainnet"].includes(cred.network))
86
+ errors.push("network must be localnet, testnet, or mainnet");
87
+
88
+ return { valid: errors.length === 0, errors };
89
+ }
90
+
91
+ /**
92
+ * Check if a verifier supports this APC-1 version.
93
+ * Allows callers to gate on version before attempting verification.
94
+ *
95
+ * @param {object} cred
96
+ * @returns {boolean}
97
+ */
98
+ function isSupportedVersion(cred) {
99
+ return SUPPORTED_APC.has(String(cred?.apc));
100
+ }
101
+
102
+ /**
103
+ * Check if an APC-1 proof is expired.
104
+ * @param {object} cred
105
+ * @param {number} [maxAgeSecs] - default 300 (5 min)
106
+ */
107
+ function isExpired(cred, maxAgeSecs = 300) {
108
+ const now = Math.floor(Date.now() / 1000);
109
+ return now - cred.timestamp > maxAgeSecs;
110
+ }
111
+
112
+ module.exports = { APC_VERSION, SUPPORTED_APC, toAPC1, validateAPC1Structure, isSupportedVersion, isExpired };
package/src/errors.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * AlgoPay Oracle SDK — Error Classes
3
+ *
4
+ * All SDK errors extend AlgoPayError so callers can catch by base type.
5
+ *
6
+ * Usage:
7
+ * const { AlgoPayError, ProofExpiredError } = require("@algopayoracle/oracle-sdk/errors");
8
+ * try { await client.verifyAndCommit(...) }
9
+ * catch (e) {
10
+ * if (e instanceof ProofExpiredError) { ... }
11
+ * if (e instanceof OracleNotRegisteredError) { ... }
12
+ * }
13
+ */
14
+
15
+ class AlgoPayError extends Error {
16
+ constructor(message, code) {
17
+ super(message);
18
+ this.name = "AlgoPayError";
19
+ this.code = code || "ALGOPAY_ERROR";
20
+ }
21
+ }
22
+
23
+ /** Amount is below the contract's MIN_AMOUNT threshold */
24
+ class InsufficientAmountError extends AlgoPayError {
25
+ constructor(amount, minAmount) {
26
+ super(`Amount ${amount} is below minimum ${minAmount}`, "INSUFFICIENT_AMOUNT");
27
+ this.name = "InsufficientAmountError";
28
+ this.amount = amount;
29
+ this.minAmount = minAmount;
30
+ }
31
+ }
32
+
33
+ /** Proof timestamp is older than PROOF_VALIDITY_SECS */
34
+ class ProofExpiredError extends AlgoPayError {
35
+ constructor(timestamp, now) {
36
+ const age = now - timestamp;
37
+ super(`Proof expired — signed ${age}s ago (max 300s)`, "PROOF_EXPIRED");
38
+ this.name = "ProofExpiredError";
39
+ this.timestamp = timestamp;
40
+ this.age = age;
41
+ }
42
+ }
43
+
44
+ /** oracle_pubkey is not in the contract's oracle registry */
45
+ class OracleNotRegisteredError extends AlgoPayError {
46
+ constructor(address) {
47
+ super(`Oracle ${address} is not registered in the contract`, "ORACLE_NOT_REGISTERED");
48
+ this.name = "OracleNotRegisteredError";
49
+ this.address = address;
50
+ }
51
+ }
52
+
53
+ /** payment_id has already been verified (replay attempt) */
54
+ class ReplayError extends AlgoPayError {
55
+ constructor(paymentId) {
56
+ super(`payment_id "${paymentId}" has already been processed`, "REPLAY_DETECTED");
57
+ this.name = "ReplayError";
58
+ this.paymentId = paymentId;
59
+ }
60
+ }
61
+
62
+ /** Ed25519 signature did not verify */
63
+ class InvalidSignatureError extends AlgoPayError {
64
+ constructor() {
65
+ super("Signature verification failed", "INVALID_SIGNATURE");
66
+ this.name = "InvalidSignatureError";
67
+ }
68
+ }
69
+
70
+ /** Configuration is missing or invalid */
71
+ class ConfigError extends AlgoPayError {
72
+ constructor(message) {
73
+ super(message, "CONFIG_ERROR");
74
+ this.name = "ConfigError";
75
+ }
76
+ }
77
+
78
+ /** Payment provider (Razorpay/Stripe) signature/HMAC check failed */
79
+ class ProviderAuthError extends AlgoPayError {
80
+ constructor(provider, reason) {
81
+ super(`${provider} signature verification failed: ${reason}`, "PROVIDER_AUTH_ERROR");
82
+ this.name = "ProviderAuthError";
83
+ this.provider = provider;
84
+ }
85
+ }
86
+
87
+ module.exports = {
88
+ AlgoPayError,
89
+ InsufficientAmountError,
90
+ ProofExpiredError,
91
+ OracleNotRegisteredError,
92
+ ReplayError,
93
+ InvalidSignatureError,
94
+ ConfigError,
95
+ ProviderAuthError,
96
+ };