├── .env.example ├── .eslintignore ├── .prettierignore ├── .husky └── pre-commit ├── .gitignore ├── .prettierrc ├── config ├── mainnet.json └── testnet.json ├── tsconfig.json ├── .lintstagedrc.mjs ├── .eslintrc ├── src ├── helper │ ├── stake.ts │ ├── price.ts │ └── initializer.ts └── mapping │ ├── liquidity-pool.ts │ ├── epoch-action.ts │ ├── fungible-token.ts │ ├── stake.ts │ └── index.ts ├── subgraph.template.yaml ├── README.md ├── LICENSE ├── docker-compose.yml ├── test ├── helper.js ├── config.js ├── price.js ├── apy.js └── stake.js ├── package.json ├── schema.graphql └── docs └── tutorial.md /.env.example: -------------------------------------------------------------------------------- 1 | SLUG=linear-testnet 2 | ACCESS_TOKEN= 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | generated 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | subgraph.yaml 2 | subgraph.template.yaml -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | generated 4 | .env 5 | yarn-error.log 6 | subgraph.yaml 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "es5", 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /config/mainnet.json: -------------------------------------------------------------------------------- 1 | { 2 | "network": "near-mainnet", 3 | "contract": "linear-protocol.near", 4 | "startBlock": 61147683 5 | } 6 | -------------------------------------------------------------------------------- /config/testnet.json: -------------------------------------------------------------------------------- 1 | { 2 | "network": "near-testnet", 3 | "contract": "linear-protocol.testnet", 4 | "startBlock": 84652738 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extend": "./node_modules/@graphprotocol/graph-ts/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@graphprotocol/graph-ts"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | "src/**/*.ts": [ 3 | () => "tsc --project tsconfig.json --alwaysStrict --noEmit", 4 | "prettier --write", 5 | "eslint --ext .ts --fix", 6 | ], 7 | "*.js": [ 8 | "prettier --write", 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": ["plugin:@typescript-eslint/recommended", "prettier", "prettier/@typescript-eslint"], 5 | "rules": { 6 | "prefer-const": "off", 7 | "@typescript-eslint/no-inferrable-types": "off", 8 | "@typescript-eslint/ban-ts-ignore": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/helper/stake.ts: -------------------------------------------------------------------------------- 1 | import { BigInt } from "@graphprotocol/graph-ts"; 2 | import { StakeAmountChange } from '../../generated/schema'; 3 | 4 | export function addStakeAmountChange( 5 | receiptId: string, 6 | accountId: string, 7 | timestamp: BigInt, 8 | amount: BigInt 9 | ): void { 10 | const change = new StakeAmountChange(receiptId); 11 | change.accountId = accountId; 12 | change.timestamp = timestamp; 13 | change.amount = amount; 14 | change.save(); 15 | } 16 | -------------------------------------------------------------------------------- /subgraph.template.yaml: -------------------------------------------------------------------------------- 1 | specVersion: 0.0.4 2 | description: LiNEAR Protocol subgraph 3 | repository: https://github.com/linear-protocol/linear-subgraph 4 | schema: 5 | file: ./schema.graphql 6 | dataSources: 7 | - kind: near 8 | name: receipts 9 | network: {{network}} 10 | source: 11 | account: "{{contract}}" 12 | startBlock: {{startBlock}} 13 | mapping: 14 | apiVersion: 0.0.5 15 | language: wasm/assemblyscript 16 | file: ./src/mapping/index.ts 17 | entities: 18 | - User 19 | - Price 20 | - TotalSwapFee 21 | - Status 22 | - FtTransfer 23 | - ValidatorEpochInfo 24 | receiptHandlers: 25 | - handler: handleReceipt 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LiNEAR Protocol subgraph 2 | 3 | ## Development 4 | 5 | ```bash 6 | # copy env and adjust its content 7 | # you can get an access token from https://thegraph.com/explorer/dashboard 8 | cp .env.example .env 9 | # install project dependencies 10 | yarn 11 | # prepare subgraph.yaml 12 | yarn prepare:mainnet 13 | # run codegen 14 | yarn codegen 15 | # now you're able to deploy to thegraph via 16 | yarn deploy 17 | ``` 18 | 19 | ## Deployment 20 | 21 | To be able to deploy to the hosted solution you will need to create a .env file and add `ACCESS_TOKEN` environment variable. You can find this in the dashboard of the TheGraph 22 | 23 | ``` 24 | // For Testnet: 25 | yarn deploy:testnet 26 | 27 | // For Mainnet: 28 | yarn deploy:mainnet 29 | ``` 30 | 31 | ## Test 32 | 33 | To test the deployed subgraph, you can try querying with the examples. 34 | 35 | ```bash 36 | yarn 37 | # test APY data 38 | node test/apy.js 39 | # test staking rewards data 40 | node test/stake.js 41 | ``` 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lit Tech Studio Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | graph-node: 4 | image: graphprotocol/graph-node:v0.22.0 5 | ports: 6 | - '8000:8000' 7 | - '8001:8001' 8 | - '8020:8020' 9 | - '8030:8030' 10 | - '8040:8040' 11 | depends_on: 12 | - ipfs 13 | - postgres 14 | environment: 15 | postgres_host: postgres 16 | postgres_user: graph-node 17 | postgres_pass: let-me-in 18 | postgres_db: graph-node 19 | ipfs: 'ipfs:5001' 20 | # Change next line if you want to connect to a different JSON-RPC endpoint 21 | ethereum: 'mainnet:http://host.docker.internal:8545' 22 | GRAPH_LOG: info 23 | ipfs: 24 | image: ipfs/go-ipfs:v0.4.23 25 | ports: 26 | - '5001:5001' 27 | volumes: 28 | - ./data/ipfs:/data/ipfs 29 | postgres: 30 | image: postgres 31 | ports: 32 | - '5432:5432' 33 | command: ["postgres", "-cshared_preload_libraries=pg_stat_statements"] 34 | environment: 35 | POSTGRES_USER: graph-node 36 | POSTGRES_PASSWORD: let-me-in 37 | POSTGRES_DB: graph-node 38 | volumes: 39 | - ./data/postgres:/var/lib/postgresql/data 40 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | require('process'); 2 | require('isomorphic-unfetch'); 3 | const { createClient } = require('urql'); 4 | const { connect, Contract } = require('near-api-js'); 5 | 6 | const NETWORK = 'mainnet'; 7 | const config = require('./config')[NETWORK]; 8 | 9 | const client = createClient({ 10 | url: config.subgraph.apiUrl, 11 | }) 12 | 13 | let contract = null; 14 | 15 | async function loadContract() { 16 | const near = await connect(config.near); 17 | const account = await near.account(""); 18 | if (!contract) { 19 | contract = new Contract( 20 | account, // the account object that is connecting 21 | config.contract_id, 22 | { 23 | // name of contract you're connecting to 24 | viewMethods: ["ft_price", "get_summary", "ft_balance_of", "get_account"], // view methods do not change state but usually return a value 25 | changeMethods: [],// change methods modify state 26 | //, // account object to initialize and sign transactions. 27 | } 28 | ); 29 | } 30 | return contract; 31 | } 32 | 33 | async function getSummaryFromContract() { 34 | const contract = await loadContract(); 35 | let response = await contract.get_summary(); 36 | //console.log(response); 37 | return response 38 | } 39 | 40 | module.exports = { 41 | client, 42 | loadContract, 43 | getSummaryFromContract, 44 | } 45 | -------------------------------------------------------------------------------- /src/helper/price.ts: -------------------------------------------------------------------------------- 1 | import { near, BigInt, BigDecimal } from '@graphprotocol/graph-ts'; 2 | import { Price } from '../../generated/schema'; 3 | import { getOrInitPrice, getOrInitStatus } from './initializer'; 4 | 5 | export function updatePrice( 6 | event: string, 7 | method: string, 8 | receipt: near.ReceiptWithOutcome, 9 | deltaNear: BigDecimal, 10 | deltaLinear: BigDecimal 11 | ): void { 12 | const timestamp = receipt.block.header.timestampNanosec; 13 | const receiptHash = receipt.receipt.id.toBase58(); 14 | 15 | let status = getOrInitStatus(); 16 | let lastPrice = getOrInitPrice(status.priceVersion.toString()); 17 | 18 | // create new price 19 | const nextVersion = status.priceVersion.plus(BigInt.fromU32(1)); 20 | let nextPrice = new Price(nextVersion.toString()); 21 | nextPrice.deltaNearAmount = deltaNear; 22 | nextPrice.deltaLinearAmount = deltaLinear; 23 | nextPrice.totalNearAmount = lastPrice.totalNearAmount.plus(deltaNear); 24 | nextPrice.totalLinearAmount = lastPrice.totalLinearAmount.plus(deltaLinear); 25 | nextPrice.price = nextPrice.totalNearAmount.div(nextPrice.totalLinearAmount); 26 | nextPrice.timestamp = BigInt.fromU64(timestamp); 27 | nextPrice.event = event; 28 | nextPrice.receiptHash = receiptHash; 29 | nextPrice.method = method; 30 | nextPrice.save(); 31 | 32 | // update status 33 | status.priceVersion = nextVersion; 34 | status.price = nextPrice.price; 35 | status.save(); 36 | } 37 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | const { keyStores } = require('near-api-js'); 2 | const { BigNumber } = require('bignumber.js'); 3 | 4 | const keyStore = new keyStores.InMemoryKeyStore(); 5 | 6 | BigNumber.config({ 7 | DECIMAL_PLACES: 64, 8 | }); 9 | 10 | const config = { 11 | mainnet: { 12 | near: { 13 | networkId: 'mainnet', 14 | keyStore, // optional if not signing transactions 15 | nodeUrl: 16 | process.env.NEAR_CLI_MAINNET_RPC_SERVER_URL || 17 | 'https://rpc.mainnet.near.org', 18 | walletUrl: 'https://wallet.near.org', 19 | helperUrl: 'https://helper.near.org', 20 | explorerUrl: 'https://explorer.near.org', 21 | }, 22 | subgraph: { 23 | apiUrl: 24 | 'https://api.studio.thegraph.com/query/76854/linear/version/latest', 25 | }, 26 | contract_id: 'linear-protocol.near', 27 | }, 28 | testnet: { 29 | near: { 30 | networkId: 'testnet', 31 | keyStore, // optional if not signing transactions 32 | nodeUrl: 33 | process.env.NEAR_CLI_TESTNET_RPC_SERVER_URL || 34 | 'https://rpc.testnet.near.org', 35 | walletUrl: 'https://wallet.testnet.near.org', 36 | helperUrl: 'https://helper.testnet.near.org', 37 | explorerUrl: 'https://explorer.testnet.near.org', 38 | }, 39 | subgraph: { 40 | apiUrl: 41 | 'https://api.studio.thegraph.com/query/76854/linear-testnet/version/latest', 42 | }, 43 | contract_id: 'linear-protocol.testnet', 44 | }, 45 | }; 46 | 47 | module.exports = config; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linear-subgraph", 3 | "version": "0.1.0", 4 | "repository": "https://github.com/linear-protocol/linear-subgraph", 5 | "license": "MIT", 6 | "scripts": { 7 | "prepare:mainnet": "mustache config/mainnet.json subgraph.template.yaml > subgraph.yaml", 8 | "prepare:testnet": "mustache config/testnet.json subgraph.template.yaml > subgraph.yaml", 9 | "codegen": "graph codegen", 10 | "predeploy": "yarn codegen", 11 | "deploy": "env-cmd --no-override yarn deploy:studio", 12 | "deploy:studio": "graph deploy --studio ${SLUG} --access-token ${ACCESS_TOKEN}", 13 | "deploy:mainnet": "yarn prepare:mainnet && SLUG=linear yarn deploy", 14 | "deploy:testnet": "yarn prepare:testnet && SLUG=linear-testnet yarn deploy", 15 | "deploy-local": "graph deploy linear --ipfs http://localhost:5001 --node http://127.0.0.1:8020", 16 | "lint": "eslint . --ext .ts", 17 | "prepare": "husky install" 18 | }, 19 | "devDependencies": { 20 | "@graphprotocol/graph-cli": "^0.61.0", 21 | "@graphprotocol/graph-ts": "^0.26.0", 22 | "@typescript-eslint/eslint-plugin": "^2.0.0", 23 | "@typescript-eslint/parser": "^2.0.0", 24 | "env-cmd": "^10.1.0", 25 | "eslint": "^6.2.2", 26 | "eslint-config-prettier": "^6.1.0", 27 | "husky": "^8.0.1", 28 | "mustache": "^4.2.0", 29 | "prettier": "^1.18.2" 30 | }, 31 | "dependencies": { 32 | "@apollo/client": "^3.5.10", 33 | "babel-polyfill": "^6.26.0", 34 | "babel-register": "^6.26.0", 35 | "big.js": "^6.1.1", 36 | "bignumber.js": "^9.0.2", 37 | "graphql": "^16.4.0", 38 | "isomorphic-unfetch": "^3.1.0", 39 | "near-api-js": "^0.44.2", 40 | "node-fetch": "^3.2.3", 41 | "urql": "^2.2.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | type User @entity{ 2 | id: ID! 3 | mintedLinear: BigInt! 4 | unstakedLinear: BigInt! 5 | stakedNear: BigInt! 6 | unstakeReceivedNear: BigInt! 7 | firstStakingTime: BigInt! 8 | transferedInValue: BigDecimal! 9 | transferedOutValue: BigDecimal! 10 | transferedInShares: BigInt! 11 | transferedOutShares: BigInt! 12 | feesPaid: BigInt! 13 | } 14 | 15 | type Price @entity{ 16 | id: ID! 17 | timestamp: BigInt! 18 | method: String! 19 | event: String! 20 | receiptHash: String! 21 | deltaLinearAmount: BigDecimal! 22 | deltaNearAmount: BigDecimal! 23 | totalLinearAmount: BigDecimal! 24 | totalNearAmount: BigDecimal! 25 | price: BigDecimal! 26 | } 27 | 28 | type TotalSwapFee @entity{ 29 | id: ID! 30 | timestamp: BigInt! 31 | feesPaid: BigInt! 32 | } 33 | 34 | # Records the latest versions and statues 35 | type Status @entity{ 36 | id: ID! 37 | price: BigDecimal! 38 | priceVersion: BigInt! 39 | totalSwapFeeVersion: BigInt! 40 | } 41 | 42 | # Raw NEP-297 Events 43 | type FtTransfer @entity { 44 | id: ID! 45 | to: User! 46 | from: User! 47 | timestamp: String! 48 | price: BigDecimal! 49 | amount: BigInt! 50 | } 51 | 52 | type ValidatorEpochInfo @entity { 53 | id: ID! 54 | epochId: String! 55 | validatorId: String! 56 | epochUnstakedAmount: BigInt! 57 | } 58 | 59 | type EpochCleanup @entity { 60 | """ 61 | The ID is the epoch ID 62 | """ 63 | id: ID! 64 | timestamp: BigInt! 65 | stakeAmountToSettle: BigInt! 66 | unstakeAmountToSettle: BigInt! 67 | } 68 | 69 | type StakeAmountChange @entity { 70 | id: ID! 71 | accountId: String! 72 | timestamp: BigInt! 73 | amount: BigInt! 74 | } 75 | -------------------------------------------------------------------------------- /test/price.js: -------------------------------------------------------------------------------- 1 | const { utils } = require('near-api-js'); 2 | const { client, loadContract } = require('./helper'); 3 | 4 | async function queryPriceBefore(timestamp) { 5 | const getBeforeQuery = ` 6 | query { 7 | prices (first: 1, orderBy: timestamp, orderDirection: desc, 8 | where: {timestamp_lte: "${timestamp.toString()}"} ) 9 | { 10 | id 11 | timestamp 12 | price 13 | } 14 | }`; 15 | //console.log(getBeforeQuery) 16 | let data = await client.query(getBeforeQuery).toPromise(); 17 | let queryData = data.data; 18 | if (queryData == null) { 19 | throw new Error('fail to query price'); 20 | } 21 | // console.log("price at %s : %s",timestamp.toString(),queryData.prices[0].price.toString()) 22 | return queryData.prices[0]; 23 | } 24 | 25 | async function queryLatestPriceFromContract() { 26 | const contract = await loadContract(); 27 | const price = await contract.ft_price(); 28 | return { 29 | price: utils.format.formatNearAmount(price), 30 | timestamp: Date.now() * 1000000, 31 | }; 32 | } 33 | 34 | async function queryLatestPriceFromSubgraph() { 35 | const getLatestQuery = ` 36 | query { 37 | prices (first: 1, orderBy: timestamp, orderDirection: desc){ 38 | id 39 | timestamp 40 | price 41 | } 42 | } 43 | `; 44 | let data = await client.query(getLatestQuery).toPromise(); 45 | let queryData = data.data; 46 | if (queryData == null) { 47 | throw new Error('fail to query price'); 48 | } 49 | // console.log("current price: ",queryData.prices[0].price.toString()) 50 | return queryData.prices[0]; 51 | } 52 | 53 | module.exports = { 54 | queryPriceBefore, 55 | queryLatestPriceFromContract, 56 | queryLatestPriceFromSubgraph, 57 | }; 58 | -------------------------------------------------------------------------------- /src/helper/initializer.ts: -------------------------------------------------------------------------------- 1 | import { BigDecimal, BigInt, log } from '@graphprotocol/graph-ts'; 2 | import { Price, User, Status, TotalSwapFee } from '../../generated/schema'; 3 | 4 | export function getOrInitUser(accountId: string): User { 5 | let user = User.load(accountId); 6 | if (!user) { 7 | log.info('create user {}', [accountId]); 8 | user = new User(accountId); 9 | user.firstStakingTime = BigInt.zero(); 10 | user.mintedLinear = BigInt.zero(); 11 | user.stakedNear = BigInt.zero(); 12 | user.unstakeReceivedNear = BigInt.zero(); 13 | user.unstakedLinear = BigInt.zero(); 14 | user.transferedInShares = BigInt.zero(); 15 | user.transferedOutShares = BigInt.zero(); 16 | user.transferedOutValue = BigDecimal.zero(); 17 | user.transferedInValue = BigDecimal.zero(); 18 | user.feesPaid = BigInt.zero(); 19 | user.save(); 20 | } 21 | return user as User; 22 | } 23 | 24 | export function getOrInitPrice(priceID: string): Price { 25 | let price = Price.load(priceID); 26 | if (!price) { 27 | log.info('create price {}', [priceID]); 28 | const TEN_NEAR = BigDecimal.fromString('10000000000000000000000000'); 29 | price = new Price(priceID); 30 | price.timestamp = BigInt.zero(); 31 | price.deltaLinearAmount = BigDecimal.zero(); 32 | price.deltaNearAmount = BigDecimal.zero(); 33 | // init with 10 near and 10 linear 34 | price.totalLinearAmount = TEN_NEAR; 35 | price.totalNearAmount = TEN_NEAR; 36 | price.event = ''; 37 | price.method = ''; 38 | price.price = BigDecimal.zero(); 39 | price.receiptHash = ''; 40 | price.save(); 41 | } 42 | return price as Price; 43 | } 44 | 45 | export function getOrInitStatus(): Status { 46 | let status = Status.load('status'); 47 | if (!status) { 48 | status = new Status('status'); 49 | status.price = BigDecimal.zero(); 50 | status.priceVersion = BigInt.zero(); 51 | status.totalSwapFeeVersion = BigInt.zero(); 52 | status.save(); 53 | } 54 | return status as Status; 55 | } 56 | 57 | export function getOrInitTotalSwapFee(version: string): TotalSwapFee { 58 | let totalSwapFee = TotalSwapFee.load(version); 59 | if (!totalSwapFee) { 60 | totalSwapFee = new TotalSwapFee(version); 61 | totalSwapFee.timestamp = BigInt.zero(); 62 | totalSwapFee.feesPaid = BigInt.zero(); 63 | totalSwapFee.save(); 64 | } 65 | return totalSwapFee as TotalSwapFee; 66 | } 67 | -------------------------------------------------------------------------------- /src/mapping/liquidity-pool.ts: -------------------------------------------------------------------------------- 1 | import { 2 | near, 3 | BigInt, 4 | JSONValue, 5 | TypedMap, 6 | BigDecimal, 7 | } from '@graphprotocol/graph-ts'; 8 | import { 9 | getOrInitUser, 10 | getOrInitStatus, 11 | getOrInitTotalSwapFee, 12 | } from '../helper/initializer'; 13 | import { updatePrice } from '../helper/price'; 14 | 15 | export function handleInstantUnstake(data: TypedMap): void { 16 | // parse event 17 | let accountId = data.get('account_id')!.toString(); 18 | let unstakeAmountStr = data.get('unstaked_amount')!.toString(); 19 | let unstakeLinearAmountStr = data.get('swapped_stake_shares')!.toString(); 20 | let unstakeAmount = BigInt.fromString(unstakeAmountStr); 21 | let unstakeLinearAmount = BigInt.fromString(unstakeLinearAmountStr); 22 | let feesPaid = BigInt.fromString(data.get('fee_amount')!.toString()); 23 | 24 | // update user 25 | let user = getOrInitUser(accountId); 26 | user.unstakeReceivedNear = user.unstakeReceivedNear.plus(unstakeAmount); 27 | user.unstakedLinear = user.unstakedLinear.plus(unstakeLinearAmount); 28 | user.feesPaid = user.feesPaid.plus(feesPaid); 29 | user.save(); 30 | } 31 | 32 | export function handleLiquidityPoolSwapFee( 33 | data: TypedMap, 34 | receipt: near.ReceiptWithOutcome 35 | ): void { 36 | const timestamp = receipt.block.header.timestampNanosec; 37 | const poolFee = data.get('pool_fee_stake_shares')!; 38 | 39 | // update version 40 | let status = getOrInitStatus(); 41 | const currentVersion = status.totalSwapFeeVersion; 42 | const nextVersion = currentVersion.plus(BigInt.fromU32(1)); 43 | status.totalSwapFeeVersion = nextVersion; 44 | status.save(); 45 | 46 | // update total swap fee 47 | const lastTotalFee = getOrInitTotalSwapFee(currentVersion.toString()); 48 | let nextTotalFee = getOrInitTotalSwapFee(nextVersion.toString()); 49 | nextTotalFee.timestamp = BigInt.fromU64(timestamp); 50 | nextTotalFee.feesPaid = lastTotalFee.feesPaid.plus( 51 | BigInt.fromString(poolFee.toString()) 52 | ); 53 | nextTotalFee.save(); 54 | } 55 | 56 | export function handleRebalanceLiquidity( 57 | method: string, 58 | event: string, 59 | data: TypedMap, 60 | receipt: near.ReceiptWithOutcome 61 | ): void { 62 | const increaseAmountStr = data.get('increased_amount')!.toString(); 63 | const burnedSharesStr = data.get('burnt_stake_shares')!.toString(); 64 | const burnedSharesFloat = BigDecimal.fromString(burnedSharesStr); 65 | const increasedAmountFloat = BigDecimal.fromString(increaseAmountStr); 66 | 67 | updatePrice( 68 | event, 69 | method, 70 | receipt, 71 | increasedAmountFloat.neg(), 72 | burnedSharesFloat.neg() 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/mapping/epoch-action.ts: -------------------------------------------------------------------------------- 1 | import { near, BigInt, JSONValue, TypedMap, BigDecimal } from '@graphprotocol/graph-ts'; 2 | import { updatePrice } from '../helper/price'; 3 | import { EpochCleanup, ValidatorEpochInfo } from '../../generated/schema'; 4 | 5 | export function handleEpochUpdateRewards( 6 | method: string, 7 | event: string, 8 | data: TypedMap, 9 | receipt: near.ReceiptWithOutcome 10 | ): void { 11 | const rewards = BigDecimal.fromString(data.get('rewards')!.toString()); 12 | updatePrice(event, method, receipt, rewards, BigDecimal.zero()); 13 | } 14 | 15 | export function handleEpochUnstakeSuccess( 16 | data: TypedMap, 17 | receipt: near.ReceiptWithOutcome 18 | ): void { 19 | const amount = BigInt.fromString(data.get('amount')!.toString()); 20 | 21 | const epochId = receipt.block.header.epochId.toBase58(); 22 | const validatorId = data.get('validator_id')!.toString(); 23 | const id = epochId + '#' + validatorId; 24 | 25 | const entity = ValidatorEpochInfo.load(id); 26 | 27 | if (!entity) { 28 | const entity = new ValidatorEpochInfo(id); 29 | 30 | entity.epochId = epochId; 31 | entity.validatorId = validatorId; 32 | entity.epochUnstakedAmount = amount; 33 | 34 | entity.save(); 35 | } else { 36 | // Logic here should be redundant because one validator can only be epoch_unstake() once. 37 | // But just in case, we still keep it here. 38 | entity.epochUnstakedAmount = entity.epochUnstakedAmount.plus(amount); 39 | 40 | entity.save(); 41 | } 42 | } 43 | 44 | export function handleEpochCleanup( 45 | data: TypedMap, 46 | receipt: near.ReceiptWithOutcome 47 | ): void { 48 | const timestamp = receipt.block.header.timestampNanosec; 49 | 50 | const stakeAmountToSettle = BigInt.fromString(data.get('stake_amount_to_settle')!.toString()); 51 | const unstakeAmountToSettle = BigInt.fromString(data.get('unstake_amount_to_settle')!.toString()); 52 | 53 | // Use epochId as id 54 | const id = receipt.block.header.epochId.toBase58(); 55 | 56 | const entity = EpochCleanup.load(id); 57 | if (entity) { 58 | if (!entity.stakeAmountToSettle.equals(stakeAmountToSettle) || 59 | !entity.unstakeAmountToSettle.equals(unstakeAmountToSettle) 60 | ) { 61 | throw new Error(`EpochCleanup entity at epoch ${id} already exists with mismatched stake and unstake amount to settle. The receipt of the mismatched event: ${receipt.receipt.id.toBase58()}`); 62 | } 63 | } else { 64 | const entity = new EpochCleanup(id); 65 | 66 | entity.timestamp = BigInt.fromU64(timestamp); 67 | entity.stakeAmountToSettle = stakeAmountToSettle; 68 | entity.unstakeAmountToSettle = unstakeAmountToSettle; 69 | 70 | entity.save(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/mapping/fungible-token.ts: -------------------------------------------------------------------------------- 1 | import { 2 | near, 3 | BigInt, 4 | log, 5 | JSONValue, 6 | TypedMap, 7 | BigDecimal, 8 | } from '@graphprotocol/graph-ts'; 9 | import { FtTransfer } from '../../generated/schema'; 10 | import { getOrInitUser, getOrInitStatus } from '../helper/initializer'; 11 | import { updatePrice } from '../helper/price'; 12 | 13 | export function handleFtTransfer( 14 | data: TypedMap, 15 | receipt: near.ReceiptWithOutcome 16 | ): void { 17 | const timestamp = receipt.block.header.timestampNanosec; 18 | const receiptHash = receipt.receipt.id.toBase58(); 19 | 20 | // parse event 21 | const oldOwnerId = data.get('old_owner_id')!.toString(); 22 | const newOwnerId = data.get('new_owner_id')!.toString(); 23 | const amount = BigInt.fromString(data.get('amount')!.toString()); 24 | const amountFloat = BigDecimal.fromString(data.get('amount')!.toString()); 25 | 26 | // update event 27 | let transferedEvent = FtTransfer.load(receiptHash); 28 | if (!transferedEvent) { 29 | const status = getOrInitStatus(); 30 | const latestPrice = status.price; 31 | 32 | transferedEvent = new FtTransfer(receiptHash); 33 | transferedEvent.to = newOwnerId; 34 | transferedEvent.from = oldOwnerId; 35 | transferedEvent.amount = amount; 36 | transferedEvent.timestamp = timestamp.toString(); 37 | transferedEvent.price = latestPrice; 38 | transferedEvent.save(); 39 | 40 | // update from user 41 | let fromUser = getOrInitUser(oldOwnerId); 42 | fromUser.transferedOutValue = amountFloat 43 | .times(latestPrice) 44 | .plus(fromUser.transferedOutValue); 45 | fromUser.transferedOutShares = amount.plus(fromUser.transferedOutShares); 46 | fromUser.save(); 47 | 48 | // update to user 49 | let toUser = getOrInitUser(newOwnerId); 50 | toUser.transferedInValue = amountFloat 51 | .times(latestPrice) 52 | .plus(toUser.transferedInValue); 53 | toUser.transferedInShares = amount.plus(toUser.transferedInShares); 54 | toUser.save(); 55 | } else { 56 | log.error('Internal Error: {}', ['FtTransfer Event']); 57 | } 58 | } 59 | 60 | export function handleFtBurn( 61 | method: string, 62 | event: string, 63 | data: TypedMap, 64 | receipt: near.ReceiptWithOutcome 65 | ): void { 66 | const amount = BigDecimal.fromString(data.get('amount')!.toString()); 67 | updatePrice(event, method, receipt, BigDecimal.zero(), amount.neg()); 68 | } 69 | 70 | export function handleFtMint( 71 | method: string, 72 | event: string, 73 | data: TypedMap, 74 | receipt: near.ReceiptWithOutcome 75 | ): void { 76 | const amount = BigDecimal.fromString(data.get('amount')!.toString()); 77 | updatePrice(event, method, receipt, BigDecimal.zero(), amount); 78 | } 79 | -------------------------------------------------------------------------------- /src/mapping/stake.ts: -------------------------------------------------------------------------------- 1 | import { 2 | near, 3 | BigInt, 4 | JSONValue, 5 | TypedMap, 6 | BigDecimal, 7 | } from '@graphprotocol/graph-ts'; 8 | import { getOrInitUser } from '../helper/initializer'; 9 | import { updatePrice } from '../helper/price'; 10 | import { addStakeAmountChange } from '../helper/stake'; 11 | 12 | export function handleStake( 13 | method: string, 14 | event: string, 15 | data: TypedMap, 16 | receipt: near.ReceiptWithOutcome 17 | ): void { 18 | const timestamp = receipt.block.header.timestampNanosec; 19 | 20 | // parse event 21 | const accountId = data.get('account_id')!.toString(); 22 | const stakeAmountStr = data.get('staked_amount')!.toString(); 23 | const mintedSharesStr = data.get('minted_stake_shares')!.toString(); 24 | const stakeAmount = BigInt.fromString(stakeAmountStr); 25 | const mintedShares = BigInt.fromString(mintedSharesStr); 26 | const mintedSharesFloat = BigDecimal.fromString(mintedSharesStr); 27 | const stakeAmountFloat = BigDecimal.fromString(stakeAmountStr); 28 | 29 | // update user 30 | let user = getOrInitUser(accountId); 31 | user.stakedNear = user.stakedNear.plus(stakeAmount); 32 | user.mintedLinear = user.mintedLinear.plus(mintedShares); 33 | if (user.firstStakingTime.isZero()) { 34 | user.firstStakingTime = BigInt.fromU64(timestamp); 35 | } 36 | user.save(); 37 | 38 | // update price 39 | updatePrice(event, method, receipt, stakeAmountFloat, mintedSharesFloat); 40 | 41 | // record stake amount change 42 | addStakeAmountChange( 43 | receipt.receipt.id.toBase58(), 44 | accountId, 45 | BigInt.fromU64(timestamp), 46 | stakeAmount 47 | ); 48 | } 49 | 50 | export function handleUnstake( 51 | method: string, 52 | event: string, 53 | data: TypedMap, 54 | receipt: near.ReceiptWithOutcome 55 | ): void { 56 | // parse event 57 | const accountId = data.get('account_id')!.toString(); 58 | const unstakeAmountStr = data.get('unstaked_amount')!.toString(); 59 | const burnedSharesStr = data.get('burnt_stake_shares')!.toString(); 60 | const unstakeAmount = BigInt.fromString(unstakeAmountStr); 61 | const burnedShares = BigInt.fromString(burnedSharesStr); 62 | const burnedSharesFloat = BigDecimal.fromString(burnedSharesStr); 63 | const unstakeSharesFloat = BigDecimal.fromString(unstakeAmountStr); 64 | 65 | // update user 66 | let user = getOrInitUser(accountId); 67 | user.unstakeReceivedNear = user.unstakeReceivedNear.plus(unstakeAmount); 68 | user.unstakedLinear = user.unstakedLinear.plus(burnedShares); 69 | user.save(); 70 | 71 | // update price 72 | updatePrice( 73 | event, 74 | method, 75 | receipt, 76 | unstakeSharesFloat.neg(), 77 | burnedSharesFloat.neg() 78 | ); 79 | 80 | // record stake amount change 81 | addStakeAmountChange( 82 | receipt.receipt.id.toBase58(), 83 | accountId, 84 | BigInt.fromU64(receipt.block.header.timestampNanosec), 85 | unstakeAmount.neg() 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/mapping/index.ts: -------------------------------------------------------------------------------- 1 | import { near, json, JSONValue, TypedMap } from '@graphprotocol/graph-ts'; 2 | import { handleStake, handleUnstake } from './stake'; 3 | import { handleFtTransfer, handleFtBurn, handleFtMint } from './fungible-token'; 4 | import { handleEpochUpdateRewards, handleEpochUnstakeSuccess, handleEpochCleanup } from './epoch-action'; 5 | import { 6 | handleInstantUnstake, 7 | handleLiquidityPoolSwapFee, 8 | handleRebalanceLiquidity, 9 | } from './liquidity-pool'; 10 | 11 | function handleEvent( 12 | method: string, 13 | event: string, 14 | data: TypedMap, 15 | receipt: near.ReceiptWithOutcome 16 | ): void { 17 | if ( 18 | method == 'stake' || 19 | method == 'deposit_and_stake' || 20 | method == 'stake_all' 21 | ) { 22 | if (event == 'stake') { 23 | handleStake(method, event, data, receipt); 24 | } else if (event == 'rebalance_liquidity') { 25 | handleRebalanceLiquidity(method, event, data, receipt); 26 | } 27 | } else if ( 28 | (method == 'unstake' || method == 'unstake_all') && 29 | event == 'unstake' 30 | ) { 31 | handleUnstake(method, event, data, receipt); 32 | } else if (method == 'instant_unstake' && event == 'instant_unstake') { 33 | handleInstantUnstake(data); 34 | } else if ( 35 | (method == 'ft_transfer' || 36 | method == 'ft_transfer_call' || 37 | // there may be received $LiNEAR when removing liquidity 38 | method == 'remove_liquidity') && 39 | event == 'ft_transfer' 40 | ) { 41 | handleFtTransfer(data, receipt); 42 | } else if ( 43 | method == 'instant_unstake' && 44 | event == 'liquidity_pool_swap_fee' 45 | ) { 46 | handleLiquidityPoolSwapFee(data, receipt); 47 | } else if (method == 'storage_unregister' && event == 'ft_burn') { 48 | handleFtBurn(method, event, data, receipt); 49 | } else if ( 50 | method == 'epoch_update_rewards' || 51 | method == 'validator_get_balance_callback' 52 | ) { 53 | if (event == 'epoch_update_rewards') { 54 | handleEpochUpdateRewards(method, event, data, receipt); 55 | } else if (event == 'ft_mint') { 56 | handleFtMint(method, event, data, receipt); 57 | } 58 | } else if (method == 'validator_unstaked_callback') { 59 | if (event == 'epoch_unstake_success') { 60 | handleEpochUnstakeSuccess(data, receipt); 61 | } 62 | } else if (event == 'epoch_cleanup') { 63 | handleEpochCleanup(data, receipt); 64 | } 65 | } 66 | 67 | function handleAction( 68 | action: near.ActionValue, 69 | receipt: near.ReceiptWithOutcome 70 | ): void { 71 | if (action.kind != near.ActionKind.FUNCTION_CALL) { 72 | return; 73 | } 74 | const outcome = receipt.outcome; 75 | const methodName = action.toFunctionCall().methodName; 76 | 77 | for (let logIndex = 0; logIndex < outcome.logs.length; logIndex++) { 78 | let outcomeLog = outcome.logs[logIndex].toString(); 79 | if (outcomeLog.startsWith('EVENT_JSON:')) { 80 | outcomeLog = outcomeLog.replace('EVENT_JSON:', ''); 81 | const jsonData = json.try_fromString(outcomeLog); 82 | const jsonObject = jsonData.value.toObject(); 83 | const event = jsonObject.get('event')!; 84 | const dataArr = jsonObject.get('data')!.toArray(); 85 | const dataObj: TypedMap = dataArr[0].toObject(); 86 | 87 | handleEvent(methodName, event.toString(), dataObj, receipt); 88 | } 89 | } 90 | } 91 | 92 | export function handleReceipt(receipt: near.ReceiptWithOutcome): void { 93 | const actions = receipt.receipt.actions; 94 | for (let i = 0; i < actions.length; i++) { 95 | handleAction(actions[i], receipt); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /test/apy.js: -------------------------------------------------------------------------------- 1 | const { BigNumber } = require('bignumber.js') 2 | const { client, getSummaryFromContract } = require("./helper"); 3 | const { queryLatestPriceFromSubgraph, queryPriceBefore } = require("./price"); 4 | 5 | async function getLatestFeesPaid() { 6 | const getLatestQuery = ` 7 | query { 8 | totalSwapFees (first: 1, orderBy: timestamp, orderDirection: desc){ 9 | id 10 | timestamp 11 | feesPaid 12 | } 13 | } 14 | ` 15 | let data = await client.query(getLatestQuery).toPromise() 16 | let queryData = data.data 17 | if (queryData == null) { 18 | throw new Error("fail to query latest totalSwapFees") 19 | } 20 | // console.log("current fees: ", queryData.totalSwapFees[0].feesPaid.toString()) 21 | return queryData.totalSwapFees[0] 22 | } 23 | 24 | async function getTargetTimeFeesPaid(timestamp) { 25 | // 26 | const getBeforeFeesPayed = ` 27 | query { 28 | totalSwapFees (first: 1, where: {timestamp_gt: "${timestamp}"} ){ 29 | id 30 | feesPaid 31 | timestamp 32 | } 33 | }` 34 | // console.log('query', getBeforeFeesPayed); 35 | //console.log(getBeforeFeesPayed) 36 | let data = await client.query(getBeforeFeesPayed).toPromise() 37 | let queryData = data.data 38 | if (queryData == null) { 39 | throw new Error("fail to query before totalSwapFees") 40 | } 41 | //console.log(queryData) 42 | // console.log("init fees: ", queryData.totalSwapFees[0].feesPaid) 43 | return queryData.totalSwapFees[0] 44 | } 45 | 46 | async function calcLpApy() { 47 | let response = await getSummaryFromContract(); 48 | const tmpLinearShares = new BigNumber(response.lp_staked_share) 49 | const tmpNEARShares = new BigNumber(response.lp_near_amount) 50 | const tmpPrice = new BigNumber(response.ft_price).div(1000000000000000000000000) 51 | const tmpLpTVL = tmpLinearShares.times(tmpPrice).plus(tmpNEARShares) 52 | // console.log("tmpLpTVL", tmpLpTVL.toString()) 53 | const tmpFeesPaid = await getLatestFeesPaid() 54 | const targetTimeForFees = tmpFeesPaid.timestamp - 3 * 24 * 60 * 60 * 1000000000 55 | const initFeesPayed = await getTargetTimeFeesPaid(targetTimeForFees) 56 | const secsCurrent = new BigNumber(tmpFeesPaid.timestamp) 57 | const secsInit = new BigNumber(initFeesPayed.timestamp) 58 | const days = 3; // secsCurrent.minus(secsInit).div(24).div(60*60).div(1000000000) 59 | // console.log("days", days.toString()) 60 | const feesCurrent = new BigNumber(tmpFeesPaid.feesPaid) 61 | const feesInit = new BigNumber(initFeesPayed.feesPaid) 62 | // console.log("feesCurrent,feesInit", feesCurrent.toString(), feesInit.toString()) 63 | const lpApy = feesCurrent.minus(feesInit).div(days).times(365).times(tmpPrice).div(tmpLpTVL) 64 | console.log("Liquidity Pool APY:", lpApy.toFixed(4)); 65 | } 66 | 67 | async function calcStakePoolApy() { 68 | const latesdPrice = await queryLatestPriceFromSubgraph() 69 | const targetTime = Number(latesdPrice.timestamp) - 30 * 24 * 60 * 60 * 1000000000 70 | const price30DaysAgo = await queryPriceBefore(targetTime) 71 | const price1 = new BigNumber(latesdPrice.price) 72 | const price2 = new BigNumber(price30DaysAgo.price) 73 | // console.log(latesdPrice, price30DaysAgo) 74 | const days = new BigNumber(24 * 60 * 60 * 1000000000 * 30) 75 | const timeGap = new BigNumber(Number(latesdPrice.timestamp - price30DaysAgo.timestamp)) 76 | const times1 = new BigNumber(24 * 60 * 60 * 1000000000 * 365) 77 | // console.log('prices', 78 | // price1.toString(), 79 | // price2.toString(), 80 | // new Date(latesdPrice.timestamp / 1000000), 81 | // new Date(price30DaysAgo.timestamp / 1000000), 82 | // ); 83 | const apy = price1.minus(price2).div(price2).times(times1).div(days) 84 | console.log("Staking APY:", apy.toFixed(4)) 85 | } 86 | 87 | async function test() { 88 | await calcStakePoolApy(); 89 | await calcLpApy(); 90 | } 91 | 92 | test(); 93 | -------------------------------------------------------------------------------- /test/stake.js: -------------------------------------------------------------------------------- 1 | const { BigNumber } = require('bignumber.js'); 2 | const { client, loadContract } = require('./helper'); 3 | const { queryLatestPriceFromSubgraph } = require('./price'); 4 | 5 | async function queryStakeTime(accountid) { 6 | const getStakeTimeQuery = ` 7 | query { 8 | users (first: 1, where: {id: "${accountid}"} ){ 9 | id 10 | firstStakingTime 11 | } 12 | }`; 13 | // console.log(getStakeTimeQuery) 14 | let data = await client.query(getStakeTimeQuery).toPromise(); 15 | let queryData = data.data; 16 | if (queryData == null) { 17 | throw new Error('fail to query price'); 18 | } 19 | const timestampInt = Number(queryData.users[0].firstStakingTime.toString()); 20 | const unixTimestamp = timestampInt / 1000000; 21 | const date = new Date(unixTimestamp); 22 | console.log('user first stake time: ', date); 23 | return queryData.users[0]; 24 | } 25 | 26 | async function getTransferIncome(accountID) { 27 | const getTransferEvent = ` 28 | query { 29 | users(first: 1,where:{id:"${accountID}"}) { 30 | id 31 | transferedInShares 32 | transferedInValue 33 | transferedOutShares 34 | transferedOutValue 35 | } 36 | }`; 37 | // console.log(getTransferEvent) 38 | let data = await client.query(getTransferEvent).toPromise(); 39 | let queryData = data.data; 40 | //console.log(queryData.users[0]) 41 | if (queryData == null) { 42 | throw new Error('Fail to query transfer event'); 43 | } 44 | const latestPrice = await queryLatestPriceFromSubgraph(); 45 | //console.log(latestPrice.price) 46 | const transferInShares = queryData.users[0].transferedInShares; 47 | const tranfserInValue = queryData.users[0].transferedInValue; 48 | const transferOutShares = queryData.users[0].transferedOutShares; 49 | const tranfserOutValue = queryData.users[0].transferedOutValue; 50 | //console.log(transferInShares,tranfserInValue) 51 | let transferInReward = latestPrice.price * transferInShares - tranfserInValue; 52 | let transferOutReward = 53 | latestPrice.price * transferOutShares - tranfserOutValue; 54 | 55 | // console.log("transfer reward: ",transferInReward - transferOutReward) 56 | return transferInReward - transferOutReward; 57 | } 58 | 59 | async function getUserIncome(accountId, flag) { 60 | const getIncomeQuery = ` 61 | query { 62 | users (first: 1, where: {id: "${accountId}"} ){ 63 | id 64 | mintedLinear 65 | stakedNear 66 | unstakedLinear 67 | unstakeReceivedNear 68 | feesPaid 69 | } 70 | }`; 71 | // console.log(getIncomeQuery) 72 | let data = await client.query(getIncomeQuery).toPromise(); 73 | //console.log(data) 74 | let queryData = data.data.users[0]; 75 | if (queryData == null) { 76 | throw new Error('fail to query user'); 77 | } 78 | const latestPrice = await queryLatestPriceFromSubgraph(); 79 | const price1 = new BigNumber(latestPrice.price); 80 | const mintedLinear = new BigNumber(queryData.mintedLinear); 81 | const stakedNear = new BigNumber(queryData.stakedNear); 82 | const unstakedLinear = new BigNumber(queryData.unstakedLinear); 83 | const unstakedGetNEAR = new BigNumber(queryData.unstakeReceivedNear); 84 | const fessPaid = new BigNumber(queryData.feesPaid); 85 | const currentLinear = mintedLinear.minus(unstakedLinear); 86 | const transferReward = await getTransferIncome(accountId); 87 | const tfReward = new BigNumber(transferReward); 88 | const reward = currentLinear 89 | .times(price1) 90 | .integerValue() 91 | .minus(stakedNear) 92 | .plus(unstakedGetNEAR) 93 | .plus(tfReward); 94 | // console.log("calc [subgraph]", 95 | // mintedLinear.toString(), 96 | // unstakedLinear.toString(), 97 | // price1.toString(), 98 | // stakedNear.toString(), 99 | // unstakedGetNEAR.toString() 100 | // ); 101 | 102 | if (flag) { 103 | const rewardFinal = reward.plus(fessPaid); 104 | console.log( 105 | 'rewards [subgraph with fee] =\t\t %s NEAR', 106 | rewardFinal.div(10 ** 24).toFixed(8) 107 | ); 108 | return rewardFinal; 109 | } else { 110 | console.log( 111 | 'rewards [subgraph without fee] =\t %s NEAR', 112 | reward.div(10 ** 24).toFixed(8) 113 | ); 114 | return reward; 115 | } 116 | } 117 | 118 | async function getDeposits(accountId) { 119 | const result = await fetch( 120 | `https://api.linearprotocol.org/deposits/${accountId}` 121 | ); 122 | const json = await result.json(); 123 | return json.deposits; 124 | } 125 | 126 | async function getStakingReward(accountId) { 127 | const contract = await loadContract(); 128 | const [liNearPrice, account, linearBalance, deposits] = await Promise.all([ 129 | contract.ft_price(), 130 | contract.get_account({ account_id: accountId }), 131 | contract.ft_balance_of({ account_id: accountId }), 132 | getDeposits(accountId), 133 | ]); 134 | 135 | const near_reward = BigNumber(linearBalance) 136 | .minus(deposits.linear) 137 | .times(liNearPrice) 138 | .div(10 ** 24) 139 | .plus(account.unstaked_balance || 0) 140 | .minus(deposits.near); 141 | 142 | // console.log("calc [indexer]", 143 | // linearBalance.toString(), 144 | // deposits.linear.toString(), 145 | // liNearPrice.toString(), 146 | // account.unstaked_balance?.toString(), 147 | // deposits.near.toString(), 148 | // ); 149 | 150 | console.log( 151 | 'rewards [indexer] =\t\t\t %s NEAR', 152 | near_reward.div(10 ** 24).toFixed(8) 153 | ); 154 | return near_reward; 155 | } 156 | 157 | async function stakingRewardsDiff(accountId) { 158 | const [ 159 | rewards_subgraph_with_fee, 160 | // rewards_subgraph, 161 | rewards_indexer, 162 | ] = await Promise.all([ 163 | getUserIncome(accountId, true), 164 | // getUserIncome(accountId, false), 165 | getStakingReward(accountId), 166 | ]); 167 | // console.log("diff [subgraph - indexer] =\t\t %s NEAR", rewards_subgraph.minus(rewards_indexer).div(10 ** 24).toFixed(8)); 168 | console.log( 169 | 'diff [subgraph with fee - indexer] =\t %s NEAR', 170 | rewards_subgraph_with_fee 171 | .minus(rewards_indexer) 172 | .div(10 ** 24) 173 | .toFixed(8) 174 | ); 175 | } 176 | 177 | async function test() { 178 | const accountIds = [ 179 | 'cookiemonster.near', 180 | 'retitre.near', 181 | 'calmincome1.near', 182 | 'linguists.near', 183 | ]; 184 | for (const accountId of accountIds) { 185 | console.log('\nstaking rewards: account: ', accountId); 186 | await queryStakeTime(accountId); 187 | await stakingRewardsDiff(accountId); 188 | } 189 | } 190 | 191 | test(); 192 | -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # Building Production-Ready Subgraphs on NEAR 2 | 3 | If you're building your DApps on [NEAR](https://near.org/) and are interested to adopt [The Graph](https://thegraph.com/) technology to empower the frontend and analytics of your project, this tutorial is exactly for you. 4 | 5 | Let's get started!!! :rocket: 6 | 7 | * [Background](#background) 8 | * [1. Introduction to Events on NEAR](#1-introduction-to-events-on-near) 9 | + [NEP-297 Event Standard](#nep-297-event-standard) 10 | + [Event Standards for FT and NFT](#event-standards-for-ft-and-nft) 11 | * [2. Implement Events in NEAR Smart Contracts](#2-implement-events-in-near-smart-contracts) 12 | + [Events in NEAR Standard Contracts](#events-in-near-standard-contracts) 13 | + [Define Events for Your Contract](#define-events-for-your-contract) 14 | + [Emit Events in Your Contract](#emit-events-in-your-contract) 15 | + [Now the Events Data are Ready for Indexing](#now-the-events-data-are-ready-for-indexing) 16 | * [3. Create Subgraphs with The Graph](#3-create-subgraphs-with-the-graph) 17 | + [Set your Objectives](#set-your-objectives) 18 | + [Create Manifest (`subgraph.yaml`)](#create-manifest---subgraphyaml--) 19 | + [Design Schema (`schema.graphql`)](#design-schema---schemagraphql--) 20 | + [Handle Events with AssemblyScript Mappings](#handle-events-with-assemblyscript-mappings) 21 | + [Deploy the Subgraph](#deploy-the-subgraph) 22 | * [4. Querying Subgraphs](#4-querying-subgraphs) 23 | + [Query with Playground](#query-with-playground) 24 | + [Query with GraphQL Client in Code](#query-with-graphql-client-in-code) 25 | * [It's time to BUIDL now!!!](#its-time-to-buidl-now) 26 | * [About](#about) 27 | + [About LiNEAR](#about-linear) 28 | + [About The Graph](#about-the-graph) 29 | 30 | ## Background 31 | 32 | As the [first non-EVM blockchain](https://thegraph.com/blog/graph-near) supported by The Graph, NEAR blockchain allows developers to index data from events, actions, receipts and logs in NEAR smart contracts, and make best use of the data in their applications and analytics using The Graph. NEAR and The Graph integration utilizes StreamingFast’s Firehose interface which is a fast and scalable solution for layer one blockchain indexing. 33 | 34 | There're already some good tutorials about building subgraphs on NEAR, such as [Building an NFT API on NEAR with The Graph](https://github.com/dabit3/near-subgraph-workshop) by Nader, but no one has clearly described how to define and emit events in NEAR smart contracts, and how to process the events in The Graph properly in details. 35 | 36 | [LiNEAR](https://linearprotocol.org/), a liquid staking protocol built on NEAR, is the first top tier projects on NEAR that has integrated The Graph in production and does benefit from the flexibility and power of subgraphs to improve statistics and analytics for its users and operations. 37 | 38 | The integration makes it possible for the important metrics on LiNEAR, such as staking APY, liquidity pool APY, and users' staking rewards, to be queried from The Graph based on [NEAR Event Standard](https://nomicon.io/Standards/EventsFormat), which replaces our previous less flexible and inefficient solution based on NEAR Indexer. 39 | 40 | Here we'd like to share how LiNEAR has used events and subgraphs in the protocol, and hope that helps more developers to learn and build great projects with NEAR and The Graph. 41 | 42 | In this tutorial, you'll learn about: 43 | 44 | 1. Introduction to Events on NEAR 45 | 2. Implement Events in Smart Contracts 46 | 3. Create Subgraphs with The Graph 47 | 4. Querying Subgraphs 48 | 49 | 50 | ![](https://i.imgur.com/gitBMeB.png) 51 | 52 | 53 | ## 1. Introduction to Events on NEAR 54 | 55 | If you're familiar with the practices of building with The Graph in Ethereum, it's a common practice to handle the events from smart contracts using subgraphs. We're following the same practice on NEAR. 56 | 57 | ### NEP-297 Event Standard 58 | 59 | Here we'll first introduce the [Event Standard NEP-297](https://nomicon.io/Standards/EventsFormat) of NEAR. 60 | 61 | The event format NEP-297 is a standard interface for tracking contract activity, using the standard logs capability of NEAR. Events are log entries that start with the `EVENT_JSON:` prefix followed by a single valid JSON string, which has the following interface: 62 | 63 | ```typescript 64 | // Interface to capture data about an event 65 | // 66 | // Arguments 67 | // * `standard`: name of standard, e.g. nep171 68 | // * `version`: e.g. 1.0.0 69 | // * `event`: type of the event, e.g. nft_mint 70 | // * `data`: associate event data. Strictly typed for each set { 71 | // standard, version, event} inside corresponding NEP 72 | interface EventLogData { 73 | standard: string, 74 | version: string, 75 | event: string, 76 | data?: unknown, 77 | } 78 | ``` 79 | 80 | In the event object, the `standard`, `version` and `event` fields are required, and the `data` field is optional. 81 | 82 | 1. The `standard` field represents the standard the event follows, such as `nep141` for fungible token, and `nep171` for non-fungible token, or your application specific standard, such as `linear`. 83 | 2. The `version` field is the current version of your event definition. If you've modified the data schema of some events, it's recommended to update the version so the subgraph could process events accordingly. 84 | 3. The `event` field is the event name, e.g. `ft_transfer`, `ft_mint`, `ft_burn` for fungible tokens, usually in snake case. 85 | 4. The `data` field includes the details of the event data. Take fungile token for example, if the event is `ft_transfer`, the data could be `[{"old_owner_id":"alice","new_owner_id":"bob","amount":"1000000000000000000"}]`, which means Alice has transferred 1 token (with 18 decimals) to Bob. 86 | 87 | ### Event Standards for FT and NFT 88 | 89 | The Fungible Token (NEP-141) and Non-Fungible Token (NEP-171) standards have defined their own standard interfaces for NEP-297 event format. 90 | 91 | For example, a FT transfer event may look as below, when Alice transfers tokens to both Bob and Charlie in a batch. 92 | 93 | ```json 94 | EVENT_JSON:{ 95 | "standard": "nep141", 96 | "version": "1.0.0", 97 | "event": "ft_transfer", 98 | "data": [ 99 | { 100 | "old_owner_id": "alice.near", 101 | "new_owner_id": "bob.near", 102 | "amount": "250", 103 | "memo": "tip" 104 | }, 105 | { 106 | "old_owner_id": "alice.near", 107 | "new_owner_id": "charlie.near", 108 | "amount": "750" 109 | } 110 | ] 111 | } 112 | ``` 113 | 114 | An NFT mint event example is as below, when two NFTs are minted for Dave. 115 | 116 | ```json 117 | EVENT_JSON:{ 118 | "standard": "nep171", 119 | "version": "1.0.0", 120 | "event": "nft_mint", 121 | "data": [ 122 | { 123 | "owner_id": "dave.near", 124 | "token_ids": [ 125 | "superman", 126 | "batman" 127 | ] 128 | } 129 | ] 130 | } 131 | ``` 132 | 133 | For more details about the FT and NFT standard events, please check out the [FT Events](https://nomicon.io/Standards/Tokens/FungibleToken/Event) and [NFT Events](https://nomicon.io/Standards/Tokens/NonFungibleToken/Event) docs. 134 | 135 | You're also allowed to define your own events which we'll talk about next. 136 | 137 | ## 2. Implement Events in NEAR Smart Contracts 138 | 139 | Now let's implement events in your NEAR smart contract. In this tutorial, we're building the smart contracts in Rust using [NEAR Rust SDK](https://github.com/near/near-sdk-rs). 140 | 141 | ### Events in NEAR Standard Contracts 142 | 143 | If you have built your contracts based on [`near-contract-standards`](https://github.com/near/near-sdk-rs/tree/master/near-contract-standards) crate, such as [fungible token](https://examples.near.org/FT) and [non-fungible token](https://examples.near.org/NFT), you already have the built-in events implementation in your contract. So you can use that in subgraphs directly. 144 | 145 | The implementation of fungible token events (`FtMint`, `FtBurn`, `FtTransfer`) can be found [here](https://github.com/near/near-sdk-rs/blob/master/near-contract-standards/src/fungible_token/events.rs). Take `FtTransfer` for example, the event data schema and `emit` methods need to be implemented. 146 | 147 | ```rust 148 | /// Data to log for an FT transfer event. To log this event, 149 | /// call [`.emit()`](FtTransfer::emit). 150 | #[must_use] 151 | #[derive(Serialize, Debug, Clone)] 152 | pub struct FtTransfer<'a> { 153 | pub old_owner_id: &'a AccountId, 154 | pub new_owner_id: &'a AccountId, 155 | pub amount: &'a U128, 156 | #[serde(skip_serializing_if = "Option::is_none")] 157 | pub memo: Option<&'a str>, 158 | } 159 | 160 | impl FtTransfer<'_> { 161 | /// Logs the event to the host. This is required to ensure that the event is triggered 162 | /// and to consume the event. 163 | pub fn emit(self) { 164 | Self::emit_many(&[self]) 165 | } 166 | 167 | /// Emits an FT transfer event, through [`env::log_str`](near_sdk::env::log_str), 168 | /// where each [`FtTransfer`] represents the data of each transfer. 169 | pub fn emit_many(data: &[FtTransfer<'_>]) { 170 | new_141_v1(Nep141EventKind::FtTransfer(data)).emit() 171 | } 172 | } 173 | ``` 174 | 175 | The defined `FtTransfer` event is emitted in [`internal_transfer`](https://github.com/near/near-sdk-rs/blob/8a2b2e19b27a764abf43df05bd0e530c3ad91d6c/near-contract-standards/src/fungible_token/core_impl.rs#L91-L109). 176 | 177 | ```rust 178 | pub fn internal_transfer( 179 | &mut self, 180 | sender_id: &AccountId, 181 | receiver_id: &AccountId, 182 | amount: Balance, 183 | memo: Option, 184 | ) { 185 | require!(sender_id != receiver_id, "Sender and receiver should be different"); 186 | require!(amount > 0, "The amount should be a positive number"); 187 | self.internal_withdraw(sender_id, amount); 188 | self.internal_deposit(receiver_id, amount); 189 | FtTransfer { 190 | old_owner_id: sender_id, 191 | new_owner_id: receiver_id, 192 | amount: &U128(amount), 193 | memo: memo.as_deref(), 194 | } 195 | .emit(); 196 | } 197 | ``` 198 | 199 | 200 | ### Define Events for Your Contract 201 | 202 | It's quite common define you own events in your contract. 203 | 204 | Here we'll implement the events in LiNEAR as an example. LiNEAR is an liquid staking protocol that you could stake $NEAR and receive liquid $LiNEAR tokens while still earning staking rewards. We will create events for all the main activities. If you're not familiar with LiNEAR's features such as `Stake` and `Unstake`, we recommend that you spend 1 minute to [have a try](https://app.linearprotocol.org/). 205 | 206 | We'll define the events for LiNEAR under [`events.rs`](https://github.com/linear-protocol/LiNEAR/blob/main/contracts/linear/src/events.rs) in the contract project. 207 | 208 | (1) First, we can define the `standard` and `version` in EVENT_JSON as constants. 209 | 210 | ```rust 211 | const EVENT_STANDARD: &str = "linear"; 212 | const EVENT_STANDARD_VERSION: &str = "1.0.0"; 213 | ``` 214 | 215 | (2) We'll define `enum Event` with all the event data schemas as enums. 216 | 217 | For example, the user operations such as `deposit`, `withdraw`, `stake` and `unstake` will emit events with the necessary data. The name of the event (e.g. `Deposit`) will be turned into `event` field in EVENT_JSON, and the content of the enum (`account_id`, `amount` and `new_unstaked_balance`) will be transformed into `data` field in EVENT_JSON. 218 | 219 | ```rust 220 | #[derive(Serialize, Debug, Clone)] 221 | #[serde(crate = "near_sdk::serde")] 222 | #[serde(tag = "event", content = "data")] 223 | #[serde(rename_all = "snake_case")] 224 | pub enum Event<'a> { 225 | // ... 226 | // Staking Pool Interface 227 | Deposit { 228 | account_id: &'a AccountId, 229 | amount: &'a U128, 230 | new_unstaked_balance: &'a U128, 231 | }, 232 | Withdraw { 233 | account_id: &'a AccountId, 234 | amount: &'a U128, 235 | new_unstaked_balance: &'a U128, 236 | }, 237 | Stake { 238 | account_id: &'a AccountId, 239 | staked_amount: &'a U128, 240 | minted_stake_shares: &'a U128, 241 | new_unstaked_balance: &'a U128, 242 | new_stake_shares: &'a U128, 243 | }, 244 | Unstake { 245 | account_id: &'a AccountId, 246 | unstaked_amount: &'a U128, 247 | burnt_stake_shares: &'a U128, 248 | new_unstaked_balance: &'a U128, 249 | new_stake_shares: &'a U128, 250 | unstaked_available_epoch_height: u64, 251 | }, 252 | // ... 253 | ``` 254 | 255 | Events in LiNEAR have various types: user operation events, epoch actions events that can be triggered every epoch by anyone, and validator management events that are emitted when validators are added/removed in the pool. 256 | 257 | (3) Add `emit()` method for your events, which will serialize your event data and log event JSON following NEP-297 standard. 258 | 259 | ```rust 260 | impl Event<'_> { 261 | pub fn emit(&self) { 262 | emit_event(&self); 263 | } 264 | } 265 | 266 | // Emit event that follows NEP-297 standard: https://nomicon.io/Standards/EventsFormat 267 | // Arguments 268 | // * `standard`: name of standard, e.g. nep171 269 | // * `version`: e.g. 1.0.0 270 | // * `event`: type of the event, e.g. nft_mint 271 | // * `data`: associate event data. Strictly typed for each set {standard, version, event} inside corresponding NEP 272 | pub(crate) fn emit_event(data: &T) { 273 | let result = json!(data); 274 | let event_json = json!({ 275 | "standard": EVENT_STANDARD, 276 | "version": EVENT_STANDARD_VERSION, 277 | "event": result["event"], 278 | "data": [result["data"]] 279 | }) 280 | .to_string(); 281 | log!(format!("EVENT_JSON:{}", event_json)); 282 | } 283 | ``` 284 | 285 | (4) If your contract contains a bunch of different events, we suggest you create unit tests for your events to make sure the generated EVENT_JSON logs look exactly as you think. Running `Event::Stake{...}.emit()` will output the EVENT JSON log. 286 | 287 | ```rust 288 | #[test] 289 | fn stake() { 290 | let account_id = &alice(); 291 | let staked_amount = &U128(100); 292 | let minted_stake_shares = &U128(99); 293 | let new_unstaked_balance = &U128(10); 294 | let new_stake_shares = &U128(199); 295 | Event::Stake { 296 | account_id, 297 | staked_amount, 298 | minted_stake_shares, 299 | new_unstaked_balance, 300 | new_stake_shares, 301 | } 302 | .emit(); 303 | assert_eq!( 304 | test_utils::get_logs()[0], 305 | r#"EVENT_JSON:{"standard":"linear","version":"1.0.0","event":"stake","data":[{"account_id":"alice","staked_amount":"100","minted_stake_shares":"99","new_unstaked_balance":"10","new_stake_shares":"199"}]}"# 306 | ); 307 | } 308 | ``` 309 | 310 | You can find the complete example of defining events in [`events.rs`](https://github.com/linear-protocol/LiNEAR/blob/main/contracts/linear/src/events.rs). 311 | 312 | ### Emit Events in Your Contract 313 | 314 | Now we have defined events for the contract, let's emit events in the right places. 315 | 316 | We'll illustrate how to emit events for `stake`, `unstake` and `epoch stake` actions in LiNEAR. 317 | 318 | (1) `Stake` event is emitted in [`internal_stake()`](https://github.com/linear-protocol/LiNEAR/blob/2c78f26084bc8e999cea9643c0f7bf3c6aef06f5/contracts/linear/src/internal.rs#L102-L121) which is called by all stake functions. The account ID, balances, staked $NEAR amount, and minted $LiNEAR are recorded in the event. Also, one standard `FtMint` event is emitted since $LiNEAR is minted for the user after staking. 319 | 320 | ```rust 321 | pub(crate) fn internal_stake(&mut self, amount: Balance) { 322 | 323 | // ... 324 | 325 | self.total_staked_near_amount += stake_amount; 326 | self.total_share_amount += num_shares; 327 | 328 | // Increase requested stake amount within the current epoch 329 | self.epoch_requested_stake_amount += stake_amount; 330 | 331 | Event::Stake { 332 | account_id: &account_id, 333 | staked_amount: &U128(charge_amount), 334 | minted_stake_shares: &U128(num_shares), 335 | new_unstaked_balance: &U128(account.unstaked), 336 | new_stake_shares: &U128(account.stake_shares), 337 | } 338 | .emit(); 339 | FtMint { 340 | owner_id: &account_id, 341 | amount: &U128(num_shares), 342 | memo: Some("stake"), 343 | } 344 | .emit(); 345 | 346 | // ... 347 | } 348 | ``` 349 | 350 | 351 | (2) `Unstake` event is emitted in [`internal_unstake()`](https://github.com/linear-protocol/LiNEAR/blob/2c78f26084bc8e999cea9643c0f7bf3c6aef06f5/contracts/linear/src/internal.rs#L180-L200) which is called by all (delayed) unstake functions. The account ID, balances, burnt $LiNEAR, received $NEAR amount, and unstake available epoch height are recorded in the event. Also, one standard `FtBurn` event is emitted since $LiNEAR is burnt when the user is unstaking. 352 | 353 | ```rust 354 | pub(crate) fn internal_unstake(&mut self, amount: u128) { 355 | 356 | // ... 357 | 358 | self.total_staked_near_amount -= unstake_amount; 359 | self.total_share_amount -= num_shares; 360 | 361 | // Increase requested unstake amount within the current epoch 362 | self.epoch_requested_unstake_amount += unstake_amount; 363 | 364 | Event::Unstake { 365 | account_id: &account_id, 366 | unstaked_amount: &U128(receive_amount), 367 | burnt_stake_shares: &U128(num_shares), 368 | new_unstaked_balance: &U128(account.unstaked), 369 | new_stake_shares: &U128(account.stake_shares), 370 | unstaked_available_epoch_height: account.unstaked_available_epoch_height, 371 | } 372 | .emit(); 373 | FtBurn { 374 | owner_id: &account_id, 375 | amount: &U128(num_shares), 376 | memo: Some("unstake"), 377 | } 378 | .emit(); 379 | 380 | // ... 381 | } 382 | ``` 383 | 384 | 385 | (3) One thing needs to pay attention to in NEAR is that, because the cross-contract call is asynchronous in NEAR's sharding design, to make sure the events are emitted in the expected status (e.g. entire transaction has been executed successfully), the events should be emitted in the appropriate functions or callbacks. 386 | 387 | Let's take a look at `EpochStakeAttempt`, `EpochStakeSuccess` and `EpochStakeFailed` as examples. 388 | 389 | The `EpochStakeAttemp` event is emitted whenever the [`epoch_stake` function](https://github.com/linear-protocol/LiNEAR/blob/2c78f26084bc8e999cea9643c0f7bf3c6aef06f5/contracts/linear/src/epoch_actions.rs#L54-L58) is called. 390 | 391 | ```rust 392 | pub fn epoch_stake(&mut self) -> bool { 393 | // ... 394 | 395 | // update internal state 396 | self.stake_amount_to_settle -= amount_to_stake; 397 | 398 | Event::EpochStakeAttempt { 399 | validator_id: &candidate.account_id, 400 | amount: &U128(amount_to_stake), 401 | } 402 | .emit(); 403 | 404 | // ... 405 | } 406 | ``` 407 | 408 | Then we emit the epoch stake result events in the callback function -- [`validator_staked_callback()`](https://github.com/linear-protocol/LiNEAR/blob/2c78f26084bc8e999cea9643c0f7bf3c6aef06f5/contracts/linear/src/epoch_actions.rs#L360-L383). 409 | 410 | The `EpochStakeSuccess` and `EpochStakeFailed` events are emitted only when the `epoch_stake` execution succeeded or failed, but `EpochStakeAttempt` is emitted as long as the `epoch_stake` function is executed. 411 | 412 | ```rust 413 | pub fn validator_staked_callback(&mut self, validator_id: AccountId, amount: Balance) { 414 | if is_promise_success() { 415 | let mut validator = self 416 | .validator_pool 417 | .get_validator(&validator_id) 418 | .unwrap_or_else(|| panic!("{}: {}", ERR_VALIDATOR_NOT_EXIST, &validator_id)); 419 | validator.on_stake_success(&mut self.validator_pool, amount); 420 | 421 | Event::EpochStakeSuccess { 422 | validator_id: &validator_id, 423 | amount: &U128(amount), 424 | } 425 | .emit(); 426 | } else { 427 | // stake failed, revert 428 | self.stake_amount_to_settle += amount; 429 | 430 | Event::EpochStakeFailed { 431 | validator_id: &validator_id, 432 | amount: &U128(amount), 433 | } 434 | .emit(); 435 | } 436 | } 437 | ``` 438 | 439 | ### Now the Events Data are Ready for Indexing 440 | 441 | Using events in contract is quite straightforward. All you need to do is to define the events for the actions and emit them in the corresponding functions. 442 | 443 | With the on-chain events data, you can now process the data through indexing technologies, such as [The Graph](https://thegraph.com), [NEAR Indexer](https://near-indexers.io/docs/projects/near-indexer-framework) and [NEAR Lake](https://near-indexers.io/docs/projects/near-lake-framework). 444 | 445 | We recommend building your data solution with The Graph because it's the most flexible, powerful and cost effective solution for DApps, while NEAR Indexer and NEAR Lake have their own best use cases that we're not going to cover in this tutorial. 446 | 447 | 448 | ## 3. Create Subgraphs with The Graph 449 | 450 | With the event implemented in our contract, now we can develop and deploy subgraphs to capture and handle the events. 451 | 452 | The general steps about how to develop a subgraph on NEAR can be found in [the tutorial by The Graph team](https://thegraph.com/docs/en/supported-networks/near/). We recommend you go through it quickly if you haven't. 453 | 454 | In this tutorial, we'll share the details about how to handle events, and how it works in production in the LiNEAR Protocol. 455 | 456 | As mentioned in the [Building a NEAR Subgraph](https://thegraph.com/docs/en/supported-networks/near/#building-a-near-subgraph) tutorial, there are three aspects of subgraph definition: 457 | 458 | - `subgraph.yaml`: the subgraph manifest, defining the data sources, and how they should be processed. 459 | - `schema.graphql`: a schema file that defines what data is stored for your subgraph, and how to query it via GraphQL. 460 | - AssemblyScript Mappings: [AssemblyScript code](https://thegraph.com/docs/en/developer/assemblyscript-api/) that translates from the event data to the entities defined in your schema. 461 | 462 | Next, we'll talk about all the three aspects with linear-subgraph project as the example: https://github.com/linear-protocol/linear-subgraph 463 | 464 | But before that, let's ensure we understand our objectives before we start. 465 | 466 | ### Set your Objectives 467 | 468 | Before we start developing subgraphs, we should know what kind of info, stats, insights or stories we want to get out of the event data. 469 | 470 | Some of the data could be queried via RPC from the smart contracts, but some statistics and analytics are easier to be queried from subgraph. We will need subgraphs for such cases. 471 | 472 | For LiNEAR, we care about the staking APY, liquidity pool APY, and staking rewards of users, and would like to show the information in the [LiNEAR web UI](https://app.linearprotocol.org/). We also care about analytics of users, validators, liquidity, etc., which could help us to improve the protocol. 473 | 474 | In this tutorial, we'll briefly talk about how to calculate the **staking APY of LiNEAR Protocol**, which is based on the growth of $LiNEAR price in the past 30 days. In order to reach this goal, we need to get the $LiNEAR price at any timestamp with The Graph. 475 | 476 | 477 | ### Create Manifest (`subgraph.yaml`) 478 | 479 | The subgraph manifest (`subgraph.yaml`) contains the below definitions: 480 | 481 | 1. *blockchain*: set data source `kind` to `near` 482 | 2. *network*: `near-mainnet` or `near-testnet` 483 | 3. *source account*: your contract account, e.g. `linear-protocol.near` 484 | 4. *start block*: usually the block when your contract was deployed 485 | 5. *mapping file*: `./src/mapping/index.ts` 486 | 6. *entities*: the entities defined in the schema file 487 | 7. *handler*: the handler function in your mapping file (`handleReceipt`). we use the `receiptHandlers` since the functions and events are processed at receipt level in NEAR 488 | 489 | See below for the manifest of the LiNEAR subgraph. 490 | 491 | ```yaml 492 | specVersion: 0.0.4 493 | description: LiNEAR Protocol subgraph 494 | repository: https://github.com/linear-protocol/linear-subgraph 495 | schema: 496 | file: ./schema.graphql 497 | dataSources: 498 | - kind: near 499 | name: receipts 500 | network: {{network}} 501 | source: 502 | account: "{{contract}}" 503 | startBlock: {{startBlock}} 504 | mapping: 505 | apiVersion: 0.0.5 506 | language: wasm/assemblyscript 507 | file: ./src/mapping/index.ts 508 | entities: 509 | - User 510 | - Price 511 | - TotalSwapFee 512 | - Status 513 | - FtTransfer 514 | receiptHandlers: 515 | - handler: handleReceipt 516 | 517 | ``` 518 | 519 | The placeholders `{{network}}`, `{{contract}}`, `{{startBlock}}` are populated with different configurations for mainnet and testnet, which are defined in `./config/mainnet.json` and `./config/testnet.json`. 520 | 521 | 522 | *./config/mainnet.json* 523 | 524 | ```json 525 | { 526 | "network": "near-mainnet", 527 | "contract": "linear-protocol.near", 528 | "startBlock": 61147683 529 | } 530 | ``` 531 | 532 | 533 | ### Design Schema (`schema.graphql`) 534 | 535 | Schema describes the structure of the resulting subgraph database and the relationships between entities. Please notice that the entities are not necessary to be the same as the events we defined in our smart contracts. 536 | 537 | The recommended way is to define the schema based on your data queries and analytics objectives, and the data models in your application. In the case of LiNEAR, we have defined: 538 | 539 | - **User**: tracks the latest states of each user such as first staking time, transferred amount in total, accumulated minted LiNEAR amount in total, etc. which are not available in contract's states. You probably also need the *User* entity in your application as long as you have users. 540 | - **Price**: the $LiNEAR price at the timestamp when the total staked NEAR or total supply of $LiNEAR changes. At any given timestamp, `$LiNEAR price = total staked NEAR plus its staking rewards / total supply of LiNEAR` 541 | - **TotalSwapFee**: records the total paid swap fees to the liquidity pool at any timestamp when there're new fees paid 542 | - **Status**: records global status such as latest version IDs of prices and total swap fees. 543 | 544 | 545 | The [built-in scalar types](https://thegraph.com/docs/en/developing/creating-a-subgraph/#built-in-scalar-types) in The Graph's GraphQL API are helpful for defining the schema. 546 | 547 | ![](https://i.imgur.com/kc68mnt.png) 548 | 549 | Below lists the schema for **User** and **Price** in LiNEAR. 550 | 551 | ```graphql 552 | type User @entity{ 553 | id: ID! 554 | mintedLinear: BigInt! 555 | unstakedLinear: BigInt! 556 | stakedNear: BigInt! 557 | unstakeReceivedNear: BigInt! 558 | firstStakingTime: BigInt! 559 | transferedInValue: BigDecimal! 560 | transferedOutValue: BigDecimal! 561 | transferedInShares: BigInt! 562 | transferedOutShares: BigInt! 563 | feesPaid: BigInt! 564 | } 565 | 566 | type Price @entity{ 567 | id: ID! 568 | timestamp: BigInt! 569 | method: String! 570 | event: String! 571 | receiptHash: String! 572 | deltaLinearAmount: BigDecimal! 573 | deltaNearAmount: BigDecimal! 574 | totalLinearAmount: BigDecimal! 575 | totalNearAmount: BigDecimal! 576 | price: BigDecimal! 577 | } 578 | ``` 579 | 580 | Now we can run `yarn codegen` in the LiNEAR subgraph project to generate the schema definitions to `./generated/schema.ts` that can be used in the mapping files. 581 | 582 | 583 | ### Handle Events with AssemblyScript Mappings 584 | 585 | In general, The Graph works by traversing all or some of the blocks of the blockchain, and processing the data (e.g. events) in the block with the handlers designed by developers. 586 | 587 | There are currently two types of handlers supported for NEAR subgraphs: 588 | 589 | - block handlers: run on every new block 590 | - receipt handlers: run every time some actions are executed at a specified account 591 | 592 | As mentioned when defining `subgraph.yaml`, we use the receipt handler in LiNEAR. As long as your application is relying on one or several smart contracts, you should probably also use the receipt handler. 593 | 594 | ```yaml 595 | receiptHandlers: 596 | - handler: handleReceipt 597 | ``` 598 | 599 | In the AssemblyScript mapping file `./src/mapping/index.ts`, firstly we process the logs in the current receipt, and extract the event data from the logs, and pass them to `handleEvent`. 600 | 601 | ```typescript 602 | function handleAction( 603 | action: near.ActionValue, 604 | receipt: near.ReceiptWithOutcome 605 | ): void { 606 | if (action.kind != near.ActionKind.FUNCTION_CALL) { 607 | return; 608 | } 609 | const outcome = receipt.outcome; 610 | const methodName = action.toFunctionCall().methodName; 611 | 612 | for (let logIndex = 0; logIndex < outcome.logs.length; logIndex++) { 613 | let outcomeLog = outcome.logs[logIndex].toString(); 614 | if (outcomeLog.startsWith('EVENT_JSON:')) { 615 | outcomeLog = outcomeLog.replace('EVENT_JSON:', ''); 616 | const jsonData = json.try_fromString(outcomeLog); 617 | const jsonObject = jsonData.value.toObject(); 618 | const event = jsonObject.get('event')!; 619 | const dataArr = jsonObject.get('data')!.toArray(); 620 | const dataObj: TypedMap = dataArr[0].toObject(); 621 | 622 | handleEvent(methodName, event.toString(), dataObj, receipt); 623 | } 624 | } 625 | } 626 | 627 | export function handleReceipt(receipt: near.ReceiptWithOutcome): void { 628 | const actions = receipt.receipt.actions; 629 | for (let i = 0; i < actions.length; i++) { 630 | handleAction(actions[i], receipt); 631 | } 632 | } 633 | ``` 634 | 635 | As one of our goals is to calculate the $LiNEAR price, we need to track all the actions that might impact the total staked NEAR plus its rewards, and the total supply of LiNEAR. 636 | 637 | To avoid delving into too many details, here we illustrate how to process the `EpochUpdateRewards` event when the staking rewards are fetched from validators every epoch, which increases the $LiNEAR price. 638 | 639 | By looking at [the contract code](https://github.com/linear-protocol/LiNEAR/blob/2c78f26084bc8e999cea9643c0f7bf3c6aef06f5/contracts/linear/src/epoch_actions.rs#L456-L462), we know `epoch_update_rewards()` function and its callback `validator_get_balance_callback()` will trigger the `EpochUpdateRewards`, we filter the condition by method name and event name, and then call the corresponding event handler `handleEpochUpdateRewards`. (Note: `method == 'epoch_update_rewards'` is actually not needed and can be removed, because the event is only emitted in the callback `validator_get_balance_callback`.) 640 | 641 | ```typescript 642 | function handleEvent( 643 | method: string, 644 | event: string, 645 | data: TypedMap, 646 | receipt: near.ReceiptWithOutcome 647 | ): void { 648 | 649 | // ... 650 | 651 | } else if ( 652 | method == 'epoch_update_rewards' || 653 | method == 'validator_get_balance_callback' 654 | ) { 655 | if (event == 'epoch_update_rewards') { 656 | handleEpochUpdateRewards(method, event, data, receipt); 657 | } else if (event == 'ft_mint') { 658 | handleFtMint(method, event, data, receipt); 659 | } 660 | } 661 | 662 | // ... 663 | 664 | } 665 | ``` 666 | 667 | In `./src/mapping/epoch-action.ts`, we have implemented the event handler for `EpochUpdateRewards`, which will update the $LiNEAR price based on the received staking rewards. 668 | 669 | ```typescript 670 | export function handleEpochUpdateRewards( 671 | method: string, 672 | event: string, 673 | data: TypedMap, 674 | receipt: near.ReceiptWithOutcome 675 | ): void { 676 | const rewards = BigDecimal.fromString(data.get('rewards')!.toString()); 677 | updatePrice(event, method, receipt, rewards, BigDecimal.zero()); 678 | } 679 | ``` 680 | 681 | It's also necessary to handle `FtMint` when updating staking rewards because around `1%` commission fee is charged and sent to the treasury in the form of $LiNEAR. 682 | 683 | In `./src/mappping/fungible-token.ts`, 684 | 685 | ```typescript 686 | export function handleFtMint( 687 | method: string, 688 | event: string, 689 | data: TypedMap, 690 | receipt: near.ReceiptWithOutcome 691 | ): void { 692 | const amount = BigDecimal.fromString(data.get('amount')!.toString()); 693 | updatePrice(event, method, receipt, BigDecimal.zero(), amount); 694 | } 695 | ``` 696 | 697 | We can also take a look how `updatePrice()` works in [`./src/helper/price.ts`](https://github.com/linear-protocol/linear-subgraph/blob/b13779eee7c233932b1ed58090c553b75464bdb6/src/helper/price.ts#L5-L36): 698 | 699 | 1. The last `Price` object will be read from the `Price` entities by using the last price version ID saved in `Status` entity, which is a global state that tracks the latest versions of price, total swap fees, etc.; 700 | 2. A new `Price` entity will be created with delta $NEAR / $LiNEAR amount, the updated total $NEAR / $LiNEAR amount, current $LiNEAR price, and other relevant info such as event name, method name, timestamp, etc., and saved into the database; 701 | 3. Increment the latest Price version ID, and update the latest price value in the global `Status` record, which will be used in next `updatePrice()` call. 702 | 703 | ```typescript 704 | export function updatePrice( 705 | event: string, 706 | method: string, 707 | receipt: near.ReceiptWithOutcome, 708 | deltaNear: BigDecimal, 709 | deltaLinear: BigDecimal 710 | ): void { 711 | const timestamp = receipt.block.header.timestampNanosec; 712 | const receiptHash = receipt.receipt.id.toBase58(); 713 | 714 | let status = getOrInitStatus(); 715 | let lastPrice = getOrInitPrice(status.priceVersion.toString()); 716 | 717 | // create new price 718 | const nextVersion = status.priceVersion.plus(BigInt.fromU32(1)); 719 | let nextPrice = new Price(nextVersion.toString()); 720 | nextPrice.deltaNearAmount = deltaNear; 721 | nextPrice.deltaLinearAmount = deltaLinear; 722 | nextPrice.totalNearAmount = lastPrice.totalNearAmount.plus(deltaNear); 723 | nextPrice.totalLinearAmount = lastPrice.totalLinearAmount.plus(deltaLinear); 724 | nextPrice.price = nextPrice.totalNearAmount.div(nextPrice.totalLinearAmount); 725 | nextPrice.timestamp = BigInt.fromU64(timestamp); 726 | nextPrice.event = event; 727 | nextPrice.receiptHash = receiptHash; 728 | nextPrice.method = method; 729 | nextPrice.save(); 730 | 731 | // update status 732 | status.priceVersion = nextVersion; 733 | status.price = nextPrice.price; 734 | status.save(); 735 | } 736 | ``` 737 | 738 | Now that we have all the versioned prices in history, it would be easy to calculate the staking APY which is reflected by the growth of $LiNEAR price. 739 | 740 | 741 | ### Deploy the Subgraph 742 | 743 | Now we have built the subgraph. It's the time to deploy it to [The Graph's Subgraph Studio](https://thegraph.com/docs/en/deploying/deploying-a-subgraph-to-studio/) for indexing. 744 | 745 | First, you need to create your subgraph in the [Subgraph Studio dashboard](https://thegraph.com/studio/) by clicking "Create a Subgraph" button, or just visit [this link](https://thegraph.com/studio/?show=Create). Enter the name for the subgraph will be good enough. 746 | 747 | ![](https://i.imgur.com/wp2hswQ.png) 748 | 749 | 750 | 751 | Next, you can follow the steps in [README](https://github.com/linear-protocol/linear-subgraph/blob/main/README.md#development) to deploy LiNEAR subgraph to either testnet or mainnet. Don't forget to replace the `SLUG` and `ACCESS_TOKEN` in `.env` with yours. 752 | 753 | ```bash 754 | # copy env and adjust its content 755 | # you can get an access token from https://thegraph.com/explorer/dashboard 756 | cp .env.example .env 757 | # install project dependencies 758 | yarn 759 | # prepare subgraph.yaml 760 | yarn prepare:mainnet 761 | # run codegen 762 | yarn codegen 763 | # now you're able to deploy to thegraph via 764 | yarn deploy 765 | ``` 766 | 767 | After waiting a while (minutes to even hours, depending on how complex your mapping handler is and how long your project exists), your subgraph should be synchronized. You can always check the latest status of your subgraph in the Subgraph Studio site (e.g. https://thegraph.com/studio/subgraph/linear if you have deployed LiNEAR subgraph for mainnet). 768 | 769 | ![](https://i.imgur.com/jYfcRhp.png) 770 | 771 | 772 | 773 | ## 4. Querying Subgraphs 774 | 775 | Thanks for staying so long with us. :muscle: Now it's time to query our subgraph!!! 776 | 777 | You'll need to [learn a bit about **GraphQL**](https://graphql.org/learn/) and [The Graph's GraphQL API](https://thegraph.com/docs/en/developer/graphql-api/) if you didn't use it before. 778 | 779 | We have at least two ways to query data: 780 | 781 | 1. using the playground of your subgraph 782 | 2. using the GraphQL client in your code 783 | 784 | 785 | ### Query with Playground 786 | 787 | After deploying your subgraph and the sync is done, you'll be able to query with the playground. (e.g. LiNEAR's mainnet subgraph: https://thegraph.com/studio/subgraph/linear/playground) 788 | 789 | ![](https://i.imgur.com/ap1dxGa.png) 790 | 791 | In the playground, you can edit, save and execute your GraphQL queries. 792 | 793 | In the above screenshot, we have queried the top 100 users who has staked the largest amount of NEAR in the history. 794 | 795 | Query: 796 | 797 | ```graphql 798 | { 799 | users ( 800 | first: 100, 801 | orderBy: stakedNear, 802 | orderDirection: desc 803 | ) { 804 | id 805 | unstakedLinear 806 | stakedNear 807 | firstStakingTime 808 | } 809 | } 810 | ``` 811 | 812 | Example Response: 813 | 814 | ```json 815 | { 816 | "data": { 817 | "users": [ 818 | { 819 | "id": "linear-stake-wars.sputnik-dao.near", 820 | "unstakedLinear": "14527932986114632810559251236484", 821 | "stakedNear": "18367600009999999999999999999967", 822 | "firstStakingTime": "1671681650798360876" 823 | }, 824 | { 825 | "id": "6c7b72429c8616c52cced76562b7c0da34c1d31bb283b16c86f7236491eee5b8", 826 | "unstakedLinear": "0", 827 | "stakedNear": "3647469999999999999999999999985", 828 | "firstStakingTime": "1694964863443998724" 829 | }, 830 | { 831 | "id": "dcc81d49b62bf89e1a07de111c32aa89fb4b6859e8bd47fa73052df9e3599244", 832 | "unstakedLinear": "0", 833 | "stakedNear": "3474282843109999999999999999980", 834 | "firstStakingTime": "1681577057045643423" 835 | }, 836 | // ... 837 | ] 838 | } 839 | } 840 | ``` 841 | 842 | 843 | ### Query with GraphQL Client in Code 844 | 845 | Usually we'll query subgraph in our application frontend and analytics/statistics tools. We can use any GraphQL client such as Apollo Client or URQL as suggested by [The Graph's docs](https://thegraph.com/docs/en/developer/querying-from-your-app/). 846 | 847 | Here we use `urql` library as an example. 848 | 849 | (1) Get the GraphQL endpoint for our subgraph: `https://api.studio.thegraph.com/query///` for development or testing purpose, or `https://gateway-arbitrum.network.thegraph.com/api/[api-key]/subgraphs/id/` for production usage. 850 | 851 | (2) Create the URQL client. 852 | 853 | ```javascript 854 | const { createClient } = require('urql'); 855 | 856 | const client = createClient({ 857 | url: config.subgraph.apiUrl, 858 | }) 859 | ``` 860 | 861 | (3) Query the subgraph with the URQL client. 862 | 863 | Here we'd like to query the $LiNEAR price 30 days ago, so we can calculate the staking APY with it. You can find more query examples in the [`./test` folder](https://github.com/linear-protocol/linear-subgraph/blob/b13779eee7c233932b1ed58090c553b75464bdb6/test/price.js#L4-L23). 864 | 865 | ```javascript 866 | async function queryPriceBefore(timestamp) { 867 | const query = ` 868 | query { 869 | prices (first: 1, orderBy: timestamp, orderDirection: desc, 870 | where: {timestamp_lte: "${timestamp.toString()}"} ) 871 | { 872 | id 873 | timestamp 874 | price 875 | } 876 | }`; 877 | const { data } = await client.query(query).toPromise(); 878 | if (data == null) { 879 | throw new Error('Failed to query price'); 880 | } 881 | return data.prices[0]; 882 | } 883 | ``` 884 | 885 | We can also get the latest $LiNEAR price from contract, and we already have queried the $LiNEAR price 30 days before now, we'll be able to calculate the annual staking APY with the formula `(price (now) - price (30 days ago)) / 30 * 365`. We finally make it!!! 886 | 887 | *P.S.* At LiNEAR Protocol, we have built a SDK based on the subgraph queries, which is used in our frontend and analytics. Please feel free to [check out](https://github.com/linear-protocol/linear-sdk) if you're interested to build your own SDKs. 888 | 889 | 890 | ## It's time to BUIDL now!!! 891 | 892 | Congratulations, my friend! :birthday: 893 | 894 | You have learnt about the details of building subgraphs on NEAR with events, from implementing and emitting the events in your smart contract, to building, deploying and querying the subgraphs that process your events. 895 | 896 | Besides this tutorial, there are other excellent guides that will help you learn more about using The Graph on NEAR. 897 | 898 | - [Building Subgraphs on NEAR](https://thegraph.com/docs/en/supported-networks/near/) 899 | - [Building an NFT API on NEAR with The Graph](https://github.com/dabit3/near-subgraph-workshop) 900 | - [Example NEAR Receipts Subgraph: Good Morning NEAR](https://github.com/graphprotocol/example-subgraph/tree/near-receipts-example) 901 | - [Quick Start Guide of The Graph](https://thegraph.com/docs/en/developer/quick-start/) 902 | 903 | 904 | Now it's your time to start building and hacking. If you have any questions, please feel free to [discuss with us in the `linear-protocol/linear-subgraph` repo](https://github.com/linear-protocol/linear-subgraph/discussions). Good luck! 905 | 906 | 907 | ## About 908 | 909 | ### About LiNEAR 910 | 911 | LiNEAR Protocol is a liquid staking solution built on the NEAR Protocol. LiNEAR unlocks liquidity of the staked NEAR by creating a staking derivative to be engaged with various DeFi protocols on NEAR and Aurora, while also enjoying over 10% APY staking rewards of the underlying base tokens. LiNEAR is the cornerstone of the NEAR-Aurora DeFi ecosystem. 912 | 913 | ### About The Graph 914 | 915 | The Graph is the indexing and query layer of web3. Developers build and publish open APIs, called subgraphs, that applications can query using GraphQL. The Graph currently supports indexing data from 31 different networks including Ethereum, NEAR, Arbitrum, Optimism, Polygon, Avalanche, Celo, Fantom, Moonbeam, IPFS, and PoA with more networks coming soon. Developers build and publish open APIs, called subgraphs, that applications can query using GraphQL. 916 | 917 | ![](https://i.imgur.com/yZSgnsT.png) 918 | --------------------------------------------------------------------------------