@algopayoracle/oracle-sdk 1.0.1 → 1.0.3
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/examples/custom-adapter.js +252 -0
- package/examples/express-webhook.js +390 -0
- package/examples/quickstart.js +67 -0
- package/package.json +2 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @algopayoracle/oracle-sdk — Custom Payment Adapter Example
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates how to integrate any payment gateway with AlgoPay Oracle.
|
|
5
|
+
* The SDK's core (AlgoPayClient) only speaks PaymentEvent.
|
|
6
|
+
* Your adapter is the translation layer between your gateway and PaymentEvent.
|
|
7
|
+
*
|
|
8
|
+
* This file shows three adapters:
|
|
9
|
+
* 1. PayUAdapter — real-world Indian payment gateway
|
|
10
|
+
* 2. PhonePeAdapter — UPI-native gateway
|
|
11
|
+
* 3. GenericAdapter — minimal template to copy for any provider
|
|
12
|
+
*
|
|
13
|
+
* The PaymentAdapter interface (from index.d.ts):
|
|
14
|
+
*
|
|
15
|
+
* interface PaymentAdapter {
|
|
16
|
+
* parseWebhook(rawBody: Buffer | string, signature: string): PaymentEvent | null;
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Rules for a correct adapter:
|
|
20
|
+
* 1. Verify the gateway's own signature/checksum before trusting any field
|
|
21
|
+
* 2. Return null (never throw) on invalid signature or non-payment events
|
|
22
|
+
* 3. Amount must come from the gateway payload — never from the request body directly
|
|
23
|
+
* 4. Set provider so canonical_id = "provider:payment_id" is unique per rail
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
"use strict";
|
|
27
|
+
|
|
28
|
+
const crypto = require("crypto");
|
|
29
|
+
const { AlgoPayClient } = require("../src");
|
|
30
|
+
|
|
31
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
32
|
+
// 1. PayUAdapter — PayU India
|
|
33
|
+
// Docs: https://devguide.payu.in/webhook/webhook-overview/
|
|
34
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
35
|
+
|
|
36
|
+
class PayUAdapter {
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} opts.merchantSalt - PayU merchant salt
|
|
40
|
+
* @param {string} [opts.defaultAction]
|
|
41
|
+
*/
|
|
42
|
+
constructor({ merchantSalt, defaultAction = "unlock" }) {
|
|
43
|
+
if (!merchantSalt) throw new Error("PayUAdapter: merchantSalt is required");
|
|
44
|
+
this.merchantSalt = merchantSalt;
|
|
45
|
+
this.defaultAction = defaultAction;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* PayU sends a hash in the payload for webhook verification.
|
|
50
|
+
* Hash formula (reverse): sha512(salt|status||||||udf5|udf4|udf3|udf2|udf1|email|productinfo|amount|txnid|key)
|
|
51
|
+
*/
|
|
52
|
+
parseWebhook(rawBody, _signature) {
|
|
53
|
+
let body;
|
|
54
|
+
try { body = Object.fromEntries(new URLSearchParams(rawBody.toString())); }
|
|
55
|
+
catch { return null; }
|
|
56
|
+
|
|
57
|
+
if (body.status !== "success") return null;
|
|
58
|
+
if (!body.txnid || !body.amount) return null;
|
|
59
|
+
|
|
60
|
+
// Verify PayU hash (simplified — see PayU docs for full field list)
|
|
61
|
+
const hashString = [
|
|
62
|
+
this.merchantSalt,
|
|
63
|
+
body.status,
|
|
64
|
+
"", "", "", "", // udf5–udf2 (empty if not used)
|
|
65
|
+
body.udf1 || "",
|
|
66
|
+
body.email || "",
|
|
67
|
+
body.productinfo || "",
|
|
68
|
+
body.amount,
|
|
69
|
+
body.txnid,
|
|
70
|
+
body.key,
|
|
71
|
+
].join("|");
|
|
72
|
+
|
|
73
|
+
const expectedHash = crypto.createHash("sha512").update(hashString).digest("hex");
|
|
74
|
+
if (body.hash !== expectedHash) return null;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
payment_id: body.txnid,
|
|
78
|
+
amount: Math.round(Number(body.amount)), // PayU sends as string "100.00"
|
|
79
|
+
currency: "INR",
|
|
80
|
+
action: this.defaultAction,
|
|
81
|
+
provider: "payu",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
87
|
+
// 2. PhonePeAdapter — PhonePe Business
|
|
88
|
+
// Docs: https://developer.phonepe.com/v1/reference/check-status-api
|
|
89
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
90
|
+
|
|
91
|
+
class PhonePeAdapter {
|
|
92
|
+
/**
|
|
93
|
+
* @param {object} opts
|
|
94
|
+
* @param {string} opts.saltKey - PhonePe salt key
|
|
95
|
+
* @param {number} opts.saltIndex - PhonePe salt index (usually 1)
|
|
96
|
+
* @param {string} [opts.defaultAction]
|
|
97
|
+
*/
|
|
98
|
+
constructor({ saltKey, saltIndex = 1, defaultAction = "unlock" }) {
|
|
99
|
+
if (!saltKey) throw new Error("PhonePeAdapter: saltKey is required");
|
|
100
|
+
this.saltKey = saltKey;
|
|
101
|
+
this.saltIndex = saltIndex;
|
|
102
|
+
this.defaultAction = defaultAction;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* PhonePe sends X-VERIFY header: sha256(base64payload + "/pg/v1/pay" + salt) + "###" + saltIndex
|
|
107
|
+
*/
|
|
108
|
+
parseWebhook(rawBody, xVerifyHeader) {
|
|
109
|
+
if (!xVerifyHeader) return null;
|
|
110
|
+
|
|
111
|
+
const [receivedHash, receivedIndex] = xVerifyHeader.split("###");
|
|
112
|
+
if (Number(receivedIndex) !== this.saltIndex) return null;
|
|
113
|
+
|
|
114
|
+
const base64Body = Buffer.from(rawBody).toString("base64");
|
|
115
|
+
const hashInput = base64Body + "/pg/v1/pay" + this.saltKey;
|
|
116
|
+
const expectedHash = crypto.createHash("sha256").update(hashInput).digest("hex");
|
|
117
|
+
|
|
118
|
+
if (receivedHash !== expectedHash) return null;
|
|
119
|
+
|
|
120
|
+
let parsed;
|
|
121
|
+
try { parsed = JSON.parse(Buffer.from(base64Body, "base64").toString()); }
|
|
122
|
+
catch { return null; }
|
|
123
|
+
|
|
124
|
+
const data = parsed?.data;
|
|
125
|
+
if (!data?.merchantTransactionId || !data?.amount) return null;
|
|
126
|
+
if (parsed?.code !== "PAYMENT_SUCCESS") return null;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
payment_id: data.merchantTransactionId,
|
|
130
|
+
amount: Math.round(data.amount / 100), // PhonePe sends in paise
|
|
131
|
+
currency: "INR",
|
|
132
|
+
action: this.defaultAction,
|
|
133
|
+
provider: "phonepe",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
139
|
+
// 3. GenericAdapter — minimal copy-paste template
|
|
140
|
+
//
|
|
141
|
+
// Steps to adapt for any gateway:
|
|
142
|
+
// a) Replace verifySignature() with your gateway's HMAC/checksum method
|
|
143
|
+
// b) Map gateway fields → payment_id, amount, currency
|
|
144
|
+
// c) Set provider to a unique lowercase label
|
|
145
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
146
|
+
|
|
147
|
+
class GenericAdapter {
|
|
148
|
+
constructor({ secret, defaultAction = "unlock" }) {
|
|
149
|
+
this.secret = secret;
|
|
150
|
+
this.defaultAction = defaultAction;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
parseWebhook(rawBody, signature) {
|
|
154
|
+
// a) Verify signature — replace this with your gateway's method
|
|
155
|
+
if (!this._verifySignature(rawBody, signature)) return null;
|
|
156
|
+
|
|
157
|
+
let body;
|
|
158
|
+
try { body = JSON.parse(rawBody.toString()); }
|
|
159
|
+
catch { return null; }
|
|
160
|
+
|
|
161
|
+
// b) Only process payment success events
|
|
162
|
+
if (body.event_type !== "payment.success") return null;
|
|
163
|
+
|
|
164
|
+
// c) Map to PaymentEvent
|
|
165
|
+
return {
|
|
166
|
+
payment_id: body.transaction_id, // gateway's unique ID
|
|
167
|
+
amount: Math.round(Number(body.amount)), // always integer
|
|
168
|
+
currency: (body.currency || "INR").toUpperCase(),
|
|
169
|
+
action: this.defaultAction,
|
|
170
|
+
provider: "mygateway", // your unique label
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_verifySignature(rawBody, signature) {
|
|
175
|
+
// Replace with your gateway's actual verification
|
|
176
|
+
const expected = crypto
|
|
177
|
+
.createHmac("sha256", this.secret)
|
|
178
|
+
.update(rawBody)
|
|
179
|
+
.digest("hex");
|
|
180
|
+
const received = Buffer.from(signature || "", "hex");
|
|
181
|
+
if (expected.length !== received.length) return false;
|
|
182
|
+
return crypto.timingSafeEqual(Buffer.from(expected, "hex"), received);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
187
|
+
// Wiring into Express (same pattern for all adapters)
|
|
188
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
189
|
+
|
|
190
|
+
function wireAdapter(app, path, adapter, client, limiter) {
|
|
191
|
+
app.post(path, limiter, async (req, res) => {
|
|
192
|
+
// Every adapter implements the same parseWebhook interface
|
|
193
|
+
const event = adapter.parseWebhook(
|
|
194
|
+
req.rawBody,
|
|
195
|
+
req.headers["x-signature"] || req.headers["x-verify"] || req.headers["x-razorpay-signature"]
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (!event) return res.status(401).json({ error: "invalid webhook signature" });
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const result = await client.verifyAndCommit(event);
|
|
202
|
+
res.json({ received: true, txId: result.txId });
|
|
203
|
+
} catch (e) {
|
|
204
|
+
res.status(500).json({ error: e.message });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
// Demo: all three adapters wired to the same client
|
|
211
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
if (require.main === module) {
|
|
214
|
+
require("dotenv").config();
|
|
215
|
+
|
|
216
|
+
const express = require("express");
|
|
217
|
+
const app = express();
|
|
218
|
+
const client = new AlgoPayClient({
|
|
219
|
+
mnemonic: process.env.ORACLE_MNEMONIC,
|
|
220
|
+
network: process.env.ALGO_NETWORK || "testnet",
|
|
221
|
+
appId: process.env.ALGO_APP_ID ? Number(process.env.ALGO_APP_ID) : null,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Raw body middleware
|
|
225
|
+
app.use((req, _res, next) => {
|
|
226
|
+
const chunks = [];
|
|
227
|
+
req.on("data", c => chunks.push(c));
|
|
228
|
+
req.on("end", () => {
|
|
229
|
+
req.rawBody = Buffer.concat(chunks);
|
|
230
|
+
try { req.body = JSON.parse(req.rawBody); } catch { req.body = {}; }
|
|
231
|
+
next();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const noopLimiter = (_req, _res, next) => next(); // replace with express-rate-limit in production
|
|
236
|
+
|
|
237
|
+
if (process.env.PAYU_SALT) {
|
|
238
|
+
const payu = new PayUAdapter({ merchantSalt: process.env.PAYU_SALT });
|
|
239
|
+
wireAdapter(app, "/webhook/payu", payu, client, noopLimiter);
|
|
240
|
+
console.log("PayU adapter wired → POST /webhook/payu");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (process.env.PHONEPE_SALT) {
|
|
244
|
+
const phonepe = new PhonePeAdapter({ saltKey: process.env.PHONEPE_SALT });
|
|
245
|
+
wireAdapter(app, "/webhook/phonepe", phonepe, client, noopLimiter);
|
|
246
|
+
console.log("PhonePe adapter wired → POST /webhook/phonepe");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
app.listen(5000, () => console.log("Custom adapter demo → http://localhost:5000"));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { PayUAdapter, PhonePeAdapter, GenericAdapter };
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @algopayoracle/oracle-sdk — Production Express Server Example
|
|
3
|
+
*
|
|
4
|
+
* This is the reference implementation for integrating the SDK into a
|
|
5
|
+
* production Express backend. Copy and adapt to your project.
|
|
6
|
+
*
|
|
7
|
+
* TWO SERVERS:
|
|
8
|
+
* Public (PORT, 0.0.0.0) — webhooks, payment verify, proof lookup
|
|
9
|
+
* Admin (ADMIN_PORT, 127.0.0.1) — oracle rotation, stats
|
|
10
|
+
*
|
|
11
|
+
* Run:
|
|
12
|
+
* node examples/express-webhook.js
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
"use strict";
|
|
16
|
+
require("dotenv").config();
|
|
17
|
+
|
|
18
|
+
const express = require("express");
|
|
19
|
+
const cors = require("cors");
|
|
20
|
+
const rateLimit = require("express-rate-limit");
|
|
21
|
+
const crypto = require("crypto");
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
AlgoPayClient,
|
|
25
|
+
RazorpayAdapter,
|
|
26
|
+
createLogger,
|
|
27
|
+
createOrderStore,
|
|
28
|
+
} = require("../src");
|
|
29
|
+
|
|
30
|
+
// ─── Environment validation ───────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
|
33
|
+
const DEMO_MODE = process.env.DEMO_MODE === "true" && !IS_PRODUCTION;
|
|
34
|
+
|
|
35
|
+
const log = createLogger("server");
|
|
36
|
+
|
|
37
|
+
function requireEnv(name) {
|
|
38
|
+
const val = process.env[name];
|
|
39
|
+
if (!val) { log.error(`${name} is required but not set`); process.exit(1); }
|
|
40
|
+
return val;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
requireEnv("ORACLE_MNEMONIC");
|
|
44
|
+
if (IS_PRODUCTION) {
|
|
45
|
+
requireEnv("ADMIN_API_KEY");
|
|
46
|
+
requireEnv("ALLOWED_ORIGINS");
|
|
47
|
+
} else {
|
|
48
|
+
if (!process.env.ADMIN_API_KEY) log.warn("ADMIN_API_KEY not set — admin server unprotected");
|
|
49
|
+
if (!process.env.ALGO_APP_ID) log.warn("ALGO_APP_ID not set — running in anchor mode");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Oracle client ────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const client = new AlgoPayClient({
|
|
55
|
+
mnemonic: process.env.ORACLE_MNEMONIC,
|
|
56
|
+
network: process.env.ALGO_NETWORK || "testnet",
|
|
57
|
+
appId: process.env.ALGO_APP_ID ? Number(process.env.ALGO_APP_ID) : null,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
log.info("Oracle initialised", {
|
|
61
|
+
address: client.getAddress(),
|
|
62
|
+
network: process.env.ALGO_NETWORK || "testnet",
|
|
63
|
+
app_id: client.appId ?? "anchor mode",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── Order store ──────────────────────────────────────────────────────────────
|
|
67
|
+
// In-memory by default. Set REDIS_URL to use Redis (see store.js for interface).
|
|
68
|
+
|
|
69
|
+
const orderStore = createOrderStore();
|
|
70
|
+
|
|
71
|
+
// ─── Razorpay adapter (optional) ──────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const razorpay = (process.env.RAZORPAY_KEY_ID && process.env.RAZORPAY_KEY_SECRET)
|
|
74
|
+
? new RazorpayAdapter({
|
|
75
|
+
keyId: process.env.RAZORPAY_KEY_ID,
|
|
76
|
+
keySecret: process.env.RAZORPAY_KEY_SECRET,
|
|
77
|
+
orderStore, // shared so createOrder amounts are enforced in parseClientPayment
|
|
78
|
+
})
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
// ─── Shared middleware ─────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const MAX_BODY = 512 * 1024;
|
|
84
|
+
|
|
85
|
+
function rawBody(req, res, next) {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
req.on("data", chunk => {
|
|
89
|
+
size += chunk.length;
|
|
90
|
+
if (size > MAX_BODY) { req.destroy(); return res.status(413).json({ error: "body too large" }); }
|
|
91
|
+
chunks.push(chunk);
|
|
92
|
+
});
|
|
93
|
+
req.on("end", () => {
|
|
94
|
+
req.rawBody = Buffer.concat(chunks);
|
|
95
|
+
try { req.body = JSON.parse(req.rawBody.toString()); } catch { req.body = {}; }
|
|
96
|
+
next();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function requestLogger(req, res, next) {
|
|
101
|
+
req.requestId = crypto.randomUUID();
|
|
102
|
+
req.log = log.child({ requestId: req.requestId });
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
res.on("finish", () => {
|
|
105
|
+
const level = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info";
|
|
106
|
+
req.log[level](`${req.method} ${req.path}`, { status: res.statusCode, ms: Date.now() - start });
|
|
107
|
+
});
|
|
108
|
+
next();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function requireAdminKey(req, res, next) {
|
|
112
|
+
const key = process.env.ADMIN_API_KEY;
|
|
113
|
+
if (!key) return res.status(503).json({ error: "ADMIN_API_KEY not configured" });
|
|
114
|
+
if (req.headers["x-admin-key"] !== key) return res.status(401).json({ error: "unauthorized" });
|
|
115
|
+
next();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sendError(res, status, message, err, reqLog) {
|
|
119
|
+
const logger = reqLog || log;
|
|
120
|
+
if (err) logger.error(message, { error: err.message });
|
|
121
|
+
res.status(status).json({ error: IS_PRODUCTION ? message : (err?.message || message) });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Rate limiters ────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
const limiterDefault = rateLimit({
|
|
127
|
+
windowMs: 60 * 1000, max: 60,
|
|
128
|
+
standardHeaders: true, legacyHeaders: false,
|
|
129
|
+
message: { error: "too many requests" },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const limiterPayment = rateLimit({
|
|
133
|
+
windowMs: 60 * 1000, max: 10, // each verify-payment triggers an on-chain tx
|
|
134
|
+
standardHeaders: true, legacyHeaders: false,
|
|
135
|
+
message: { error: "too many payment requests" },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const limiterWebhook = rateLimit({
|
|
139
|
+
windowMs: 60 * 1000, max: 120, // higher — legitimate burst from provider is possible
|
|
140
|
+
standardHeaders: true, legacyHeaders: false,
|
|
141
|
+
message: { error: "too many requests" },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
145
|
+
// PUBLIC SERVER
|
|
146
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
147
|
+
|
|
148
|
+
const publicApp = express();
|
|
149
|
+
|
|
150
|
+
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
|
151
|
+
? process.env.ALLOWED_ORIGINS.split(",").map(s => s.trim())
|
|
152
|
+
: ["http://localhost:5173", "http://localhost:3000"];
|
|
153
|
+
|
|
154
|
+
publicApp.use(cors({ origin: allowedOrigins, methods: ["GET", "POST"] }));
|
|
155
|
+
publicApp.use(requestLogger);
|
|
156
|
+
publicApp.use(rawBody);
|
|
157
|
+
|
|
158
|
+
publicApp.get("/health", limiterDefault, async (_req, res) => {
|
|
159
|
+
try {
|
|
160
|
+
const status = await client.algod.status().do();
|
|
161
|
+
res.json({ ok: true, network: client.network, round: Number(status["last-round"]), demo_mode: DEMO_MODE });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
res.status(503).json({ ok: false, error: IS_PRODUCTION ? "algod unreachable" : e.message });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
publicApp.get("/oracle/info", limiterDefault, (_req, res) => {
|
|
168
|
+
res.json({ address: client.getAddress(), pubkey_base64: client.getPublicKeyBase64(), network: client.network, app_id: client.appId });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// POST /create-order
|
|
172
|
+
// Step 1 of payment flow — get an order ID before opening checkout UI.
|
|
173
|
+
// Amount is stored server-side here and enforced in /verify-payment.
|
|
174
|
+
publicApp.post("/create-order", limiterPayment, async (req, res) => {
|
|
175
|
+
const amount = Math.round(Number(req.body.amount || 100));
|
|
176
|
+
const currency = (req.body.currency || "INR").toUpperCase();
|
|
177
|
+
|
|
178
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
179
|
+
return res.status(400).json({ error: "amount must be a positive integer" });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (razorpay) {
|
|
183
|
+
try {
|
|
184
|
+
// createOrder writes to orderStore internally via the shared reference
|
|
185
|
+
const order = await razorpay.createOrder({ amount, currency });
|
|
186
|
+
req.log.info("order created", { order_id: order.order_id, amount, provider: "razorpay" });
|
|
187
|
+
return res.json({ provider: "razorpay", ...order });
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return sendError(res, 502, "order creation failed", e, req.log);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (DEMO_MODE) {
|
|
194
|
+
const order_id = `demo_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
|
|
195
|
+
await orderStore.set(order_id, { amount, currency });
|
|
196
|
+
req.log.info("demo order created", { order_id, amount });
|
|
197
|
+
return res.json({ provider: "demo", order_id, amount, currency, key_id: null });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
res.status(503).json({
|
|
201
|
+
error: "no payment provider configured",
|
|
202
|
+
hint: IS_PRODUCTION ? "configure RAZORPAY_KEY_ID/SECRET" : "set DEMO_MODE=true for local dev",
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// POST /webhook/razorpay — server-to-server from Razorpay on payment.captured
|
|
207
|
+
publicApp.post("/webhook/razorpay", limiterWebhook, async (req, res) => {
|
|
208
|
+
if (!razorpay) return res.status(503).json({ error: "Razorpay not configured" });
|
|
209
|
+
|
|
210
|
+
const event = razorpay.parseWebhook(req.rawBody, req.headers["x-razorpay-signature"]);
|
|
211
|
+
if (!event) {
|
|
212
|
+
req.log.warn("webhook rejected — invalid signature");
|
|
213
|
+
return res.status(401).json({ error: "invalid signature" });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
req.log.info("webhook received", { payment_id: event.payment_id, amount: event.amount });
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const result = await client.verifyAndCommit(event);
|
|
220
|
+
req.log.info("webhook committed", { payment_id: event.payment_id, txId: result.txId });
|
|
221
|
+
res.json({ received: true, txId: result.txId });
|
|
222
|
+
} catch (e) {
|
|
223
|
+
sendError(res, 500, "oracle submission failed", e, req.log);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// POST /verify-payment — frontend calls this after checkout success
|
|
228
|
+
// Amount is ALWAYS resolved from orderStore — never from request body.
|
|
229
|
+
publicApp.post("/verify-payment", limiterPayment, async (req, res) => {
|
|
230
|
+
const { razorpay_order_id, razorpay_payment_id, razorpay_signature, order_id, action = "unlock" } = req.body;
|
|
231
|
+
const isRazorpay = razorpay_payment_id && razorpay_signature;
|
|
232
|
+
let event;
|
|
233
|
+
|
|
234
|
+
if (isRazorpay) {
|
|
235
|
+
if (!razorpay) return res.status(503).json({ success: false, error: "Razorpay not configured" });
|
|
236
|
+
try {
|
|
237
|
+
// parseClientPayment: verifies HMAC, resolves amount from orderStore (never client body)
|
|
238
|
+
event = await razorpay.parseClientPayment({ razorpay_order_id, razorpay_payment_id, razorpay_signature, action });
|
|
239
|
+
} catch (e) {
|
|
240
|
+
req.log.warn("client payment rejected", { error: e.message });
|
|
241
|
+
return res.status(401).json({ success: false, error: e.message });
|
|
242
|
+
}
|
|
243
|
+
req.log.info("Razorpay payment verified", { payment_id: event.payment_id, amount: event.amount });
|
|
244
|
+
|
|
245
|
+
} else {
|
|
246
|
+
if (!DEMO_MODE) {
|
|
247
|
+
return res.status(503).json({
|
|
248
|
+
success: false,
|
|
249
|
+
error: IS_PRODUCTION ? "demo mode disabled in production" : "set DEMO_MODE=true",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const effectiveId = razorpay_order_id || order_id;
|
|
253
|
+
const record = await orderStore.consume(effectiveId);
|
|
254
|
+
if (!record) return res.status(400).json({ success: false, error: "order not found or expired — call /create-order first" });
|
|
255
|
+
|
|
256
|
+
await new Promise(r => setTimeout(r, 600));
|
|
257
|
+
event = { payment_id: `demo_${Date.now()}`, amount: record.amount, currency: record.currency, action, provider: "demo" };
|
|
258
|
+
req.log.info("demo payment", { amount: event.amount, action });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const result = await client.verifyAndCommit(event);
|
|
263
|
+
req.log.info("payment committed on-chain", { payment_id: event.payment_id, txId: result.txId });
|
|
264
|
+
res.json({ success: true, ...result });
|
|
265
|
+
} catch (e) {
|
|
266
|
+
sendError(res, 500, "oracle submission failed", e, req.log);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// GET /verify-proof/:txId — frontend polls this to confirm on-chain proof
|
|
271
|
+
publicApp.get("/verify-proof/:txId", limiterDefault, async (req, res) => {
|
|
272
|
+
try {
|
|
273
|
+
const result = await client.verifyProof(req.params.txId);
|
|
274
|
+
res.json(result);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
sendError(res, 500, "verification failed", e, req.log);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ─── Generic gateway (any provider pattern — documented, not wired) ───────────
|
|
281
|
+
//
|
|
282
|
+
// publicApp.post("/webhook/payu", limiterWebhook, async (req, res) => {
|
|
283
|
+
// if (!verifyPayUChecksum(req.rawBody, req.headers["x-payu-checksum"])) {
|
|
284
|
+
// return res.status(401).end();
|
|
285
|
+
// }
|
|
286
|
+
// const result = await client.verifyAndCommit({
|
|
287
|
+
// payment_id: req.body.mihpayid,
|
|
288
|
+
// amount: Math.round(Number(req.body.amount)),
|
|
289
|
+
// currency: "INR",
|
|
290
|
+
// action: "unlock",
|
|
291
|
+
// provider: "payu",
|
|
292
|
+
// });
|
|
293
|
+
// res.json({ received: true, txId: result.txId });
|
|
294
|
+
// });
|
|
295
|
+
|
|
296
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
297
|
+
// ADMIN SERVER (127.0.0.1 only)
|
|
298
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
299
|
+
|
|
300
|
+
const adminApp = express();
|
|
301
|
+
const adminLog = log.child({ server: "admin" });
|
|
302
|
+
|
|
303
|
+
adminApp.use(rawBody);
|
|
304
|
+
adminApp.use(requireAdminKey);
|
|
305
|
+
|
|
306
|
+
adminApp.get("/status", async (_req, res) => {
|
|
307
|
+
const info = {
|
|
308
|
+
address: client.getAddress(),
|
|
309
|
+
network: client.network,
|
|
310
|
+
app_id: client.appId,
|
|
311
|
+
demo_mode: DEMO_MODE,
|
|
312
|
+
node_env: process.env.NODE_ENV || "development",
|
|
313
|
+
order_store: { type: "in-memory", size: orderStore.size?.() ?? "unknown" },
|
|
314
|
+
};
|
|
315
|
+
if (client.appId) {
|
|
316
|
+
try {
|
|
317
|
+
info.total_verified = await client.getTotalVerified();
|
|
318
|
+
info.oracle_count = await client.getOracleCount();
|
|
319
|
+
} catch (e) {
|
|
320
|
+
info.contract_error = IS_PRODUCTION ? "unreachable" : e.message;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
res.json(info);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
adminApp.post("/oracle/add", async (req, res) => {
|
|
327
|
+
const { address } = req.body;
|
|
328
|
+
if (!address) return res.status(400).json({ error: "address required" });
|
|
329
|
+
try {
|
|
330
|
+
const txId = await client.addOracle(address);
|
|
331
|
+
adminLog.info("oracle added", { address, txId });
|
|
332
|
+
res.json({ success: true, txId, added: address });
|
|
333
|
+
} catch (e) {
|
|
334
|
+
adminLog.error("addOracle failed", { address, error: e.message });
|
|
335
|
+
res.status(500).json({ success: false, error: e.message });
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
adminApp.post("/oracle/remove", async (req, res) => {
|
|
340
|
+
const { address } = req.body;
|
|
341
|
+
if (!address) return res.status(400).json({ error: "address required" });
|
|
342
|
+
try {
|
|
343
|
+
const txId = await client.removeOracle(address);
|
|
344
|
+
adminLog.info("oracle removed", { address, txId });
|
|
345
|
+
res.json({ success: true, txId, removed: address });
|
|
346
|
+
} catch (e) {
|
|
347
|
+
adminLog.error("removeOracle failed", { address, error: e.message });
|
|
348
|
+
res.status(500).json({ success: false, error: e.message });
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
adminApp.get("/oracle/check", async (req, res) => {
|
|
353
|
+
const { address } = req.query;
|
|
354
|
+
if (!address) return res.status(400).json({ error: "address query param required" });
|
|
355
|
+
try {
|
|
356
|
+
const registered = await client.isOracleRegistered(address);
|
|
357
|
+
res.json({ address, registered });
|
|
358
|
+
} catch (e) {
|
|
359
|
+
res.status(500).json({ error: e.message });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ─── Shutdown ─────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
function shutdown(signal) {
|
|
366
|
+
log.info(`${signal} — shutting down`);
|
|
367
|
+
orderStore.destroy?.();
|
|
368
|
+
process.exit(0);
|
|
369
|
+
}
|
|
370
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
371
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
372
|
+
|
|
373
|
+
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
const PUBLIC_PORT = Number(process.env.PORT || 5000);
|
|
376
|
+
const ADMIN_PORT = Number(process.env.ADMIN_PORT || 5001);
|
|
377
|
+
|
|
378
|
+
publicApp.listen(PUBLIC_PORT, () => {
|
|
379
|
+
log.info("Public server started", { port: PUBLIC_PORT, bind: "0.0.0.0" });
|
|
380
|
+
log.info(`Razorpay: ${razorpay ? "configured" : "not configured"}`);
|
|
381
|
+
log.info(`Demo mode: ${DEMO_MODE}`);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
adminApp.listen(ADMIN_PORT, "127.0.0.1", () => {
|
|
385
|
+
log.info("Admin server started", { port: ADMIN_PORT, bind: "127.0.0.1" });
|
|
386
|
+
if (IS_PRODUCTION && !process.env.ADMIN_API_KEY) {
|
|
387
|
+
log.error("ADMIN_API_KEY required in production");
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @algopayoracle/oracle-sdk — Quickstart
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates anchor mode (no contract needed).
|
|
5
|
+
* A real payment proof is signed and anchored on Algorand TestNet.
|
|
6
|
+
*
|
|
7
|
+
* Run:
|
|
8
|
+
* ORACLE_MNEMONIC="your 25 words" node examples/quickstart.js
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
require("dotenv").config();
|
|
12
|
+
const { AlgoPayClient, OracleSigner } = require("../src");
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
const mnemonic = process.env.ORACLE_MNEMONIC;
|
|
16
|
+
if (!mnemonic) {
|
|
17
|
+
console.error("Set ORACLE_MNEMONIC in your environment or .env file");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── 1. Inspect oracle identity ─────────────────────────────────────────
|
|
22
|
+
const signer = new OracleSigner(mnemonic);
|
|
23
|
+
console.log("\n🔑 Oracle address :", signer.getAddress());
|
|
24
|
+
console.log("📦 Oracle pubkey :", signer.getPublicKeyBase64());
|
|
25
|
+
console.log(" (paste this into AlgoPayOracle.py create() call)\n");
|
|
26
|
+
|
|
27
|
+
// ── 2. Create client in anchor mode (no appId) ─────────────────────────
|
|
28
|
+
// Anchor mode: proof is stored in the note field of a 0-ALGO self-payment.
|
|
29
|
+
// Use this while testing before contract deployment.
|
|
30
|
+
const client = new AlgoPayClient({
|
|
31
|
+
mnemonic,
|
|
32
|
+
network: "testnet",
|
|
33
|
+
// appId: Number(process.env.ALGO_APP_ID), // uncomment after deployment
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ── 3. Sign a payment proof ────────────────────────────────────────────
|
|
37
|
+
const proof = client.signer.sign({
|
|
38
|
+
payment_id: "demo_" + Date.now(),
|
|
39
|
+
amount: 100,
|
|
40
|
+
action: "unlock",
|
|
41
|
+
currency: "INR",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
console.log("✍️ Signed proof:");
|
|
45
|
+
console.log(JSON.stringify(proof, null, 2));
|
|
46
|
+
|
|
47
|
+
// ── 4. Verify offline (no network) ────────────────────────────────────
|
|
48
|
+
const offchainResult = client.verifyProofOffchain(proof);
|
|
49
|
+
console.log("\n🔍 Off-chain verify:", offchainResult.valid ? "✅ valid" : "❌ " + offchainResult.reason);
|
|
50
|
+
|
|
51
|
+
// ── 5. Submit to Algorand ─────────────────────────────────────────────
|
|
52
|
+
console.log("\n⛓ Submitting to Algorand TestNet...");
|
|
53
|
+
const result = await client.verifyAndCommit({
|
|
54
|
+
payment_id: proof.payment_id,
|
|
55
|
+
amount: proof.amount,
|
|
56
|
+
action: proof.action,
|
|
57
|
+
currency: proof.currency,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
console.log("\n✅ Success!");
|
|
61
|
+
console.log(" txId :", result.txId);
|
|
62
|
+
console.log(" Explorer :", result.explorerUrl);
|
|
63
|
+
console.log("\n📋 APC-1 Credential:");
|
|
64
|
+
console.log(JSON.stringify(result.apc1, null, 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main().catch(e => { console.error("Error:", e.message); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@algopayoracle/oracle-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Programmable payment oracle SDK — bridge fiat payments to Algorand smart contracts",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"files": [
|
|
7
7
|
"src",
|
|
8
|
+
"examples",
|
|
8
9
|
"README.md"
|
|
9
10
|
],
|
|
10
11
|
"scripts": {
|