@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.
- package/README.md +397 -0
- package/package.json +60 -0
- package/src/AlgoPayClient.js +298 -0
- package/src/OracleSigner.js +142 -0
- package/src/ProofVerifier.js +132 -0
- package/src/adapters/index.js +4 -0
- package/src/adapters/razorpay.js +177 -0
- package/src/adapters/stripe.js +77 -0
- package/src/apc1.js +112 -0
- package/src/errors.js +96 -0
- package/src/index.d.ts +373 -0
- package/src/index.js +99 -0
- package/src/networks.js +66 -0
- package/src/utils/logger.js +98 -0
- package/src/utils/store.js +119 -0
- package/src/validate.js +105 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlgoPay Oracle SDK — AlgoPayClient
|
|
3
|
+
*
|
|
4
|
+
* Main entry point. Wraps OracleSigner + Algorand submission + ProofVerifier.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - Provider-agnostic at the credential layer: verifyAndCommit() accepts a
|
|
8
|
+
* PaymentEvent from any adapter (Razorpay, Stripe, UPI, manual).
|
|
9
|
+
* - The payment rail (how money moved) is the adapter's concern.
|
|
10
|
+
* - The credential (proof of payment) is always APC-1.
|
|
11
|
+
* - One canonical proof path — no legacy branches, no format drift.
|
|
12
|
+
*
|
|
13
|
+
* Quick start:
|
|
14
|
+
* const client = new AlgoPayClient({
|
|
15
|
+
* mnemonic: process.env.ORACLE_MNEMONIC,
|
|
16
|
+
* network: "testnet",
|
|
17
|
+
* appId: Number(process.env.ALGO_APP_ID),
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* const result = await client.verifyAndCommit({
|
|
21
|
+
* payment_id: "pay_XXXXXXX",
|
|
22
|
+
* amount: 100,
|
|
23
|
+
* action: "unlock",
|
|
24
|
+
* provider: "razorpay", // optional — enables cross-provider replay protection
|
|
25
|
+
* });
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const algosdk = require("algosdk");
|
|
29
|
+
const { OracleSigner } = require("./OracleSigner");
|
|
30
|
+
const { ProofVerifier } = require("./ProofVerifier");
|
|
31
|
+
const { createClients } = require("./networks");
|
|
32
|
+
const { toAPC1 } = require("./apc1");
|
|
33
|
+
const { ConfigError } = require("./errors");
|
|
34
|
+
|
|
35
|
+
class AlgoPayClient {
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} opts.mnemonic - 25-word oracle account mnemonic
|
|
39
|
+
* @param {"localnet"|"testnet"|"mainnet"} [opts.network]
|
|
40
|
+
* @param {number} [opts.appId] - deployed AlgoPayOracle App ID
|
|
41
|
+
* @param {algosdk.Algodv2} [opts.algod] - custom algod (overrides network)
|
|
42
|
+
* @param {algosdk.Indexer} [opts.indexer] - custom indexer
|
|
43
|
+
* @param {string} [opts.explorerBase] - custom explorer URL
|
|
44
|
+
*/
|
|
45
|
+
constructor({ mnemonic, network = "testnet", appId = null, algod, indexer, explorerBase } = {}) {
|
|
46
|
+
if (!mnemonic) throw new ConfigError("mnemonic is required");
|
|
47
|
+
|
|
48
|
+
this.network = network;
|
|
49
|
+
this.appId = appId;
|
|
50
|
+
this.signer = new OracleSigner(mnemonic);
|
|
51
|
+
|
|
52
|
+
if (algod && indexer) {
|
|
53
|
+
this.algod = algod;
|
|
54
|
+
this.indexer = indexer;
|
|
55
|
+
this.explorerBase = explorerBase || "";
|
|
56
|
+
} else {
|
|
57
|
+
const clients = createClients(network);
|
|
58
|
+
this.algod = clients.algod;
|
|
59
|
+
this.indexer = clients.indexer;
|
|
60
|
+
this.explorerBase = explorerBase || clients.config.explorerBase;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.verifier = new ProofVerifier({ indexer: this.indexer, network });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Core API ──────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sign a payment proof and commit it to Algorand.
|
|
70
|
+
* This is the only method most integrations need.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} payment
|
|
73
|
+
* @param {string} payment.payment_id - provider-issued payment reference
|
|
74
|
+
* @param {number} payment.amount - integer fiat amount
|
|
75
|
+
* @param {string} [payment.action] - "unlock" | "mint" | "vote" | any string
|
|
76
|
+
* @param {string} [payment.currency] - ISO 4217 (default: "INR")
|
|
77
|
+
* @param {string} [payment.provider] - payment rail label (enables namespaced replay protection)
|
|
78
|
+
*
|
|
79
|
+
* @returns {Promise<{
|
|
80
|
+
* txId: string, — confirmed Algorand transaction ID
|
|
81
|
+
* proof: object, — internal signed proof
|
|
82
|
+
* apc1: object, — APC-1 standardized credential
|
|
83
|
+
* explorerUrl: string,
|
|
84
|
+
* verifyUrl: string,
|
|
85
|
+
* access_seconds: number
|
|
86
|
+
* }>}
|
|
87
|
+
*/
|
|
88
|
+
async verifyAndCommit({ payment_id, amount, action = "unlock", currency = "INR", provider = "unknown" }) {
|
|
89
|
+
const proof = this.signer.sign({ payment_id, amount, action, currency, provider }, this.appId);
|
|
90
|
+
const txId = await this._submitProof(proof);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
txId,
|
|
94
|
+
proof,
|
|
95
|
+
apc1: toAPC1(proof, { network: this.network, appId: this.appId, provider }),
|
|
96
|
+
explorerUrl: `${this.explorerBase}/transaction/${txId}`,
|
|
97
|
+
verifyUrl: `/verify-proof/${txId}`,
|
|
98
|
+
access_seconds: 300,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Verify a proof by txId (indexer lookup).
|
|
104
|
+
* @param {string} txId
|
|
105
|
+
* @param {object} [opts]
|
|
106
|
+
*/
|
|
107
|
+
async verifyProof(txId, opts = {}) {
|
|
108
|
+
return this.verifier.verifyTxn(txId, opts);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Verify a proof object offline (no network).
|
|
113
|
+
* @param {object} proof
|
|
114
|
+
* @param {object} [opts]
|
|
115
|
+
*/
|
|
116
|
+
verifyProofOffchain(proof, opts = {}) {
|
|
117
|
+
return this.verifier.verifyOffchain(proof, opts);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Oracle rotation ───────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Register a new oracle in the contract. Creator-only.
|
|
124
|
+
* @param {string} addressOrBase64 - Algorand address or base64 pubkey
|
|
125
|
+
* @returns {Promise<string>} txId
|
|
126
|
+
*/
|
|
127
|
+
async addOracle(addressOrBase64) {
|
|
128
|
+
if (!this.appId) throw new ConfigError("appId required for oracle management");
|
|
129
|
+
return this._oracleAdminCall("add_oracle", AlgoPayClient._toPubKeyBytes(addressOrBase64));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Deregister an oracle. Creator-only. Cannot remove the last oracle.
|
|
134
|
+
* @param {string} addressOrBase64
|
|
135
|
+
* @returns {Promise<string>} txId
|
|
136
|
+
*/
|
|
137
|
+
async removeOracle(addressOrBase64) {
|
|
138
|
+
if (!this.appId) throw new ConfigError("appId required for oracle management");
|
|
139
|
+
return this._oracleAdminCall("remove_oracle", AlgoPayClient._toPubKeyBytes(addressOrBase64));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if an oracle pubkey is registered in the contract.
|
|
144
|
+
* @param {string} addressOrBase64
|
|
145
|
+
* @returns {Promise<boolean>}
|
|
146
|
+
*/
|
|
147
|
+
async isOracleRegistered(addressOrBase64) {
|
|
148
|
+
if (!this.appId) throw new ConfigError("appId required");
|
|
149
|
+
const pubkey = AlgoPayClient._toPubKeyBytes(addressOrBase64);
|
|
150
|
+
const params = await this.algod.getTransactionParams().do();
|
|
151
|
+
const method = new algosdk.ABIMethod({
|
|
152
|
+
name: "is_oracle", args: [{ name: "pubkey", type: "byte[]" }], returns: { type: "bool" },
|
|
153
|
+
});
|
|
154
|
+
const signer = algosdk.makeBasicAccountTransactionSigner(this.signer.account);
|
|
155
|
+
const atc = new algosdk.AtomicTransactionComposer();
|
|
156
|
+
atc.addMethodCall({
|
|
157
|
+
appID: this.appId, method,
|
|
158
|
+
methodArgs: [Buffer.from(pubkey)],
|
|
159
|
+
boxes: [{ appIndex: 0, name: Buffer.from(pubkey) }],
|
|
160
|
+
sender: this.signer.address, suggestedParams: params, signer,
|
|
161
|
+
});
|
|
162
|
+
const result = await atc.simulate(this.algod);
|
|
163
|
+
return result.methodResults[0].returnValue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Contract stats ────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async getTotalVerified() {
|
|
169
|
+
if (!this.appId) throw new ConfigError("appId required");
|
|
170
|
+
const info = await this.algod.getApplicationByID(this.appId).do();
|
|
171
|
+
const state = info.params["global-state"] || [];
|
|
172
|
+
const entry = state.find(s => Buffer.from(s.key, "base64").toString() === "total_verified");
|
|
173
|
+
return entry ? Number(entry.value.uint) : 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async getOracleCount() {
|
|
177
|
+
if (!this.appId) throw new ConfigError("appId required");
|
|
178
|
+
const info = await this.algod.getApplicationByID(this.appId).do();
|
|
179
|
+
const state = info.params["global-state"] || [];
|
|
180
|
+
const entry = state.find(s => Buffer.from(s.key, "base64").toString() === "oracle_count");
|
|
181
|
+
return entry ? Number(entry.value.uint) : 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Identity ──────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
getAddress() { return this.signer.address; }
|
|
187
|
+
getPublicKeyBase64() { return this.signer.getPublicKeyBase64(); }
|
|
188
|
+
getPublicKeyBytes() { return this.signer.getPublicKeyBytes(); }
|
|
189
|
+
getExplorerUrl(txId) { return `${this.explorerBase}/transaction/${txId}`; }
|
|
190
|
+
|
|
191
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
async _submitProof(proof) {
|
|
194
|
+
const params = await this.algod.getTransactionParams().do();
|
|
195
|
+
params.flatFee = true;
|
|
196
|
+
params.fee = Math.max(Number(params.minFee ?? 1000), 1000) * 8;
|
|
197
|
+
|
|
198
|
+
// proof JSON in note = the anchor. Indexer picks this up for verifyProof().
|
|
199
|
+
const note = new TextEncoder().encode(JSON.stringify(proof));
|
|
200
|
+
|
|
201
|
+
if (this.appId) {
|
|
202
|
+
const oraclePubKeyBytes = this.signer.getPublicKeyBytes();
|
|
203
|
+
|
|
204
|
+
const verifyMethod = new algosdk.ABIMethod({
|
|
205
|
+
name: "verify_payment",
|
|
206
|
+
args: [
|
|
207
|
+
{ name: "payment_id", type: "string" },
|
|
208
|
+
{ name: "action", type: "string" },
|
|
209
|
+
{ name: "amount", type: "uint64" },
|
|
210
|
+
{ name: "currency", type: "string" },
|
|
211
|
+
{ name: "timestamp", type: "uint64" },
|
|
212
|
+
{ name: "app_id", type: "uint64" },
|
|
213
|
+
{ name: "oracle_pubkey", type: "byte[]" },
|
|
214
|
+
{ name: "signature", type: "byte[]" },
|
|
215
|
+
],
|
|
216
|
+
returns: { type: "bool" },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const nopMethod = new algosdk.ABIMethod({
|
|
220
|
+
name: "nop", args: [], returns: { type: "void" },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const signer = algosdk.makeBasicAccountTransactionSigner(this.signer.account);
|
|
224
|
+
const atc = new algosdk.AtomicTransactionComposer();
|
|
225
|
+
|
|
226
|
+
for (let i = 1; i <= 3; i++) {
|
|
227
|
+
atc.addMethodCall({
|
|
228
|
+
appID: this.appId, method: nopMethod, methodArgs: [],
|
|
229
|
+
sender: this.signer.address, suggestedParams: params,
|
|
230
|
+
note: new TextEncoder().encode(`pad${i}`), signer,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
atc.addMethodCall({
|
|
235
|
+
appID: this.appId,
|
|
236
|
+
method: verifyMethod,
|
|
237
|
+
methodArgs: [
|
|
238
|
+
proof.canonical_id, // namespaced box key
|
|
239
|
+
proof.action,
|
|
240
|
+
proof.amount,
|
|
241
|
+
proof.currency,
|
|
242
|
+
proof.timestamp,
|
|
243
|
+
this.appId,
|
|
244
|
+
Buffer.from(oraclePubKeyBytes),
|
|
245
|
+
Buffer.from(proof.signature, "base64"),
|
|
246
|
+
],
|
|
247
|
+
boxes: [
|
|
248
|
+
{ appIndex: 0, name: Buffer.from(oraclePubKeyBytes) }, // oracle registry
|
|
249
|
+
{ appIndex: 0, name: new TextEncoder().encode(proof.canonical_id) }, // replay lock
|
|
250
|
+
],
|
|
251
|
+
sender: this.signer.address, suggestedParams: params, note, signer,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const result = await atc.execute(this.algod, 6);
|
|
255
|
+
return result.txIDs[3];
|
|
256
|
+
|
|
257
|
+
} else {
|
|
258
|
+
// Anchor mode — 0-ALGO self-payment, proof JSON in note field
|
|
259
|
+
const txn = algosdk.makePaymentTxnWithSuggestedParamsFromObject({
|
|
260
|
+
sender: this.signer.address, receiver: this.signer.address, amount: 0,
|
|
261
|
+
note, suggestedParams: params,
|
|
262
|
+
});
|
|
263
|
+
const signed = txn.signTxn(this.signer.account.sk);
|
|
264
|
+
const { txid } = await this.algod.sendRawTransaction(signed).do();
|
|
265
|
+
await algosdk.waitForConfirmation(this.algod, txid, 6);
|
|
266
|
+
return txid;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async _oracleAdminCall(methodName, pubkeyBytes) {
|
|
271
|
+
const params = await this.algod.getTransactionParams().do();
|
|
272
|
+
params.flatFee = true;
|
|
273
|
+
params.fee = Math.max(Number(params.minFee ?? 1000), 1000) * 2;
|
|
274
|
+
|
|
275
|
+
const method = new algosdk.ABIMethod({
|
|
276
|
+
name: methodName, args: [{ name: "pubkey", type: "byte[]" }], returns: { type: "void" },
|
|
277
|
+
});
|
|
278
|
+
const signer = algosdk.makeBasicAccountTransactionSigner(this.signer.account);
|
|
279
|
+
const atc = new algosdk.AtomicTransactionComposer();
|
|
280
|
+
atc.addMethodCall({
|
|
281
|
+
appID: this.appId, method,
|
|
282
|
+
methodArgs: [Buffer.from(pubkeyBytes)],
|
|
283
|
+
boxes: [{ appIndex: 0, name: Buffer.from(pubkeyBytes) }],
|
|
284
|
+
sender: this.signer.address, suggestedParams: params, signer,
|
|
285
|
+
});
|
|
286
|
+
const result = await atc.execute(this.algod, 4);
|
|
287
|
+
return result.txIDs[0];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
static _toPubKeyBytes(addressOrBase64) {
|
|
291
|
+
if (typeof addressOrBase64 === "string" && addressOrBase64.length === 58) {
|
|
292
|
+
return algosdk.decodeAddress(addressOrBase64).publicKey;
|
|
293
|
+
}
|
|
294
|
+
return Buffer.from(addressOrBase64, "base64");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = { AlgoPayClient };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlgoPay Oracle SDK — OracleSigner
|
|
3
|
+
*
|
|
4
|
+
* Pure Ed25519 signing — no network calls.
|
|
5
|
+
* The credential layer is provider-agnostic: sign() accepts a PaymentEvent
|
|
6
|
+
* from any adapter and produces an APC-1 compatible signed proof.
|
|
7
|
+
*
|
|
8
|
+
* Replay protection namespacing:
|
|
9
|
+
* On-chain box keys use `provider:payment_id` (canonical_id), not raw payment_id.
|
|
10
|
+
* This prevents cross-provider collisions where two providers issue the same ID.
|
|
11
|
+
* The contract only sees canonical_id — it does not know or care about providers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const algosdk = require("algosdk");
|
|
15
|
+
const { ConfigError, InsufficientAmountError } = require("./errors");
|
|
16
|
+
const { validatePaymentEvent } = require("./validate");
|
|
17
|
+
|
|
18
|
+
const PROOF_PREFIX = "AlgoPay:v1:";
|
|
19
|
+
const MIN_AMOUNT = 100;
|
|
20
|
+
|
|
21
|
+
class OracleSigner {
|
|
22
|
+
/** @param {string} mnemonic - 25-word Algorand mnemonic */
|
|
23
|
+
constructor(mnemonic) {
|
|
24
|
+
if (!mnemonic) throw new ConfigError("mnemonic is required");
|
|
25
|
+
try {
|
|
26
|
+
this.account = algosdk.mnemonicToSecretKey(mnemonic);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
throw new ConfigError(`Invalid mnemonic: ${e.message}`);
|
|
29
|
+
}
|
|
30
|
+
this.address = this.account.addr.toString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sign a payment proof.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} payload
|
|
37
|
+
* @param {string} payload.payment_id - provider-issued payment reference
|
|
38
|
+
* @param {number} payload.amount - integer fiat amount (e.g. 100 for ₹100)
|
|
39
|
+
* @param {string} [payload.action] - intended Web3 action (default: "unlock")
|
|
40
|
+
* @param {string} [payload.currency] - ISO 4217 (default: "INR")
|
|
41
|
+
* @param {string} [payload.provider] - payment provider label (default: "unknown")
|
|
42
|
+
* @returns {object} signed proof — pass directly to AlgoPayClient._submitProof()
|
|
43
|
+
*/
|
|
44
|
+
sign({ payment_id, amount, action = "unlock", currency = "INR", provider = "unknown"}, appId = 0) {
|
|
45
|
+
// Delegate validation to validatePaymentEvent — consistent typed errors
|
|
46
|
+
validatePaymentEvent({ payment_id, amount, action, currency, provider });
|
|
47
|
+
|
|
48
|
+
// canonical_id = namespaced box key used on-chain for replay protection.
|
|
49
|
+
// Prevents cross-provider replay: razorpay:pay_ABC != stripe:pay_ABC.
|
|
50
|
+
// "unknown" provider falls back to raw payment_id (demo/manual mode).
|
|
51
|
+
const canonical_id = provider !== "unknown"
|
|
52
|
+
? `${provider}:${payment_id}`
|
|
53
|
+
: payment_id;
|
|
54
|
+
|
|
55
|
+
const timestamp = Math.floor(Date.now() / 1000) - 30; // backdate 30s — absorbs signing + tx propagation latency
|
|
56
|
+
const message = OracleSigner.buildMessage(canonical_id, action, currency, amount, timestamp, appId);
|
|
57
|
+
const sigBytes = algosdk.signBytes(message, this.account.sk);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
payment_id, // original provider ID — for display and logging
|
|
61
|
+
canonical_id, // namespaced — used in signing and as on-chain box key
|
|
62
|
+
provider,
|
|
63
|
+
amount,
|
|
64
|
+
action,
|
|
65
|
+
currency: currency.toUpperCase(),
|
|
66
|
+
timestamp,
|
|
67
|
+
app_id: appId,
|
|
68
|
+
oracle_address: this.address,
|
|
69
|
+
signature: Buffer.from(sigBytes).toString("base64"),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the raw message bytes for signing.
|
|
75
|
+
*
|
|
76
|
+
* Byte order (must match AlgoPayOracle.py verify_payment exactly):
|
|
77
|
+
* AlgoPay:v1: + canonical_id + action + currency + amount(8B BE) + timestamp(8B BE)
|
|
78
|
+
*
|
|
79
|
+
* algosdk.signBytes prepends "MX" before the Ed25519 operation.
|
|
80
|
+
* The contract prepends "MX" manually before ed25519verify_bare.
|
|
81
|
+
* So the effective signed bytes are: MX + the message below.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} canonical_id - namespaced payment ID (provider:payment_id)
|
|
84
|
+
* @param {string} action
|
|
85
|
+
* @param {string} currency
|
|
86
|
+
* @param {number} amount
|
|
87
|
+
* @param {number} timestamp - unix seconds
|
|
88
|
+
* @returns {Uint8Array}
|
|
89
|
+
*/
|
|
90
|
+
static buildMessage(canonical_id, action, currency, amount, timestamp, appId = 0) {
|
|
91
|
+
const enc = new TextEncoder();
|
|
92
|
+
const parts = [
|
|
93
|
+
enc.encode(PROOF_PREFIX),
|
|
94
|
+
enc.encode(canonical_id),
|
|
95
|
+
enc.encode(action),
|
|
96
|
+
enc.encode(currency.toUpperCase()),
|
|
97
|
+
algosdk.encodeUint64(amount),
|
|
98
|
+
algosdk.encodeUint64(timestamp),
|
|
99
|
+
algosdk.encodeUint64(appId),
|
|
100
|
+
];
|
|
101
|
+
const total = parts.reduce((s, p) => s + p.length, 0);
|
|
102
|
+
const msg = new Uint8Array(total);
|
|
103
|
+
let off = 0;
|
|
104
|
+
for (const p of parts) { msg.set(p, off); off += p.length; }
|
|
105
|
+
return msg;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Verify a proof's signature offline — no network, no indexer.
|
|
110
|
+
* Uses canonical_id if present, falls back to payment_id for backward compat.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} proof
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
static verifyOffchain(proof) {
|
|
116
|
+
try {
|
|
117
|
+
const id = proof.canonical_id || proof.payment_id;
|
|
118
|
+
const message = OracleSigner.buildMessage(
|
|
119
|
+
id, proof.action, proof.currency || "INR", proof.amount, proof.timestamp, proof.app_id || 0
|
|
120
|
+
);
|
|
121
|
+
const sigBytes = Buffer.from(proof.signature, "base64");
|
|
122
|
+
return algosdk.verifyBytes(message, sigBytes, proof.oracle_address);
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @returns {string} Base64 pubkey — paste into create() call at contract deploy */
|
|
129
|
+
getPublicKeyBase64() {
|
|
130
|
+
return Buffer.from(algosdk.decodeAddress(this.address).publicKey).toString("base64");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @returns {Uint8Array} Raw 32-byte Ed25519 public key */
|
|
134
|
+
getPublicKeyBytes() {
|
|
135
|
+
return algosdk.decodeAddress(this.address).publicKey;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @returns {string} Algorand address of this oracle */
|
|
139
|
+
getAddress() { return this.address; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { OracleSigner };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlgoPay Oracle SDK — ProofVerifier
|
|
3
|
+
*
|
|
4
|
+
* Verifies APC-1 proofs both off-chain (Ed25519 sig check) and
|
|
5
|
+
* on-chain (Algorand indexer transaction lookup).
|
|
6
|
+
*
|
|
7
|
+
* Verification chain:
|
|
8
|
+
* 1. APC-1 version supported
|
|
9
|
+
* 2. Required fields present and typed correctly
|
|
10
|
+
* 3. canonical_id format sane (provider:payment_id or raw id)
|
|
11
|
+
* 4. Proof not expired
|
|
12
|
+
* 5. Oracle address filter (optional)
|
|
13
|
+
* 6. Action filter (optional)
|
|
14
|
+
* 7. Ed25519 signature verified using canonical_id (not payment_id)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
const { OracleSigner } = require("./OracleSigner");
|
|
20
|
+
const { validateAPC1Structure, isSupportedVersion, isExpired } = require("./apc1");
|
|
21
|
+
const { InvalidSignatureError, ProofExpiredError } = require("./errors");
|
|
22
|
+
|
|
23
|
+
class ProofVerifier {
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {object} opts.indexer - algosdk.Indexer instance
|
|
27
|
+
* @param {string} [opts.network]
|
|
28
|
+
*/
|
|
29
|
+
constructor({ indexer, network = "testnet" } = {}) {
|
|
30
|
+
this.indexer = indexer;
|
|
31
|
+
this.network = network;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Verify an APC-1 proof object offline — no network calls.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} proof
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {string} [opts.expectedOracleAddress]
|
|
40
|
+
* @param {string} [opts.expectedAction]
|
|
41
|
+
* @param {number} [opts.maxAgeSecs] - default 300
|
|
42
|
+
* @returns {{ valid: boolean, reason?: string, proof?: object }}
|
|
43
|
+
*/
|
|
44
|
+
verifyOffchain(proof, opts = {}) {
|
|
45
|
+
// 1. APC-1 version check — fail early on unknown versions
|
|
46
|
+
if (proof.apc && !isSupportedVersion(proof)) {
|
|
47
|
+
return { valid: false, reason: `unsupported APC version: "${proof.apc}"` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. Required fields (works for both full APC-1 and internal proof objects)
|
|
51
|
+
const required = ["payment_id", "canonical_id", "amount", "currency", "action", "timestamp", "oracle_address", "signature"];
|
|
52
|
+
for (const f of required) {
|
|
53
|
+
if (proof[f] == null) return { valid: false, reason: `missing required field: ${f}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. canonical_id consistency check
|
|
57
|
+
// If provider is set and not "unknown", canonical_id should start with "provider:"
|
|
58
|
+
if (proof.provider && proof.provider !== "unknown") {
|
|
59
|
+
const expected_prefix = `${proof.provider}:`;
|
|
60
|
+
if (!proof.canonical_id.startsWith(expected_prefix) && proof.canonical_id !== proof.payment_id) {
|
|
61
|
+
return { valid: false, reason: `canonical_id "${proof.canonical_id}" does not match provider "${proof.provider}"` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 4. Expiry
|
|
66
|
+
const maxAge = opts.maxAgeSecs ?? 300;
|
|
67
|
+
if (isExpired(proof, maxAge)) {
|
|
68
|
+
const age = Math.floor(Date.now() / 1000) - proof.timestamp;
|
|
69
|
+
return { valid: false, reason: `proof expired — age ${age}s exceeds maximum ${maxAge}s` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 5. Oracle address filter
|
|
73
|
+
if (opts.expectedOracleAddress && proof.oracle_address !== opts.expectedOracleAddress) {
|
|
74
|
+
return { valid: false, reason: `oracle address mismatch: expected ${opts.expectedOracleAddress}` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 6. Action filter
|
|
78
|
+
if (opts.expectedAction && proof.action !== opts.expectedAction) {
|
|
79
|
+
return { valid: false, reason: `action mismatch: expected "${opts.expectedAction}", got "${proof.action}"` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 7. Ed25519 signature — uses canonical_id (the on-chain replay key)
|
|
83
|
+
const sigValid = OracleSigner.verifyOffchain(proof);
|
|
84
|
+
if (!sigValid) return { valid: false, reason: "Ed25519 signature verification failed" };
|
|
85
|
+
|
|
86
|
+
return { valid: true, proof };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Verify a proof by fetching its anchor transaction from the Algorand indexer.
|
|
91
|
+
* The proof JSON is stored in the transaction's note field.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} txId
|
|
94
|
+
* @param {object} [opts] - same as verifyOffchain opts
|
|
95
|
+
* @returns {Promise<{ valid: boolean, reason?: string, proof?: object, txId: string }>}
|
|
96
|
+
*/
|
|
97
|
+
async verifyTxn(txId, opts = {}) {
|
|
98
|
+
if (!this.indexer) return { valid: false, reason: "indexer not configured", txId };
|
|
99
|
+
|
|
100
|
+
let info;
|
|
101
|
+
try {
|
|
102
|
+
info = await this.indexer.lookupTransactionByID(txId).do();
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { valid: false, reason: `indexer lookup failed: ${e.message}`, txId };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const txn = info.transaction;
|
|
108
|
+
if (!txn?.note) return { valid: false, reason: "transaction not found or has no note field", txId };
|
|
109
|
+
|
|
110
|
+
let proof;
|
|
111
|
+
try {
|
|
112
|
+
proof = JSON.parse(Buffer.from(txn.note, "base64").toString("utf8"));
|
|
113
|
+
} catch {
|
|
114
|
+
return { valid: false, reason: "note field is not valid JSON", txId };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = this.verifyOffchain(proof, opts);
|
|
118
|
+
return { ...result, txId };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Batch verify multiple txIds.
|
|
123
|
+
* @param {string[]} txIds
|
|
124
|
+
* @param {object} [opts]
|
|
125
|
+
* @returns {Promise<Array>}
|
|
126
|
+
*/
|
|
127
|
+
async verifyBatch(txIds, opts = {}) {
|
|
128
|
+
return Promise.all(txIds.map(id => this.verifyTxn(id, opts)));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { ProofVerifier };
|