├── .gitignore ├── Cargo.toml ├── README.md ├── run.sh └── src ├── api.rs ├── database.rs ├── main.rs ├── redis_db ├── mod.rs └── stream.rs ├── rpc.rs └── status.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env 3 | Cargo.lock 4 | logs/ 5 | scripts/ 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "data-server" 3 | version = "0.10.0" 4 | edition = "2021" 5 | 6 | [[bin]] 7 | path = "src/main.rs" 8 | name = "server" 9 | 10 | [dependencies] 11 | actix-web = "4.5.1" 12 | actix-cors = "0.7.0" 13 | serde = { version = "1", features = ["derive"] } 14 | serde_json = "1" 15 | dotenv = "0.15.0" 16 | tracing = "0.1" 17 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 18 | redis = { version = "0.25.2", features = ["tokio-comp", "tokio-native-tls-comp", "streams"] } 19 | itertools = "0.12.0" 20 | tokio = { version = "1.36.0", features = ["full", "tracing"] } 21 | tracing-actix-web = "0.7.9" 22 | 23 | near-account-id = "0.1.0" 24 | near-crypto = "0.20.0" 25 | 26 | reqwest = { version = "0.11.24", features = ["json"] } 27 | base64 = "0.21.7" 28 | hex = "0.4.3" 29 | openssl-probe = "0.1.5" 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FASTNEAR API 2 | 3 | The low-latency API for wallets and explorers. 4 | 5 | ## Overview 6 | 7 | APIs: 8 | 9 | 1. Public Key to Account ID(s) mapping. 10 | 11 | - Full Access Public Key to Account ID(s) mapping. 12 | - Any Public Key to Account ID(s) mapping. 13 | 14 | 2. Account ID to delegated staking pools (validators). 15 | 3. Account ID to fungible tokens (FT contracts). 16 | 4. Account ID to non-fungible tokens (NFT contracts). 17 | 5. Token ID to top 100 accounts by balance (for FT contracts). 18 | 6. Account ID to full info (validators, FT, NFT and account state). 19 | 20 | Endpoints: 21 | 22 | - Mainnet: https://api.fastnear.com 23 | - Testnet: https://test.api.fastnear.com 24 | 25 | ## Status 26 | 27 | You can check status of the API server. 28 | 29 | ``` 30 | GET /status 31 | ``` 32 | 33 | https://api.fastnear.com/status 34 | 35 | ```bash 36 | curl https://api.fastnear.com/status 37 | ``` 38 | 39 | Example Result: 40 | 41 | ```json 42 | { 43 | "sync_balance_block_height": 129734103, 44 | "sync_block_height": 129734103, 45 | "sync_block_timestamp_nanosec": "1728256282197171397", 46 | "sync_latency_sec": 4.671730603, 47 | "version": "0.10.0" 48 | } 49 | ``` 50 | 51 | ## Health 52 | 53 | Returns the health status of the API server. 54 | 55 | ``` 56 | GET /health 57 | ``` 58 | 59 | https://api.fastnear.com/health 60 | 61 | ```bash 62 | curl https://api.fastnear.com/health 63 | ``` 64 | 65 | Example Result (for healthy): 66 | 67 | ```json 68 | { 69 | "status": "ok" 70 | } 71 | ``` 72 | 73 | ## API V1 74 | 75 | In API V1, the API endpoints provide extra details about the contracts. 76 | E.g. the block height when the last change was made on a contract that affected a given account, or a token balance. 77 | 78 | #### Token ID to top 100 accounts by balance (for FT contracts). 79 | 80 | Returns the list of account IDs for a given fungible tokens (FT) contract ordered by decreasing FT balance. 81 | Each account result includes the following: 82 | 83 | - `account_id` - the account ID. 84 | - `balance` - the last known balance of the account for this token. 85 | 86 | Notes: 87 | 88 | - the `balance` will be returned as a decimal integer string, e.g. `"100"`. 89 | 90 | ``` 91 | GET /v1/ft/{token_id}/top 92 | ``` 93 | 94 | Example: https://api.fastnear.com/v1/ft/first.tkn.near/top 95 | 96 | ```bash 97 | curl https://api.fastnear.com/v1/ft/first.tkn.near/top 98 | ``` 99 | 100 | Result: 101 | 102 | ```json 103 | { 104 | "token_id": "first.tkn.near", 105 | "accounts": [ 106 | { 107 | "account_id": "mob.near", 108 | "balance": "979894691374420631019486155" 109 | }, 110 | { 111 | "account_id": "lucky-bastard.near", 112 | "balance": "10319841074196024761995069" 113 | }, 114 | { 115 | "account_id": "mattlock.near", 116 | "balance": "9775084808910328058513245" 117 | }, 118 | { 119 | "account_id": "ref-finance.near", 120 | "balance": "10290906529190035816723" 121 | }, 122 | { 123 | "account_id": "zilulagg.near", 124 | "balance": "91835943826124178808" 125 | }, 126 | { 127 | "account_id": "kotleta.near", 128 | "balance": "10000" 129 | }, 130 | { 131 | "account_id": "ryanmehta.near", 132 | "balance": "0" 133 | } 134 | ] 135 | } 136 | ``` 137 | 138 | #### Account ID to delegated staking pools (validators). 139 | 140 | Returns the list of staking pools that the account has delegated to in the past, including the block 141 | height when the last change was made on the staking pool by the account. 142 | 143 | Note, if the `last_update_block_height` is `null`, then no recent updates were made. 144 | 145 | ``` 146 | GET /v1/account/{account_id}/staking 147 | ``` 148 | 149 | Example: https://api.fastnear.com/v1/account/mob.near/staking 150 | 151 | ```bash 152 | curl https://api.fastnear.com/v1/account/mob.near/staking 153 | ``` 154 | 155 | Result: 156 | 157 | ```json 158 | { 159 | "account_id": "mob.near", 160 | "pools": [ 161 | { 162 | "last_update_block_height": 114976469, 163 | "pool_id": "zavodil.poolv1.near" 164 | }, 165 | { 166 | "last_update_block_height": null, 167 | "pool_id": "usn.pool.near" 168 | }, 169 | { 170 | "last_update_block_height": null, 171 | "pool_id": "usn-unofficial.pool.near" 172 | }, 173 | { 174 | "last_update_block_height": null, 175 | "pool_id": "epic.poolv1.near" 176 | }, 177 | { 178 | "last_update_block_height": 114976560, 179 | "pool_id": "here.poolv1.near" 180 | } 181 | ] 182 | } 183 | ``` 184 | 185 | #### Account ID to fungible tokens (FT contracts). 186 | 187 | Returns the list of fungible tokens (FT) contracts that the account may have. 188 | Each token result includes the following: 189 | 190 | - `contract_id` - the account ID of the fungible token contract. 191 | - `last_update_block_height` - the block height when the last change was made on the contract that affected this given 192 | account. 193 | - `balance` - the last known balance of the account for this token. 194 | 195 | Notes: 196 | 197 | - if the `last_update_block_height` is `null`, then no recent updates were made. The last update block height change was 198 | enabled around block `115000000`. 199 | - the `balance` will be returned as a decimal integer string, e.g. `"100"`. 200 | - the `balance` is not adjusted to the decimals in the FT metadata, it's the raw balance as stored in the contract. 201 | - if the `balance` is `null`, then the balance is not available yet. It's likely will be updated soon (within a few 202 | seconds). 203 | - if the `balance` is empty string (`""`), then the account fungible token contract might be broken, because it didn't 204 | return the proper balance. 205 | 206 | ``` 207 | GET /v1/account/{account_id}/ft 208 | ``` 209 | 210 | Example: https://api.fastnear.com/v1/account/here.tg/ft 211 | 212 | ```bash 213 | curl https://api.fastnear.com/v1/account/here.tg/ft 214 | ``` 215 | 216 | Result: 217 | 218 | ```json 219 | { 220 | "account_id": "here.tg", 221 | "tokens": [ 222 | { 223 | "balance": "10000", 224 | "contract_id": "game.hot.tg", 225 | "last_update_block_height": 115615375 226 | }, 227 | { 228 | "balance": "81000", 229 | "contract_id": "usdt.tether-token.near", 230 | "last_update_block_height": null 231 | } 232 | ] 233 | } 234 | ``` 235 | 236 | #### Account ID to non-fungible tokens (NFT contracts). 237 | 238 | Returns the list of non-fungible tokens (NFT) contracts that the account has interacted with or received, including the 239 | block height when the last change was made on the contract that affected this given account. 240 | 241 | Note, if the `last_update_block_height` is `null`, then no recent updates were made. 242 | 243 | ``` 244 | GET /v1/account/{account_id}/nft 245 | ``` 246 | 247 | Example: https://api.fastnear.com/v1/account/sharddog.near/nft 248 | 249 | ```bash 250 | curl https://api.fastnear.com/v1/account/sharddog.near/nft 251 | ``` 252 | 253 | Result: 254 | 255 | ```json 256 | { 257 | "account_id": "sharddog.near", 258 | "tokens": [ 259 | { 260 | "contract_id": "mint.sharddog.near", 261 | "last_update_block_height": 115034954 262 | }, 263 | { 264 | "contract_id": "open.sharddog.near", 265 | "last_update_block_height": null 266 | }, 267 | { 268 | "contract_id": "humansofbrazil.sharddog.near", 269 | "last_update_block_height": null 270 | }, 271 | { 272 | "contract_id": "nft.bluntdao.near", 273 | "last_update_block_height": null 274 | }, 275 | { 276 | "contract_id": "ndcconstellationnft.sharddog.near", 277 | "last_update_block_height": null 278 | }, 279 | { 280 | "contract_id": "mmc.sharddog.near", 281 | "last_update_block_height": null 282 | }, 283 | { 284 | "contract_id": "nft.genadrop.near", 285 | "last_update_block_height": null 286 | }, 287 | { 288 | "contract_id": "harvestmoon.sharddog.near", 289 | "last_update_block_height": null 290 | }, 291 | { 292 | "contract_id": "comic.sharddog.near", 293 | "last_update_block_height": 114988538 294 | }, 295 | { 296 | "contract_id": "meteor.sharddog.near", 297 | "last_update_block_height": null 298 | }, 299 | { 300 | "contract_id": "nstreetwolves.near", 301 | "last_update_block_height": null 302 | }, 303 | { 304 | "contract_id": "starpause.mintbase1.near", 305 | "last_update_block_height": null 306 | }, 307 | { 308 | "contract_id": "rubenm4rcusstore.mintbase1.near", 309 | "last_update_block_height": null 310 | }, 311 | { 312 | "contract_id": "rogues-genesis.nfts.fewandfar.near", 313 | "last_update_block_height": null 314 | }, 315 | { 316 | "contract_id": "nft.regens.near", 317 | "last_update_block_height": null 318 | }, 319 | { 320 | "contract_id": "mmc-mint.sharddog.near", 321 | "last_update_block_height": null 322 | }, 323 | { 324 | "contract_id": "badges.devhub.near", 325 | "last_update_block_height": null 326 | }, 327 | { 328 | "contract_id": "secretnft.devhub.near", 329 | "last_update_block_height": null 330 | }, 331 | { 332 | "contract_id": "giveaway.mydev.near", 333 | "last_update_block_height": null 334 | }, 335 | { 336 | "contract_id": "mintv2.sharddog.near", 337 | "last_update_block_height": 114973604 338 | }, 339 | { 340 | "contract_id": "nearvidia.sharddog.near", 341 | "last_update_block_height": null 342 | }, 343 | { 344 | "contract_id": "claim.sharddog.near", 345 | "last_update_block_height": 115039779 346 | } 347 | ] 348 | } 349 | ``` 350 | 351 | #### Account ID to full info (validators, FT, NFT and account state) 352 | 353 | Returns the full information about the account, including the following: 354 | 355 | - Delegated staking pools (validators). 356 | - Fungible tokens (FT) contracts and balances. 357 | - Non-fungible tokens (NFT) contracts. 358 | - Account state (balance, locked balance, storage usage). 359 | 360 | ``` 361 | GET /v1/account/{account_id}/full 362 | ``` 363 | 364 | Example: https://api.fastnear.com/v1/account/here.tg/full 365 | 366 | ```bash 367 | curl https://api.fastnear.com/v1/account/here.tg/full 368 | ``` 369 | 370 | Result: 371 | 372 | ```json 373 | { 374 | "account_id": "here.tg", 375 | "nfts": [ 376 | { 377 | "contract_id": "harvestmoon.sharddog.near", 378 | "last_update_block_height": null 379 | }, 380 | { 381 | "contract_id": "nft.hot.tg", 382 | "last_update_block_height": 115282010 383 | }, 384 | { 385 | "contract_id": "nearvoucherstore.mintbase1.near", 386 | "last_update_block_height": 118841842 387 | }, 388 | { 389 | "contract_id": "nearreward.mintbase1.near", 390 | "last_update_block_height": 121969370 391 | } 392 | ], 393 | "pools": [ 394 | { 395 | "last_update_block_height": null, 396 | "pool_id": "here.poolv1.near" 397 | } 398 | ], 399 | "state": { 400 | "balance": "240420562203528059226991880", 401 | "locked": "0", 402 | "storage_bytes": 26340 403 | }, 404 | "tokens": [ 405 | { 406 | "balance": "9990", 407 | "contract_id": "game.hot.tg", 408 | "last_update_block_height": 123971814 409 | }, 410 | { 411 | "balance": "10283000", 412 | "contract_id": "usdt.tether-token.near", 413 | "last_update_block_height": 116301157 414 | }, 415 | { 416 | "balance": "0", 417 | "contract_id": "aurora", 418 | "last_update_block_height": 118627759 419 | }, 420 | { 421 | "balance": "2318000000000000", 422 | "contract_id": "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.factory.bridge.near", 423 | "last_update_block_height": 118667336 424 | }, 425 | { 426 | "balance": "10000000000000000000", 427 | "contract_id": "nearrewards.near", 428 | "last_update_block_height": 118842567 429 | }, 430 | { 431 | "balance": "8999999999899999", 432 | "contract_id": "wbnb.hot.tg", 433 | "last_update_block_height": 121310030 434 | }, 435 | { 436 | "balance": "", 437 | "contract_id": "v1.omni.hot.tg", 438 | "last_update_block_height": 128025061 439 | } 440 | ] 441 | } 442 | ``` 443 | 444 | ## API V0 445 | 446 | #### Full Access Public Key to Account ID mapping. 447 | 448 | Returns the list of account IDs that are associated with the full-access public key. 449 | 450 | Note, the API will also return an implicit account ID for the public key, even if the implicit account might not exist. 451 | 452 | ``` 453 | GET /v0/public_key/{public_key} 454 | ``` 455 | 456 | Example: https://api.fastnear.com/v0/public_key/ed25519:FekbqN74kXhVPRd8ysAqJwLydFvTPYh7ZXHmhqCETcR3 457 | 458 | ```bash 459 | curl https://api.fastnear.com/v0/public_key/ed25519:FekbqN74kXhVPRd8ysAqJwLydFvTPYh7ZXHmhqCETcR3 460 | ``` 461 | 462 | Result: 463 | 464 | ```json 465 | { 466 | "account_ids": [ 467 | "root.near", 468 | "d9af67ff794a93e05bdba5c25ad7af027d72b3b76823051c0fb4b6e3e79ac51e" 469 | ], 470 | "public_key": "ed25519:FekbqN74kXhVPRd8ysAqJwLydFvTPYh7ZXHmhqCETcR3" 471 | } 472 | ``` 473 | 474 | #### Any Public Key to Account ID mapping. 475 | 476 | Returns the list of account IDs that are associated with this public key, including limited access keys. 477 | 478 | Note, the API will also return an implicit account ID for the public key, even if the implicit account might not exist. 479 | 480 | ``` 481 | GET /v0/public_key/{public_key}/all 482 | ``` 483 | 484 | Example: https://api.fastnear.com/v0/public_key/ed25519:HLcgpHWRn3ij97JfpPNYDScMXVguWSFH1mR58RB7qPpd/all 485 | 486 | ```bash 487 | curl https://api.fastnear.com/v0/public_key/ed25519:HLcgpHWRn3ij97JfpPNYDScMXVguWSFH1mR58RB7qPpd/all 488 | ``` 489 | 490 | Result: 491 | 492 | ```json 493 | { 494 | "account_ids": [ 495 | "root.near", 496 | "f2c160840040d637041a5dc63eeb23b8aae41a79fc9b0f2d8df07adb613d1d82" 497 | ], 498 | "public_key": "ed25519:HLcgpHWRn3ij97JfpPNYDScMXVguWSFH1mR58RB7qPpd" 499 | } 500 | ``` 501 | 502 | #### Account ID to delegated staking pools (validators). 503 | 504 | Returns the list of staking pools that the account has delegated to in the past. 505 | 506 | *Deprecated in favor of API V1.* 507 | 508 | ``` 509 | GET /v0/account/{account_id}/staking 510 | ``` 511 | 512 | Example: https://api.fastnear.com/v0/account/root.near/staking 513 | 514 | ```bash 515 | curl https://api.fastnear.com/v0/account/root.near/staking 516 | ``` 517 | 518 | Result: 519 | 520 | ```json 521 | { 522 | "account_id": "root.near", 523 | "pools": [ 524 | "ashert.poolv1.near" 525 | ] 526 | } 527 | ``` 528 | 529 | #### Account ID to fungible tokens (FT contracts). 530 | 531 | Returns the list of fungible tokens (FT) contracts that the account has interacted with or received. 532 | 533 | *Deprecated in favor of API V1.* 534 | 535 | ``` 536 | GET /v0/account/{account_id}/ft 537 | ``` 538 | 539 | Example: https://api.fastnear.com/v0/account/root.near/ft 540 | 541 | ```bash 542 | curl https://api.fastnear.com/v0/account/root.near/ft 543 | ``` 544 | 545 | Result: 546 | 547 | ```json 548 | { 549 | "account_id": "root.near", 550 | "contract_ids": [ 551 | "pixeltoken.near", 552 | "ndc.tkn.near", 553 | "meta-pool.near", 554 | "coin.asac.near", 555 | "cheems.tkn.near", 556 | "baby.tkn.near", 557 | "meteor-points.near", 558 | "9aeb50f542050172359a0e1a25a9933bc8c01259.factory.bridge.near", 559 | "meta-token.near", 560 | "c.tkn.near", 561 | "bobo.tkn.near", 562 | "gold.l2e.near", 563 | "usn", 564 | "token.lonkingnearbackto2024.near", 565 | "utopia.secretskelliessociety.near", 566 | "wnear-at-150-0.wentokensir.near", 567 | "v3.oin_finance.near", 568 | "adtoken.near", 569 | "nearbit.tkn.near", 570 | "mvp.tkn.near", 571 | "youwon500neartoclaimyourgainwwwlotte.laboratory.jumpfinance.near", 572 | "wnear-150-0000.wentokensir.near", 573 | "fx.tkn.near", 574 | "1.laboratory.jumpfinance.near", 575 | "zod.near", 576 | "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.factory.bridge.near", 577 | "kusama-airdrop.near", 578 | "blackdragon.tkn.near", 579 | "congratulations.laboratory.jumpfinance.near", 580 | "wrap.near", 581 | "avb.tkn.near", 582 | "ftv2.nekotoken.near", 583 | "superbot.near", 584 | "fusotao-token.near", 585 | "deezz.near", 586 | "ser.tkn.near", 587 | "near-20-0000.wentokensir.near", 588 | "aurora.tkn.near", 589 | "f5cfbc74057c610c8ef151a439252680ac68c6dc.factory.bridge.near", 590 | "nearkat.tkn.near", 591 | "youwon500neartoclaimyourgainwwwnearl.laboratory.jumpfinance.near" 592 | ] 593 | } 594 | ``` 595 | 596 | #### Account ID to non-fungible tokens (NFT contracts). 597 | 598 | Returns the list of non-fungible tokens (NFT) contracts that the account has interacted with or received. 599 | 600 | *Deprecated in favor of API V1.* 601 | 602 | ``` 603 | GET /v0/account/{account_id}/nft 604 | ``` 605 | 606 | Example: https://api.fastnear.com/v0/account/root.near/nft 607 | 608 | ```bash 609 | curl https://api.fastnear.com/v0/account/root.near/nft 610 | ``` 611 | 612 | Result: 613 | 614 | ```json 615 | { 616 | "account_id": "root.near", 617 | "contract_ids": [ 618 | "nft.goodfortunefelines.near", 619 | "genadrop-contract.nftgen.near", 620 | "paulcrans.mintbase1.near", 621 | "mailgun.near", 622 | "citizen.bodega-lab.near", 623 | "avtr.near", 624 | "comic.paras.near", 625 | "spin-nft-contract.near", 626 | "ambernft.near", 627 | "ndcconstellationnft.sharddog.near", 628 | "learnernft.learnclub.near", 629 | "freedom.mintbase1.near", 630 | "nshackathon2022.mintbase1.near", 631 | "near-punks.near", 632 | "mint.sharddog.near", 633 | "nft.genadrop.near", 634 | "nearnautsnft.near", 635 | "roughcentury.mintbase1.near", 636 | "chatgpt.mintbase1.near", 637 | "tonic_goblin.enleap.near", 638 | "nft.greedygoblins.near", 639 | "nep172.nfnft.near", 640 | "nearcrashnft.near", 641 | "yearoftherabbit.near", 642 | "nft-message.nearkits.near", 643 | "famdom1.nearhubonline.near", 644 | "nft.widget.near", 645 | "nearmailbot.near", 646 | "harvestmoon.sharddog.near", 647 | "pcards.near", 648 | "kaizofighters.tenk.near", 649 | "hot-or-bot.near", 650 | "nearnauts.mintbase1.near", 651 | "dotdot.mintbase1.near", 652 | "qstienft.near", 653 | "yuzu.recurforever.near", 654 | "serumnft.near", 655 | "pack.pack_minter.playible.near", 656 | "nearcon.mintbase1.near", 657 | "seoul2020.snft.near", 658 | "astropup.near", 659 | "nearnautnft.near", 660 | "kashmirthroughmylens.mintbase1.near", 661 | "nearmixtapev1beatdao.mintbase1.near", 662 | "rtrpkp.mintbase1.near", 663 | "ouchworld.mintbase1.near", 664 | "beenftofficial.near", 665 | "pluminite.near", 666 | "tigercheck4.near", 667 | "misfits.tenk.near", 668 | "nearcon2.mintbase1.near", 669 | "jwneartokens.mintbase1.near", 670 | "mmc.nfts.fewandfar.near", 671 | "nft-v2.keypom.near", 672 | "proof-of-memories-nearcon-2022.snft.near", 673 | "cartelgen1.neartopia.near", 674 | "reginamintbase.mintbase1.near", 675 | "nearpay-portals.near", 676 | "near-x-sailgp-f50-fan-token.snft.near", 677 | "starbox.herewallet.near", 678 | "mmc-pups.nfts.fewandfar.near", 679 | "root.mintbase1.near", 680 | "undead.secretskelliessociety.near", 681 | "asac.near", 682 | "x.paras.near", 683 | "athlete.nfl.playible.near" 684 | ] 685 | } 686 | ``` 687 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # set -e 3 | 4 | cd $(dirname "$0") 5 | mkdir -p logs 6 | DATE=$(date "+%Y_%m_%d") 7 | 8 | cargo run --release 2>&1 | tee -a logs/$DATE.txt 9 | -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | use actix_web::ResponseError; 3 | use near_account_id::AccountId; 4 | use near_crypto::PublicKey; 5 | use serde_json::json; 6 | use std::collections::HashMap; 7 | use std::fmt; 8 | use std::str::FromStr; 9 | 10 | const TARGET_API: &str = "api"; 11 | 12 | pub type BlockHeight = u64; 13 | 14 | #[derive(Debug)] 15 | pub enum ServiceError { 16 | DatabaseError(database::DatabaseError), 17 | RpcError(rpc::RpcError), 18 | ArgumentError, 19 | } 20 | 21 | impl From for ServiceError { 22 | fn from(error: redis::RedisError) -> Self { 23 | ServiceError::DatabaseError(database::DatabaseError::RedisError(error)) 24 | } 25 | } 26 | 27 | impl From for ServiceError { 28 | fn from(error: database::DatabaseError) -> Self { 29 | ServiceError::DatabaseError(error) 30 | } 31 | } 32 | 33 | impl From for ServiceError { 34 | fn from(error: rpc::RpcError) -> Self { 35 | ServiceError::RpcError(error) 36 | } 37 | } 38 | 39 | impl fmt::Display for ServiceError { 40 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 41 | match *self { 42 | ServiceError::DatabaseError(ref err) => write!(f, "Database Error: {:?}", err), 43 | ServiceError::ArgumentError => write!(f, "Invalid argument"), 44 | ServiceError::RpcError(ref err) => write!(f, "Rpc Error: {:?}", err), 45 | } 46 | } 47 | } 48 | 49 | impl ResponseError for ServiceError { 50 | fn error_response(&self) -> HttpResponse { 51 | match *self { 52 | ServiceError::DatabaseError(_) => { 53 | tracing::error!(target: TARGET_API, "Service error: {}", self); 54 | HttpResponse::InternalServerError().json("Internal server error") 55 | } 56 | ServiceError::ArgumentError => { 57 | tracing::info!(target: TARGET_API, "Service error: {}", self); 58 | HttpResponse::BadRequest().json("Invalid argument") 59 | } 60 | ServiceError::RpcError(ref e) => { 61 | tracing::error!(target: TARGET_API, "Service error: {}", self); 62 | HttpResponse::InternalServerError().json(format!("Internal server error {:?}", e)) 63 | } 64 | } 65 | } 66 | } 67 | 68 | pub mod v0 { 69 | use super::*; 70 | 71 | #[get("/public_key/{public_key}")] 72 | pub async fn lookup_by_public_key( 73 | request: HttpRequest, 74 | app_state: web::Data, 75 | ) -> Result { 76 | let public_key = PublicKey::from_str(request.match_info().get("public_key").unwrap()) 77 | .map_err(|_| ServiceError::ArgumentError)?; 78 | 79 | tracing::debug!(target: TARGET_API, "Looking up account_ids for public_key: {}", public_key); 80 | 81 | let mut connection = app_state 82 | .redis_client 83 | .get_multiplexed_async_connection() 84 | .await?; 85 | 86 | let public_key = public_key.to_string(); 87 | 88 | let account_ids = database::query_with_prefix(&mut connection, "pk", &public_key).await?; 89 | 90 | Ok(web::Json(json!({ 91 | "public_key": public_key, 92 | "account_ids": account_ids.into_iter().filter_map(|(k, v)| if v == "f" { 93 | Some(k) 94 | } else { 95 | None 96 | }).collect::>(), 97 | }))) 98 | } 99 | 100 | #[get("/public_key/{public_key}/all")] 101 | pub async fn lookup_by_public_key_all( 102 | request: HttpRequest, 103 | app_state: web::Data, 104 | ) -> Result { 105 | let public_key = PublicKey::from_str(request.match_info().get("public_key").unwrap()) 106 | .map_err(|_| ServiceError::ArgumentError)?; 107 | 108 | tracing::debug!(target: TARGET_API, "Looking up account_ids for all public_key: {}", public_key); 109 | 110 | let mut connection = app_state 111 | .redis_client 112 | .get_multiplexed_async_connection() 113 | .await?; 114 | 115 | let public_key = public_key.to_string(); 116 | 117 | let account_ids = database::query_with_prefix(&mut connection, "pk", &public_key).await?; 118 | 119 | Ok(web::Json(json!({ 120 | "public_key": public_key, 121 | "account_ids": account_ids.into_iter().map(|(k, _v)| k).collect::>(), 122 | }))) 123 | } 124 | 125 | #[get("/account/{account_id}/staking")] 126 | pub async fn staking( 127 | request: HttpRequest, 128 | app_state: web::Data, 129 | ) -> Result { 130 | let account_id = 131 | AccountId::try_from(request.match_info().get("account_id").unwrap().to_string()) 132 | .map_err(|_| ServiceError::ArgumentError)?; 133 | 134 | tracing::debug!(target: TARGET_API, "Looking up validators for account_id: {}", account_id); 135 | 136 | let mut connection = app_state 137 | .redis_client 138 | .get_multiplexed_async_connection() 139 | .await?; 140 | 141 | let query_result = 142 | database::query_with_prefix(&mut connection, "st", &account_id.to_string()).await?; 143 | 144 | Ok(web::Json(json!({ 145 | "account_id": account_id, 146 | "pools": query_result.into_iter().map(|(k, _v)| k).collect::>(), 147 | }))) 148 | } 149 | 150 | #[get("/account/{account_id}/ft")] 151 | pub async fn ft( 152 | request: HttpRequest, 153 | app_state: web::Data, 154 | ) -> Result { 155 | let account_id = 156 | AccountId::try_from(request.match_info().get("account_id").unwrap().to_string()) 157 | .map_err(|_| ServiceError::ArgumentError)?; 158 | 159 | tracing::debug!(target: TARGET_API, "Looking up fungible tokens for account_id: {}", account_id); 160 | 161 | let mut connection = app_state 162 | .redis_client 163 | .get_multiplexed_async_connection() 164 | .await?; 165 | 166 | let query_result = 167 | database::query_with_prefix(&mut connection, "ft", &account_id.to_string()).await?; 168 | 169 | Ok(web::Json(json!({ 170 | "account_id": account_id, 171 | "contract_ids": query_result.into_iter().map(|(k, _v)| k).collect::>(), 172 | }))) 173 | } 174 | 175 | #[get("/account/{account_id}/nft")] 176 | pub async fn nft( 177 | request: HttpRequest, 178 | app_state: web::Data, 179 | ) -> Result { 180 | let account_id = 181 | AccountId::try_from(request.match_info().get("account_id").unwrap().to_string()) 182 | .map_err(|_| ServiceError::ArgumentError)?; 183 | 184 | tracing::debug!(target: TARGET_API, "Looking up non-fungible tokens for account_id: {}", account_id); 185 | 186 | let mut connection = app_state 187 | .redis_client 188 | .get_multiplexed_async_connection() 189 | .await?; 190 | 191 | let query_result = 192 | database::query_with_prefix(&mut connection, "nf", &account_id.to_string()).await?; 193 | 194 | Ok(web::Json(json!({ 195 | "account_id": account_id, 196 | "contract_ids": query_result.into_iter().map(|(k, _v)| k).collect::>(), 197 | }))) 198 | } 199 | } 200 | 201 | pub mod exp { 202 | use super::*; 203 | 204 | #[get("/account/{account_id}/ft_with_balances")] 205 | pub async fn ft_with_balances( 206 | request: HttpRequest, 207 | app_state: web::Data, 208 | ) -> Result { 209 | let account_id = 210 | AccountId::try_from(request.match_info().get("account_id").unwrap().to_string()) 211 | .map_err(|_| ServiceError::ArgumentError)?; 212 | 213 | tracing::debug!(target: TARGET_API, "Looking up fungible tokens for account_id: {}", account_id); 214 | 215 | let mut connection = app_state 216 | .redis_client 217 | .get_multiplexed_async_connection() 218 | .await?; 219 | 220 | let account_id = account_id.to_string(); 221 | 222 | let token_ids = 223 | database::query_with_prefix_parse(&mut connection, "ft", &account_id).await?; 224 | 225 | let token_balances: HashMap> = 226 | rpc::get_ft_balances(&account_id, &token_ids).await?; 227 | 228 | Ok(web::Json(json!({ 229 | "account_id": account_id, 230 | "tokens": token_balances, 231 | }))) 232 | } 233 | 234 | #[get("/ft/{token_id}/all")] 235 | pub async fn ft_all( 236 | request: HttpRequest, 237 | app_state: web::Data, 238 | ) -> Result { 239 | let token_id = 240 | AccountId::try_from(request.match_info().get("token_id").unwrap().to_string()) 241 | .map_err(|_| ServiceError::ArgumentError)?; 242 | 243 | tracing::debug!(target: TARGET_API, "Retrieving all holders for token: {}", token_id); 244 | 245 | let mut connection = app_state 246 | .redis_client 247 | .get_multiplexed_async_connection() 248 | .await?; 249 | 250 | let token_id = token_id.to_string(); 251 | 252 | let tokens_with_balances = 253 | database::query_with_prefix(&mut connection, "b", &token_id).await?; 254 | 255 | Ok(web::Json(json!({ 256 | "token_id": token_id, 257 | "accounts": tokens_with_balances.into_iter().map(|(account_id, balance)| json!({ 258 | "account_id": account_id, 259 | "balance": balance, 260 | })).collect::>() 261 | }))) 262 | } 263 | } 264 | 265 | pub mod v1 { 266 | use super::*; 267 | 268 | #[get("/account/{account_id}/staking")] 269 | pub async fn staking( 270 | request: HttpRequest, 271 | app_state: web::Data, 272 | ) -> Result { 273 | let account_id = 274 | AccountId::try_from(request.match_info().get("account_id").unwrap().to_string()) 275 | .map_err(|_| ServiceError::ArgumentError)?; 276 | 277 | tracing::debug!(target: TARGET_API, "Looking up validators for account_id: {}", account_id); 278 | 279 | let mut connection = app_state 280 | .redis_client 281 | .get_multiplexed_async_connection() 282 | .await?; 283 | 284 | let query_result = 285 | database::query_with_prefix_parse(&mut connection, "st", &account_id.to_string()) 286 | .await?; 287 | 288 | Ok(web::Json(json!({ 289 | "account_id": account_id, 290 | "pools": query_result.into_iter().map(|(pool_id, last_update_block_height)| json!({ 291 | "pool_id": pool_id, 292 | "last_update_block_height": last_update_block_height, 293 | })).collect::>() 294 | }))) 295 | } 296 | 297 | #[get("/account/{account_id}/ft")] 298 | pub async fn ft( 299 | request: HttpRequest, 300 | app_state: web::Data, 301 | ) -> Result { 302 | let account_id = 303 | AccountId::try_from(request.match_info().get("account_id").unwrap().to_string()) 304 | .map_err(|_| ServiceError::ArgumentError)?; 305 | 306 | tracing::debug!(target: TARGET_API, "Looking up fungible tokens for account_id: {}", account_id); 307 | 308 | let mut connection = app_state 309 | .redis_client 310 | .get_multiplexed_async_connection() 311 | .await?; 312 | 313 | let account_id = account_id.to_string(); 314 | 315 | let query_result = 316 | database::query_with_prefix_parse(&mut connection, "ft", &account_id).await?; 317 | let balances = database::query_balances( 318 | &mut connection, 319 | query_result 320 | .iter() 321 | .map(|(token_id, _)| (token_id.as_str(), account_id.as_str())) 322 | .collect::>() 323 | .as_slice(), 324 | ) 325 | .await?; 326 | 327 | Ok(web::Json(json!({ 328 | "account_id": account_id, 329 | "tokens": query_result.into_iter().zip(balances.into_iter()).map(|((contract_id, last_update_block_height), balance)| json!({ 330 | "contract_id": contract_id, 331 | "last_update_block_height": last_update_block_height, 332 | "balance": balance, 333 | })).collect::>() 334 | }))) 335 | } 336 | 337 | #[get("/account/{account_id}/nft")] 338 | pub async fn nft( 339 | request: HttpRequest, 340 | app_state: web::Data, 341 | ) -> Result { 342 | let account_id = 343 | AccountId::try_from(request.match_info().get("account_id").unwrap().to_string()) 344 | .map_err(|_| ServiceError::ArgumentError)?; 345 | 346 | tracing::debug!(target: TARGET_API, "Looking up non-fungible tokens for account_id: {}", account_id); 347 | 348 | let mut connection = app_state 349 | .redis_client 350 | .get_multiplexed_async_connection() 351 | .await?; 352 | 353 | let query_result = 354 | database::query_with_prefix_parse(&mut connection, "nf", &account_id.to_string()) 355 | .await?; 356 | 357 | Ok(web::Json(json!({ 358 | "account_id": account_id, 359 | "tokens": query_result.into_iter().map(|(contract_id, last_update_block_height)| json!({ 360 | "contract_id": contract_id, 361 | "last_update_block_height": last_update_block_height, 362 | })).collect::>() 363 | }))) 364 | } 365 | 366 | #[get("/ft/{token_id}/top")] 367 | pub async fn ft_top( 368 | request: HttpRequest, 369 | app_state: web::Data, 370 | ) -> Result { 371 | let token_id = 372 | AccountId::try_from(request.match_info().get("token_id").unwrap().to_string()) 373 | .map_err(|_| ServiceError::ArgumentError)?; 374 | 375 | tracing::debug!(target: TARGET_API, "Retrieving top holders for token: {}", token_id); 376 | 377 | let mut connection = app_state 378 | .redis_client 379 | .get_multiplexed_async_connection() 380 | .await?; 381 | 382 | let token_id = token_id.to_string(); 383 | 384 | let query_result = 385 | database::query_zset_by_score(&mut connection, &format!("tb:{}", token_id), 100) 386 | .await?; 387 | let balances = database::query_balances( 388 | &mut connection, 389 | query_result 390 | .iter() 391 | .map(|account_id| (token_id.as_str(), account_id.as_str())) 392 | .collect::>() 393 | .as_slice(), 394 | ) 395 | .await?; 396 | 397 | let mut top_holders = query_result 398 | .into_iter() 399 | .zip(balances.into_iter()) 400 | .collect::>(); 401 | 402 | top_holders.sort_unstable_by(|a, b| { 403 | ( 404 | b.1.as_ref() 405 | .and_then(|b| b.parse::().ok()) 406 | .unwrap_or(0), 407 | &b.0, 408 | ) 409 | .cmp(&( 410 | a.1.as_ref() 411 | .and_then(|b| b.parse::().ok()) 412 | .unwrap_or(0), 413 | &a.0, 414 | )) 415 | }); 416 | 417 | Ok(web::Json(json!({ 418 | "token_id": token_id, 419 | "accounts": top_holders.iter().map(|(account_id, balance)| json!({ 420 | "account_id": account_id, 421 | "balance": balance, 422 | })).collect::>() 423 | }))) 424 | } 425 | 426 | #[get("/account/{account_id}/full")] 427 | pub async fn account_full( 428 | request: HttpRequest, 429 | app_state: web::Data, 430 | ) -> Result { 431 | let account_id = 432 | AccountId::try_from(request.match_info().get("account_id").unwrap().to_string()) 433 | .map_err(|_| ServiceError::ArgumentError)?; 434 | 435 | tracing::debug!(target: TARGET_API, "Looking full data for account_id: {}", account_id); 436 | 437 | let mut connection = app_state 438 | .redis_client 439 | .get_multiplexed_async_connection() 440 | .await?; 441 | 442 | let account_id = account_id.to_string(); 443 | 444 | let query_result = 445 | database::query_with_prefix_parse(&mut connection, "st", &account_id.to_string()) 446 | .await?; 447 | 448 | let pools = query_result 449 | .into_iter() 450 | .map(|(pool_id, last_update_block_height)| { 451 | json!({ 452 | "pool_id": pool_id, 453 | "last_update_block_height": last_update_block_height, 454 | }) 455 | }) 456 | .collect::>(); 457 | 458 | let query_result = 459 | database::query_with_prefix_parse(&mut connection, "ft", &account_id).await?; 460 | let balances = database::query_balances( 461 | &mut connection, 462 | query_result 463 | .iter() 464 | .map(|(token_id, _)| (token_id.as_str(), account_id.as_str())) 465 | .collect::>() 466 | .as_slice(), 467 | ) 468 | .await?; 469 | let tokens = query_result 470 | .into_iter() 471 | .zip(balances.into_iter()) 472 | .map(|((contract_id, last_update_block_height), balance)| { 473 | json!({ 474 | "contract_id": contract_id, 475 | "last_update_block_height": last_update_block_height, 476 | "balance": balance, 477 | }) 478 | }) 479 | .collect::>(); 480 | 481 | let query_result = 482 | database::query_with_prefix_parse(&mut connection, "nf", &account_id.to_string()) 483 | .await?; 484 | 485 | let nfts = query_result 486 | .into_iter() 487 | .map(|(contract_id, last_update_block_height)| { 488 | json!({ 489 | "contract_id": contract_id, 490 | "last_update_block_height": last_update_block_height, 491 | }) 492 | }) 493 | .collect::>(); 494 | 495 | let state = database::query_hget(&mut connection, "accounts", &account_id) 496 | .await? 497 | .and_then(|state| { 498 | if state.is_empty() { 499 | None 500 | } else { 501 | serde_json::from_str::(&state).ok() 502 | } 503 | }); 504 | 505 | Ok(web::Json(json!({ 506 | "account_id": account_id, 507 | "pools": pools, 508 | "tokens": tokens, 509 | "nfts": nfts, 510 | "state": state.map(|state| json!({ 511 | "balance": state["b"], 512 | "locked": state["l"], 513 | "storage_bytes": state["s"], 514 | })), 515 | }))) 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /src/database.rs: -------------------------------------------------------------------------------- 1 | use crate::api::BlockHeight; 2 | 3 | const TARGET_DB: &str = "database"; 4 | 5 | #[derive(Debug)] 6 | pub enum DatabaseError { 7 | RedisError(redis::RedisError), 8 | } 9 | 10 | impl From for DatabaseError { 11 | fn from(error: redis::RedisError) -> Self { 12 | DatabaseError::RedisError(error) 13 | } 14 | } 15 | 16 | pub(crate) async fn query_with_prefix( 17 | connection: &mut redis::aio::MultiplexedConnection, 18 | prefix: &str, 19 | account_id: &str, 20 | ) -> Result, DatabaseError> { 21 | let start = std::time::Instant::now(); 22 | 23 | let res: redis::RedisResult> = redis::cmd("HGETALL") 24 | .arg(format!("{}:{}", prefix, account_id)) 25 | .query_async(connection) 26 | .await; 27 | 28 | let duration = start.elapsed().as_millis(); 29 | 30 | tracing::debug!(target: TARGET_DB, "Query {}ms: query_with_prefix {}:{}", 31 | duration, 32 | prefix, 33 | account_id); 34 | 35 | Ok(res?) 36 | } 37 | 38 | pub(crate) async fn query_with_prefix_parse( 39 | connection: &mut redis::aio::MultiplexedConnection, 40 | prefix: &str, 41 | account_id: &str, 42 | ) -> Result)>, DatabaseError> { 43 | let res = query_with_prefix(connection, prefix, account_id).await?; 44 | 45 | Ok(res.into_iter().map(|(k, v)| (k, v.parse().ok())).collect()) 46 | } 47 | 48 | pub(crate) async fn query_zset_by_score( 49 | connection: &mut redis::aio::MultiplexedConnection, 50 | key: &str, 51 | limit: usize, 52 | ) -> Result, DatabaseError> { 53 | let start = std::time::Instant::now(); 54 | 55 | let res: redis::RedisResult> = redis::cmd("ZRANGE") 56 | .arg(key) 57 | .arg("inf") 58 | .arg(0) 59 | .arg("BYSCORE") 60 | .arg("REV") 61 | .arg("LIMIT") 62 | .arg(0) 63 | .arg(limit) 64 | .query_async(connection) 65 | .await; 66 | 67 | let duration = start.elapsed().as_millis(); 68 | 69 | tracing::debug!(target: TARGET_DB, "Query {}ms: query_zset {}", 70 | duration, 71 | key); 72 | 73 | Ok(res?) 74 | } 75 | 76 | pub(crate) async fn query_balances( 77 | connection: &mut redis::aio::MultiplexedConnection, 78 | pairs: &[(&str, &str)], 79 | ) -> Result>, DatabaseError> { 80 | let start = std::time::Instant::now(); 81 | 82 | let mut pipe = redis::pipe(); 83 | for (token_id, account_id) in pairs { 84 | pipe.cmd("HGET") 85 | .arg(format!("b:{}", token_id)) 86 | .arg(account_id); 87 | } 88 | 89 | let res: redis::RedisResult>> = pipe.query_async(connection).await; 90 | 91 | let duration = start.elapsed().as_millis(); 92 | 93 | tracing::debug!(target: TARGET_DB, "Query {}ms: query_balances {} pairs", 94 | duration, 95 | pairs.len() 96 | ); 97 | 98 | Ok(res?) 99 | } 100 | 101 | pub(crate) async fn query_hget( 102 | connection: &mut redis::aio::MultiplexedConnection, 103 | key: &str, 104 | field: &str, 105 | ) -> Result, DatabaseError> { 106 | let start = std::time::Instant::now(); 107 | 108 | let res: redis::RedisResult> = redis::cmd("HGET") 109 | .arg(key) 110 | .arg(field) 111 | .query_async(connection) 112 | .await; 113 | 114 | let duration = start.elapsed().as_millis(); 115 | 116 | tracing::debug!(target: TARGET_DB, "Query {}ms: query_hget {} {}", 117 | duration, 118 | key, field); 119 | 120 | Ok(res?) 121 | } 122 | 123 | pub(crate) async fn query_get( 124 | connection: &mut redis::aio::MultiplexedConnection, 125 | key: &str, 126 | ) -> Result, DatabaseError> { 127 | let start = std::time::Instant::now(); 128 | 129 | let res: redis::RedisResult> = 130 | redis::cmd("GET").arg(key).query_async(connection).await; 131 | 132 | let duration = start.elapsed().as_millis(); 133 | 134 | tracing::debug!(target: TARGET_DB, "Query {}ms: query_get {}", 135 | duration, 136 | key); 137 | 138 | Ok(res?) 139 | } 140 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | mod database; 3 | mod redis_db; 4 | mod rpc; 5 | mod status; 6 | 7 | use dotenv::dotenv; 8 | use std::env; 9 | 10 | use actix_cors::Cors; 11 | use actix_web::http::header; 12 | use actix_web::{get, middleware, web, App, HttpRequest, HttpResponse, HttpServer, Responder}; 13 | use tracing_subscriber::EnvFilter; 14 | 15 | #[derive(Clone)] 16 | pub struct Config { 17 | pub max_healthy_latency_sec: f64, 18 | pub max_healthy_sync_block_diff: u64, 19 | } 20 | 21 | #[derive(Clone)] 22 | pub struct AppState { 23 | pub redis_client: redis::Client, 24 | pub config: Config, 25 | } 26 | 27 | async fn greet() -> impl Responder { 28 | HttpResponse::Ok().body("Hello, Actix Web!") 29 | } 30 | 31 | #[actix_web::main] 32 | async fn main() -> std::io::Result<()> { 33 | openssl_probe::init_ssl_cert_env_vars(); 34 | dotenv().ok(); 35 | 36 | tracing_subscriber::fmt::Subscriber::builder() 37 | .with_env_filter(EnvFilter::from_default_env()) 38 | // .with_env_filter(EnvFilter::new("debug")) 39 | .with_writer(std::io::stderr) 40 | .init(); 41 | 42 | let redis_client = 43 | redis::Client::open(env::var("REDIS_URL").expect("Missing REDIS_URL env var")) 44 | .expect("Failed to connect to Redis"); 45 | 46 | let config = Config { 47 | max_healthy_latency_sec: env::var("MAX_HEALTHY_SYNC_LATENCY_SEC") 48 | .map(|s| { 49 | s.parse() 50 | .expect("Failed to parse MAX_HEALTHY_SYNC_LATENCY_SEC") 51 | }) 52 | .unwrap_or(10.0), 53 | max_healthy_sync_block_diff: env::var("MAX_HEALTHY_SYNC_BLOCK_DIFF") 54 | .map(|s| { 55 | s.parse() 56 | .expect("Failed to parse MAX_HEALTHY_SYNC_BLOCK_DIFF") 57 | }) 58 | .unwrap_or(3), 59 | }; 60 | 61 | HttpServer::new(move || { 62 | // Configure CORS middleware 63 | let cors = Cors::default() 64 | .allow_any_origin() 65 | .allowed_methods(vec!["GET", "POST"]) 66 | .allowed_headers(vec![ 67 | header::CONTENT_TYPE, 68 | header::AUTHORIZATION, 69 | header::ACCEPT, 70 | ]) 71 | .max_age(3600) 72 | .supports_credentials(); 73 | 74 | let api_v0 = web::scope("/v0") 75 | .service(api::v0::lookup_by_public_key) 76 | .service(api::v0::lookup_by_public_key_all) 77 | .service(api::v0::staking) 78 | .service(api::v0::ft) 79 | .service(api::v0::nft); 80 | 81 | let mut api_exp = web::scope("/exp"); 82 | 83 | if env::var("EXPERIMENTAL_API").ok() == Some("true".to_string()) { 84 | api_exp = api_exp 85 | .service(api::exp::ft_with_balances) 86 | .service(api::exp::ft_all); 87 | } 88 | 89 | let api_v1 = web::scope("/v1") 90 | .service(api::v0::lookup_by_public_key) 91 | .service(api::v0::lookup_by_public_key_all) 92 | .service(api::v1::staking) 93 | .service(api::v1::ft) 94 | .service(api::v1::nft) 95 | .service(api::v1::ft_top) 96 | .service(api::v1::account_full); 97 | 98 | App::new() 99 | .app_data(web::Data::new(AppState { 100 | redis_client: redis_client.clone(), 101 | config: config.clone(), 102 | })) 103 | .wrap(cors) 104 | .wrap(middleware::Logger::new( 105 | "%{r}a \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\" %T", 106 | )) 107 | .wrap(tracing_actix_web::TracingLogger::default()) 108 | .service(api_v0) 109 | .service(api_exp) 110 | .service(api_v1) 111 | .service(status::status) 112 | .service(status::health) 113 | .route("/", web::get().to(greet)) 114 | }) 115 | .bind(format!("127.0.0.1:{}", env::var("PORT").unwrap()))? 116 | .run() 117 | .await?; 118 | 119 | Ok(()) 120 | } 121 | -------------------------------------------------------------------------------- /src/redis_db/mod.rs: -------------------------------------------------------------------------------- 1 | mod stream; 2 | 3 | use stream::*; 4 | 5 | use itertools::Itertools; 6 | use redis::aio::MultiplexedConnection; 7 | use redis::Client; 8 | use std::env; 9 | 10 | pub struct RedisDB { 11 | pub client: Client, 12 | pub connection: MultiplexedConnection, 13 | } 14 | 15 | #[allow(dead_code)] 16 | impl RedisDB { 17 | pub async fn new(redis_url: Option) -> Self { 18 | let client = Client::open( 19 | redis_url.unwrap_or_else(|| env::var("REDIS_URL").expect("Missing REDIS_URL env var")), 20 | ) 21 | .expect("Failed to connect to Redis"); 22 | let connection = client 23 | .get_multiplexed_async_connection() 24 | .await 25 | .expect("Failed to on Redis connection"); 26 | Self { client, connection } 27 | } 28 | 29 | pub async fn reconnect(&mut self) -> redis::RedisResult<()> { 30 | self.connection = self.client.get_multiplexed_async_connection().await?; 31 | Ok(()) 32 | } 33 | } 34 | 35 | #[allow(dead_code)] 36 | impl RedisDB { 37 | pub async fn set(&mut self, key: &str, value: &str) -> redis::RedisResult { 38 | redis::cmd("SET") 39 | .arg(key) 40 | .arg(value) 41 | .query_async(&mut self.connection) 42 | .await 43 | } 44 | 45 | pub async fn get(&mut self, key: &str) -> redis::RedisResult> { 46 | redis::cmd("GET") 47 | .arg(key) 48 | .query_async(&mut self.connection) 49 | .await 50 | } 51 | 52 | pub async fn xadd( 53 | &mut self, 54 | key: &str, 55 | id: &str, 56 | data: &[(String, String)], 57 | max_len: Option, 58 | ) -> redis::RedisResult { 59 | if let Some(max_len) = max_len { 60 | redis::cmd("XADD") 61 | .arg(key) 62 | .arg("MAXLEN") 63 | .arg("~") 64 | .arg(max_len) 65 | .arg(id) 66 | .arg(data) 67 | .query_async(&mut self.connection) 68 | .await 69 | } else { 70 | redis::cmd("XADD") 71 | .arg(key) 72 | .arg(id) 73 | .arg(data) 74 | .query_async(&mut self.connection) 75 | .await 76 | } 77 | } 78 | 79 | pub async fn xread( 80 | &mut self, 81 | count: usize, 82 | key: &str, 83 | id: &str, 84 | ) -> redis::RedisResult)>> { 85 | let streams: Vec = redis::cmd("XREAD") 86 | .arg("COUNT") 87 | .arg(count) 88 | .arg("BLOCK") 89 | .arg(0) 90 | .arg("STREAMS") 91 | .arg(key) 92 | .arg(id) 93 | .query_async(&mut self.connection) 94 | .await?; 95 | // Taking the first stream 96 | let stream = streams.into_iter().next().unwrap(); 97 | Ok(stream 98 | .entries 99 | .into_iter() 100 | .map(|entry| { 101 | let id = entry.id().unwrap(); 102 | let key_values = entry 103 | .key_values 104 | .into_iter() 105 | .map(|v| redis::from_redis_value::(&v).unwrap()) 106 | .tuples() 107 | .collect(); 108 | (id, key_values) 109 | }) 110 | .collect()) 111 | } 112 | 113 | pub async fn last_id(&mut self, key: &str) -> redis::RedisResult> { 114 | let entries: Vec = redis::cmd("XREVRANGE") 115 | .arg(key) 116 | .arg("+") 117 | .arg("-") 118 | .arg("COUNT") 119 | .arg(1) 120 | .query_async(&mut self.connection) 121 | .await?; 122 | Ok(entries.first().map(|e| e.id().unwrap())) 123 | } 124 | 125 | pub async fn hset(&mut self, key: &str, data: &[(String, String)]) -> redis::RedisResult<()> { 126 | redis::cmd("HSET") 127 | .arg(key) 128 | .arg(data) 129 | .query_async(&mut self.connection) 130 | .await 131 | } 132 | } 133 | 134 | #[macro_export] 135 | macro_rules! with_retries { 136 | ($db: expr, $f_async: expr) => { 137 | { 138 | let mut delay = tokio::time::Duration::from_millis(100); 139 | let max_retries = 10; 140 | let mut i = 0; 141 | loop { 142 | match $f_async(&mut $db.connection).await { 143 | Ok(v) => break Ok(v), 144 | Err(err) => { 145 | tracing::log::error!(target: "redis", "Attempt #{}: {}", i, err); 146 | tokio::time::sleep(delay).await; 147 | let _ = $db.reconnect().await; 148 | delay *= 2; 149 | if i == max_retries - 1 { 150 | break Err(err); 151 | } 152 | } 153 | }; 154 | i += 1; 155 | } 156 | } 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /src/redis_db/stream.rs: -------------------------------------------------------------------------------- 1 | use redis::{from_redis_value, FromRedisValue, RedisResult, Value}; 2 | 3 | pub struct Stream { 4 | id: Value, 5 | pub entries: Vec, 6 | } 7 | 8 | impl Stream { 9 | #[allow(dead_code)] 10 | pub fn id(&self) -> RedisResult { 11 | from_redis_value(&self.id) 12 | } 13 | } 14 | 15 | impl FromRedisValue for Stream { 16 | fn from_redis_value(v: &Value) -> RedisResult { 17 | let (id, entries): (Value, Vec) = from_redis_value(v)?; 18 | Ok(Stream { id, entries }) 19 | } 20 | } 21 | 22 | pub struct Entry { 23 | id: Value, 24 | pub key_values: Vec, 25 | } 26 | 27 | impl FromRedisValue for Entry { 28 | fn from_redis_value(v: &Value) -> RedisResult { 29 | let (id, key_values): (Value, Vec) = from_redis_value(v)?; 30 | Ok(Entry { id, key_values }) 31 | } 32 | } 33 | 34 | impl Entry { 35 | pub fn id(&self) -> RedisResult { 36 | from_redis_value(&self.id) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/rpc.rs: -------------------------------------------------------------------------------- 1 | use crate::api::BlockHeight; 2 | use base64::prelude::*; 3 | use reqwest::Client; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::{json, Value}; 6 | use std::collections::HashMap; 7 | use std::time::Duration; 8 | 9 | const RPC_TIMEOUT: Duration = Duration::from_secs(10); 10 | const TARGET_RPC: &str = "rpc"; 11 | 12 | #[derive(Debug)] 13 | pub enum RpcError { 14 | ReqwestError(reqwest::Error), 15 | InvalidJsonRpcResponse, 16 | InvalidFunctionCallResponse, 17 | } 18 | 19 | impl From for RpcError { 20 | fn from(error: reqwest::Error) -> Self { 21 | RpcError::ReqwestError(error) 22 | } 23 | } 24 | 25 | #[derive(Serialize)] 26 | struct JsonRequest { 27 | jsonrpc: String, 28 | method: String, 29 | params: Value, 30 | id: String, 31 | } 32 | 33 | #[derive(Deserialize)] 34 | struct JsonResponse { 35 | id: String, 36 | // jsonrpc: String, 37 | result: Option, 38 | // error: Option, 39 | } 40 | 41 | #[derive(Deserialize)] 42 | struct FunctionCallResponse { 43 | // block_hash: String, 44 | // block_height: u64, 45 | result: Vec, 46 | } 47 | 48 | pub(crate) async fn get_ft_balances( 49 | account_id: &str, 50 | token_ids: &[(String, Option)], 51 | ) -> Result>, RpcError> { 52 | let mut token_balances = HashMap::new(); 53 | if token_ids.is_empty() { 54 | return Ok(token_balances); 55 | } 56 | let start = std::time::Instant::now(); 57 | let client = Client::new(); 58 | let request = token_ids 59 | .iter() 60 | .enumerate() 61 | .map(|(id, (token_id, _))| JsonRequest { 62 | jsonrpc: "2.0".to_string(), 63 | method: "query".to_string(), 64 | params: json!({ 65 | "request_type": "call_function", 66 | "finality": "final", 67 | "account_id": token_id, 68 | "method_name": "ft_balance_of", 69 | "args_base64": BASE64_STANDARD.encode(format!("{{\"account_id\": \"{}\"}}", account_id)), 70 | }), 71 | id: id.to_string(), 72 | }) 73 | .collect::>(); 74 | let response = client 75 | .post("https://beta.rpc.mainnet.near.org") 76 | .json(&request) 77 | .timeout(RPC_TIMEOUT) 78 | .send() 79 | .await?; 80 | let responses = response.json::>().await?; 81 | for response in responses { 82 | let id: usize = response 83 | .id 84 | .parse() 85 | .map_err(|_| RpcError::InvalidJsonRpcResponse)?; 86 | let token_id = token_ids 87 | .get(id) 88 | .ok_or(RpcError::InvalidJsonRpcResponse)? 89 | .clone() 90 | .0; 91 | let balance = if let Some(res) = response.result { 92 | let fc: FunctionCallResponse = 93 | serde_json::from_value(res).map_err(|_| RpcError::InvalidFunctionCallResponse)?; 94 | let balance: Option = serde_json::from_slice(&fc.result).ok(); 95 | let parsed_balance: Option = balance.and_then(|s| s.parse().ok()); 96 | parsed_balance.map(|b| b.to_string()) 97 | } else { 98 | None 99 | }; 100 | token_balances.insert(token_id, balance); 101 | } 102 | let duration = start.elapsed().as_millis(); 103 | 104 | tracing::debug!(target: TARGET_RPC, "Query {}ms: get_ft_balances {} with {} tokens", 105 | duration, 106 | account_id, 107 | token_ids.len()); 108 | 109 | Ok(token_balances) 110 | } 111 | -------------------------------------------------------------------------------- /src/status.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | use actix_web::{get, web, Responder}; 3 | use serde_json::json; 4 | 5 | async fn internal_status( 6 | app_state: &web::Data, 7 | ) -> Result { 8 | let mut connection = app_state 9 | .redis_client 10 | .get_multiplexed_async_connection() 11 | .await?; 12 | 13 | let latest_sync_block = database::query_get(&mut connection, "meta:latest_block").await?; 14 | let latest_block_time = database::query_get(&mut connection, "meta:latest_block_time").await?; 15 | let latest_balance_block = 16 | database::query_get(&mut connection, "meta:latest_balance_block").await?; 17 | 18 | let sync_latency_sec = latest_block_time.as_ref().map(|t| { 19 | let t_nano = t.parse::().unwrap_or(0); 20 | let now = std::time::SystemTime::now() 21 | .duration_since(std::time::UNIX_EPOCH) 22 | .unwrap_or_default(); 23 | now.as_nanos().saturating_sub(t_nano) as f64 / 1e9 24 | }); 25 | 26 | Ok(json!({ 27 | "version": env!("CARGO_PKG_VERSION"), 28 | "sync_block_height": latest_sync_block.map(|s| s.parse::().unwrap_or(0)), 29 | "sync_latency_sec": sync_latency_sec, 30 | "sync_block_timestamp_nanosec": latest_block_time, 31 | "sync_balance_block_height": latest_balance_block.map(|s| s.parse::().unwrap_or(0)), 32 | })) 33 | } 34 | 35 | fn is_healthy(v: serde_json::Value, config: &Config) -> Option<()> { 36 | let latency = v["sync_latency_sec"].as_f64()?; 37 | if latency > config.max_healthy_latency_sec { 38 | return None; 39 | } 40 | let latest_sync_block = v["sync_block_height"].as_u64()?; 41 | let latest_balance_block = v["sync_balance_block_height"].as_u64()?; 42 | if latest_sync_block.saturating_sub(latest_balance_block) > config.max_healthy_sync_block_diff { 43 | None 44 | } else { 45 | Some(()) 46 | } 47 | } 48 | 49 | #[get("/status")] 50 | pub async fn status( 51 | app_state: web::Data, 52 | ) -> Result { 53 | internal_status(&app_state).await.map(|res| web::Json(res)) 54 | } 55 | 56 | #[get("/health")] 57 | pub async fn health(app_state: web::Data) -> Result { 58 | let res = internal_status(&app_state).await?; 59 | Ok(web::Json( 60 | json!({"status": is_healthy(res, &app_state.config).map(|_| "ok").unwrap_or("unhealthy")}), 61 | )) 62 | } 63 | --------------------------------------------------------------------------------