@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
package/README.md
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# @algopayoracle/oracle-sdk
|
|
2
|
+
|
|
3
|
+
**Programmable payment oracle for Algorand.**
|
|
4
|
+
Bridge any fiat payment to an on-chain action — verified by Ed25519 signature, enforced by smart contract.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @algopayoracle/oracle-sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
When a user pays via UPI, Razorpay, Stripe, or any other gateway, your backend receives a payment event. This SDK signs that event with an oracle key, submits it to an Algorand smart contract, and the contract independently verifies the signature before executing any action.
|
|
15
|
+
|
|
16
|
+
**The contract does not trust your backend. It only trusts the cryptographic proof.**
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Payment Gateway → Webhook → Oracle Signs → Algorand Contract → Action Executed
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const { AlgoPayClient } = require("@algopayoracle/oracle-sdk");
|
|
28
|
+
|
|
29
|
+
const client = new AlgoPayClient({
|
|
30
|
+
mnemonic: process.env.ORACLE_MNEMONIC,
|
|
31
|
+
network: "testnet",
|
|
32
|
+
appId: Number(process.env.ALGO_APP_ID),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Call this from any payment webhook — gateway-agnostic
|
|
36
|
+
const result = await client.verifyAndCommit({
|
|
37
|
+
payment_id: "your_gateway_payment_id",
|
|
38
|
+
amount: 100,
|
|
39
|
+
currency: "INR",
|
|
40
|
+
action: "unlock",
|
|
41
|
+
provider: "razorpay", // optional — enables namespaced replay protection
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
console.log(result.txId); // confirmed Algorand transaction ID
|
|
45
|
+
console.log(result.explorerUrl); // Lora explorer link
|
|
46
|
+
console.log(result.apc1); // APC-1 standardized credential
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Core concepts
|
|
52
|
+
|
|
53
|
+
### Payment-gateway agnostic
|
|
54
|
+
|
|
55
|
+
The oracle pipeline speaks `PaymentEvent`, not Razorpay or Stripe:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
// This shape works from any gateway
|
|
59
|
+
const event = {
|
|
60
|
+
payment_id: "your_gateway_id",
|
|
61
|
+
amount: 100, // integer, base currency unit
|
|
62
|
+
currency: "INR", // ISO 4217
|
|
63
|
+
action: "unlock", // what to trigger on-chain
|
|
64
|
+
provider: "razorpay", // optional label
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const result = await client.verifyAndCommit(event);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Adapters handle gateway-specific signature verification and normalization.
|
|
71
|
+
The oracle and contract have zero gateway-specific code.
|
|
72
|
+
|
|
73
|
+
### APC-1 — standardized payment credential
|
|
74
|
+
|
|
75
|
+
Every verified payment produces an APC-1 credential:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"apc": "1",
|
|
80
|
+
"payment_id": "pay_XXXXXXX",
|
|
81
|
+
"canonical_id": "razorpay:pay_XXXXXXX",
|
|
82
|
+
"amount": XXX,
|
|
83
|
+
"currency": "INR",
|
|
84
|
+
"action": "unlock",
|
|
85
|
+
"timestamp": 1714500000,
|
|
86
|
+
"oracle_address": "ABCDEF...",
|
|
87
|
+
"signature": "base64...",
|
|
88
|
+
"chain": "algorand",
|
|
89
|
+
"network": "testnet",
|
|
90
|
+
"app_id": XXXXXXXXX,
|
|
91
|
+
"provider": "razorpay"
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
APC-1 proofs are self-contained and verifiable by anyone who knows the oracle's public key.
|
|
96
|
+
|
|
97
|
+
### Trust model
|
|
98
|
+
|
|
99
|
+
- Oracle's Ed25519 public key is registered in the contract at deploy time
|
|
100
|
+
- The contract runs `ed25519verify_bare` on every call — no valid signature, no action
|
|
101
|
+
- Multiple oracles can be registered (rotation without downtime)
|
|
102
|
+
- `payment_id` box storage prevents replay attacks on-chain
|
|
103
|
+
- Proofs are time-bound — valid for 5 minutes from signing
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## API reference
|
|
108
|
+
|
|
109
|
+
### `AlgoPayClient`
|
|
110
|
+
|
|
111
|
+
#### Constructor
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
new AlgoPayClient({
|
|
115
|
+
mnemonic, // required — 25-word oracle account mnemonic
|
|
116
|
+
network, // "localnet" | "testnet" | "mainnet" (default: "testnet")
|
|
117
|
+
appId, // deployed AlgoPayOracle App ID (null = anchor mode)
|
|
118
|
+
algod, // optional — custom algosdk.Algodv2 instance
|
|
119
|
+
indexer, // optional — custom algosdk.Indexer instance
|
|
120
|
+
explorerBase, // optional — custom explorer URL
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### `verifyAndCommit(payment)` → `Promise<Result>`
|
|
125
|
+
|
|
126
|
+
Sign and commit a payment proof to Algorand.
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
const result = await client.verifyAndCommit({
|
|
130
|
+
payment_id: "pay_XXXXXXX",
|
|
131
|
+
amount: 100,
|
|
132
|
+
currency: "INR",
|
|
133
|
+
action: "unlock",
|
|
134
|
+
provider: "razorpay",
|
|
135
|
+
});
|
|
136
|
+
// result: { txId, proof, apc1, explorerUrl, verifyUrl, access_seconds }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
#### `verifyProof(txId)` → `Promise<VerifyResult>`
|
|
140
|
+
|
|
141
|
+
Verify a proof via the Algorand indexer.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const { valid, proof } = await client.verifyProof("TXID...");
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
#### `verifyProofOffchain(proof)` → `VerifyResult`
|
|
148
|
+
|
|
149
|
+
Verify a proof's Ed25519 signature without any network call.
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
const { valid } = client.verifyProofOffchain(result.proof);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
#### Oracle rotation
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
await client.addOracle("ALGORAND_ADDRESS_OR_BASE64_PUBKEY"); // creator only
|
|
159
|
+
await client.removeOracle("..."); // cannot remove last oracle
|
|
160
|
+
const registered = await client.isOracleRegistered("...");
|
|
161
|
+
|
|
162
|
+
const total = await client.getTotalVerified();
|
|
163
|
+
const count = await client.getOracleCount();
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
### `OracleSigner`
|
|
169
|
+
|
|
170
|
+
Pure Ed25519 signing — no network calls. Useful for offline signing and testing.
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
const { OracleSigner } = require("@algopayoracle/oracle-sdk");
|
|
174
|
+
|
|
175
|
+
const signer = new OracleSigner(mnemonic);
|
|
176
|
+
|
|
177
|
+
console.log(signer.getAddress()); // Algorand address
|
|
178
|
+
console.log(signer.getPublicKeyBase64()); // paste into contract deploy
|
|
179
|
+
|
|
180
|
+
const proof = signer.sign({ payment_id, amount, action, currency, provider });
|
|
181
|
+
const valid = OracleSigner.verifyOffchain(proof); // static, no network
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
### `ProofVerifier`
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
const { ProofVerifier, createClients } = require("@algopayoracle/oracle-sdk");
|
|
190
|
+
|
|
191
|
+
const { indexer } = createClients("testnet");
|
|
192
|
+
const verifier = new ProofVerifier({ indexer, network: "testnet" });
|
|
193
|
+
|
|
194
|
+
// Single txId
|
|
195
|
+
const result = await verifier.verifyTxn("TXID...", {
|
|
196
|
+
expectedOracleAddress: "ABCDEF...", // optional — restrict to specific oracle
|
|
197
|
+
expectedAction: "unlock", // optional — restrict to specific action
|
|
198
|
+
maxAgeSecs: 300, // optional — default 300
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Batch
|
|
202
|
+
const results = await verifier.verifyBatch(["TXID1", "TXID2", "TXID3"]);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
### Payment adapters
|
|
208
|
+
|
|
209
|
+
Adapters are optional. You can normalize any gateway's webhook payload manually.
|
|
210
|
+
|
|
211
|
+
#### Razorpay
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
const { RazorpayAdapter } = require("@algopayoracle/oracle-sdk");
|
|
215
|
+
|
|
216
|
+
// Share orderStore with the client to enforce server-side amounts
|
|
217
|
+
const orderStore = new Map();
|
|
218
|
+
const adapter = new RazorpayAdapter({
|
|
219
|
+
keyId: process.env.RAZORPAY_KEY_ID,
|
|
220
|
+
keySecret: process.env.RAZORPAY_KEY_SECRET,
|
|
221
|
+
orderStore, // prevents client from spoofing amounts
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Server-side webhook
|
|
225
|
+
app.post("/webhook/razorpay", (req, res) => {
|
|
226
|
+
const event = adapter.parseWebhook(req.rawBody, req.headers["x-razorpay-signature"]);
|
|
227
|
+
if (!event) return res.status(401).end();
|
|
228
|
+
const result = await client.verifyAndCommit(event);
|
|
229
|
+
res.json({ txId: result.txId });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Create order (stores amount server-side)
|
|
233
|
+
const order = await adapter.createOrder({ amount: 100, currency: "INR" });
|
|
234
|
+
|
|
235
|
+
// Client-side verification (amount taken from orderStore, not request body)
|
|
236
|
+
app.post("/verify-payment", (req, res) => {
|
|
237
|
+
const event = adapter.parseClientPayment({
|
|
238
|
+
razorpay_order_id: req.body.razorpay_order_id,
|
|
239
|
+
razorpay_payment_id: req.body.razorpay_payment_id,
|
|
240
|
+
razorpay_signature: req.body.razorpay_signature,
|
|
241
|
+
action: "unlock",
|
|
242
|
+
});
|
|
243
|
+
const result = await client.verifyAndCommit(event);
|
|
244
|
+
res.json({ txId: result.txId });
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
#### Stripe
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
const { StripeAdapter } = require("@algopayoracle/oracle-sdk");
|
|
252
|
+
// npm install stripe
|
|
253
|
+
|
|
254
|
+
const adapter = new StripeAdapter({
|
|
255
|
+
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
|
256
|
+
secretKey: process.env.STRIPE_SECRET_KEY,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
app.post("/webhook/stripe", (req, res) => {
|
|
260
|
+
const event = adapter.parseWebhook(req.rawBody, req.headers["stripe-signature"]);
|
|
261
|
+
if (!event) return res.status(401).end();
|
|
262
|
+
const result = await client.verifyAndCommit(event);
|
|
263
|
+
res.json({ txId: result.txId });
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
#### Custom gateway (any provider)
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
// No adapter needed — normalize manually and call verifyAndCommit
|
|
271
|
+
app.post("/webhook/payu", async (req, res) => {
|
|
272
|
+
// Verify PayU's signature with their method
|
|
273
|
+
if (!verifyPayUSignature(req.rawBody, req.headers["x-payu-checksum"])) {
|
|
274
|
+
return res.status(401).end();
|
|
275
|
+
}
|
|
276
|
+
const result = await client.verifyAndCommit({
|
|
277
|
+
payment_id: req.body.mihpayid,
|
|
278
|
+
amount: Math.round(Number(req.body.amount)),
|
|
279
|
+
currency: "INR",
|
|
280
|
+
action: "unlock",
|
|
281
|
+
provider: "payu",
|
|
282
|
+
});
|
|
283
|
+
res.json({ txId: result.txId });
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Contract deployment
|
|
290
|
+
|
|
291
|
+
### 1. Get your oracle public key
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
node -e "
|
|
295
|
+
const { OracleSigner } = require('@algopayoracle/oracle-sdk');
|
|
296
|
+
const s = new OracleSigner(process.env.ORACLE_MNEMONIC);
|
|
297
|
+
console.log('Address:', s.getAddress());
|
|
298
|
+
console.log('Pubkey :', s.getPublicKeyBase64());
|
|
299
|
+
"
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### 2. Compile the contract
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
cd contracts
|
|
306
|
+
algokit compile python AlgoPayOracle.py
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### 3. Deploy via Lora
|
|
310
|
+
|
|
311
|
+
Open https://lora.algokit.io/testnet → App Lab → Create → upload the compiled ARC-32 JSON.
|
|
312
|
+
|
|
313
|
+
When prompted for `create()` args, pass the oracle's 32-byte pubkey (base64-decoded).
|
|
314
|
+
|
|
315
|
+
### 4. Fund the contract for box storage
|
|
316
|
+
|
|
317
|
+
Each payment creates a box (~33 bytes) costing ~0.01109 ALGO.
|
|
318
|
+
Send at least 0.1 ALGO to the contract address after deploy.
|
|
319
|
+
|
|
320
|
+
### 5. Configure environment
|
|
321
|
+
|
|
322
|
+
```env
|
|
323
|
+
ORACLE_MNEMONIC=your twenty five words here
|
|
324
|
+
ALGO_NETWORK=testnet
|
|
325
|
+
ALGO_APP_ID=XXXXXXXXX
|
|
326
|
+
ADMIN_API_KEY=your_secret_admin_key
|
|
327
|
+
ALLOWED_ORIGINS=https://yourdomain.com
|
|
328
|
+
RAZORPAY_KEY_ID=rzp_test_xxx # optional
|
|
329
|
+
RAZORPAY_KEY_SECRET=your_secret # optional
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Network configuration
|
|
335
|
+
|
|
336
|
+
### Built-in networks (AlgoNode, no API key required)
|
|
337
|
+
|
|
338
|
+
| Network | Algod | Indexer |
|
|
339
|
+
|-----------|----------------------------------------|-----------------------------------------|
|
|
340
|
+
| localnet | http://localhost:4001 | http://localhost:8980 |
|
|
341
|
+
| testnet | https://testnet-api.algonode.cloud | https://testnet-idx.algonode.cloud |
|
|
342
|
+
| mainnet | https://mainnet-api.algonode.cloud | https://mainnet-idx.algonode.cloud |
|
|
343
|
+
|
|
344
|
+
### Custom node (Nodely, PureStake, self-hosted)
|
|
345
|
+
|
|
346
|
+
```js
|
|
347
|
+
const { createCustomClients } = require("@algopayoracle/oracle-sdk");
|
|
348
|
+
|
|
349
|
+
const { algod, indexer } = createCustomClients({
|
|
350
|
+
algodUrl: "https://mainnet-api.nodely.dev",
|
|
351
|
+
algodToken: process.env.NODELY_TOKEN,
|
|
352
|
+
indexerUrl: "https://mainnet-idx.nodely.dev",
|
|
353
|
+
indexerToken: process.env.NODELY_TOKEN,
|
|
354
|
+
explorerBase: "https://lora.algokit.io/mainnet",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const client = new AlgoPayClient({ mnemonic, appId, algod, indexer });
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Security notes
|
|
363
|
+
|
|
364
|
+
- **Admin endpoints** — `addOracle` / `removeOracle` call on-chain contract functions. In production, these routes must be protected (API key at minimum) and should not be on the public internet. See the express-webhook example for the `requireAdmin` middleware pattern.
|
|
365
|
+
- **Amount trust** — Never trust `amount` from the client in the payment verify path. The `RazorpayAdapter` orderStore pattern enforces this. If you write a custom adapter, always source the amount from your server-side order record or the provider's webhook body.
|
|
366
|
+
- **Oracle key custody** — The oracle key is the trust anchor. Treat it like a private key: never commit to git, rotate via `addOracle` + `removeOracle` if compromised, consider multisig for mainnet.
|
|
367
|
+
- **Webhook body size** — Always apply a body size limit to webhook handlers. The express-webhook example enforces 512 KB.
|
|
368
|
+
- **CORS** — Lock `ALLOWED_ORIGINS` to your actual frontend domain before production deployment.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Error handling
|
|
373
|
+
|
|
374
|
+
```js
|
|
375
|
+
const {
|
|
376
|
+
AlgoPayError,
|
|
377
|
+
InsufficientAmountError,
|
|
378
|
+
ProofExpiredError,
|
|
379
|
+
OracleNotRegisteredError,
|
|
380
|
+
ReplayError,
|
|
381
|
+
ProviderAuthError,
|
|
382
|
+
} = require("@algopayoracle/oracle-sdk");
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
await client.verifyAndCommit(event);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
if (e instanceof InsufficientAmountError) { /* amount < minimum */ }
|
|
388
|
+
if (e instanceof ProviderAuthError) { /* gateway sig check failed */ }
|
|
389
|
+
if (e instanceof AlgoPayError) { /* any SDK error */ }
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## License
|
|
396
|
+
|
|
397
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@algopayoracle/oracle-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Programmable payment oracle SDK \u2014 bridge fiat payments to Algorand smart contracts",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"README.md"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test examples/quickstart.js",
|
|
12
|
+
"test:unit": "node --test",
|
|
13
|
+
"test:integration": "ALGO_NETWORK=localnet node --test",
|
|
14
|
+
"example": "node examples/express-webhook.js",
|
|
15
|
+
"lint": "node --check src/index.js src/AlgoPayClient.js src/OracleSigner.js",
|
|
16
|
+
"prepublishOnly": "node --check src/index.js && echo 'Pre-publish checks passed'"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"algorand",
|
|
20
|
+
"oracle",
|
|
21
|
+
"payments",
|
|
22
|
+
"upi",
|
|
23
|
+
"razorpay",
|
|
24
|
+
"web3",
|
|
25
|
+
"blockchain",
|
|
26
|
+
"ed25519",
|
|
27
|
+
"smart-contract"
|
|
28
|
+
],
|
|
29
|
+
"author": "AlgoPay Oracle",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/your-org/algopay-oracle-sdk"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"algosdk": ">=3.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"algosdk": "^3.0.0",
|
|
40
|
+
"express": "^4.18.0",
|
|
41
|
+
"express-rate-limit": "^7.0.0",
|
|
42
|
+
"dotenv": "^16.0.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"types": "src/index.d.ts",
|
|
48
|
+
"exports": {
|
|
49
|
+
".": {
|
|
50
|
+
"require": "./src/index.js",
|
|
51
|
+
"types": "./src/index.d.ts"
|
|
52
|
+
},
|
|
53
|
+
"./validate": {
|
|
54
|
+
"require": "./src/validate.js"
|
|
55
|
+
},
|
|
56
|
+
"./errors": {
|
|
57
|
+
"require": "./src/errors.js"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|