Merge remote-tracking branch 'origin/main' into blockchain-watcher/multi-instance

This commit is contained in:
matias martinez 2023-12-13 09:54:38 -03:00
commit 5737b73949
21 changed files with 932 additions and 385 deletions

View File

@ -37,38 +37,89 @@ By default, jobs are read from `metadata-repo/jobs/jobs.json`.
Example:
```
{ "jobs": [
{
"id": "poll-log-message-published-ethereum",
"chain": "ethereum",
"source": {
"action": "PollEvmLogs",
"config": {
"fromBlock": "10012499",
"blockBatchSize": 100,
"commitment": "latest",
"interval": 15000,
"addresses": ["0x706abc4E45D419950511e474C7B9Ed348A4a716c"],
"chain": "ethereum",
"topics": []
}
},
"handlers": [
{
"action": "HandleEvmLogs",
"target": "sns",
"mapper": "evmLogMessagePublishedMapper",
{
"jobs": [
{
"id": "poll-log-message-published-ethereum",
"chain": "ethereum",
"source": {
"action": "PollEvmLogs",
"config": {
"abi": "event LogMessagePublished(address indexed sender, uint64 sequence, uint32 nonce, bytes payload, uint8 consistencyLevel)",
"filter": {
"addresses": ["0x706abc4E45D419950511e474C7B9Ed348A4a716c"],
"topics": ["0x6eb224fb001ed210e379b335e35efe88672a8ce935d981a6896b27ffdf52a3b2"]
"fromBlock": "10012499",
"blockBatchSize": 100,
"commitment": "latest",
"interval": 15000,
"addresses": ["0x706abc4E45D419950511e474C7B9Ed348A4a716c"],
"chain": "ethereum",
"topics": []
}
},
"handlers": [
{
"action": "HandleEvmLogs",
"target": "sns",
"mapper": "evmLogMessagePublishedMapper",
"config": {
"abi": "event LogMessagePublished(address indexed sender, uint64 sequence, uint32 nonce, bytes payload, uint8 consistencyLevel)",
"filter": {
"addresses": ["0x706abc4E45D419950511e474C7B9Ed348A4a716c"],
"topics": ["0x6eb224fb001ed210e379b335e35efe88672a8ce935d981a6896b27ffdf52a3b2"]
}
}
},
{
"action": "HandleEvmLogs",
"target": "sns",
"mapper": "evmTransferRedeemedMapper",
"config": {
"abi": "event TransferRedeemed(uint16 indexed emitterChainId, bytes32 indexed emitterAddress, uint64 indexed sequence)",
"filter": {
"addresses": ["0x3ee18b2214aff97000d974cf647e7c347e8fa585"],
"topics": ["0xcaf280c8cfeba144da67230d9b009c8f868a75bac9a528fa0474be1ba317c169"]
}
}
},
{
"action": "HandleEvmLogs",
"target": "sns",
"mapper": "evmStandardRelayDelivered",
"config": {
"abi": "event Delivery(address indexed recipientContract, uint16 indexed sourceChain, uint64 indexed sequence, bytes32 deliveryVaaHash, uint8 status, uint256 gasUsed, uint8 refundStatus, bytes additionalStatusInfo, bytes overridesInfo)",
"filter": {
"addresses": ["0x27428dd2d3dd32a4d7f7c497eaaa23130d894911"],
"topics": ["0xbccc00b713f54173962e7de6098f643d8ebf53d488d71f4b2a5171496d038f9e"]
}
}
}
}
]
}
]}
]
},
{
"id": "poll-transfer-redeemed-solana",
"chain": "solana",
"source": {
"action": "PollSolanaTransactions",
"config": {
"slotBatchSize": 1000,
"commitment": "finalized",
"interval": 1500,
"signaturesLimit": 200,
"programId": "wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb",
"chain": "solana"
}
},
"handlers": [
{
"action": "HandleSolanaTransactions",
"target": "sns",
"mapper": "solanaTransferRedeemedMapper",
"config": {
"programId": "wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb"
}
}
]
}
]
}
```

View File

@ -6,7 +6,7 @@ info:
Platform service that allows to extract, transform and load data from different blockchains platforms.
servers:
staging-testnet:
url: arn:aws:sns:us-east-2:581679387567:notification-chain-events-dev-testnet.fifo
url: notification-chain-events-dev-testnet.fifo
protocol: sns
defaultContentType: application/json
channels:
@ -34,6 +34,44 @@ components:
contentType: application/json
payload:
$ref: "#/components/schemas/transferRedeemed"
examples:
- name: TransferRedeemed in Solana from Ethereum
payload:
name: "transfer-redeemed"
address: wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb
chainId: 1
txHash: 3FySmshUgVCM2N158oNYbeTfZt2typEU32c9ZxdAXiXURFHuTmeJHhc7cSUtqHdwAsbVWWvEsEddWNAKzkjVPSg2
blockHeight: 234015120
blockTime: 1701724272
attributes:
emitterChainId: 2
emitterAddress: "0000000000000000000000003ee18b2214aff97000d974cf647e7c347e8fa585"
sequence: 144500
standardRelayDelivered:
name: StandardRelayDelivered
title: StandardRelayDelivered
contentType: application/json
payload:
$ref: "#/components/schemas/standardRelayDelivered"
examples:
- name: StandardRelayDelivered from in Ethereum from Base
payload:
name: "standard-relay-delivered"
address: "0x27428dd2d3dd32a4d7f7c497eaaa23130d894911"
chainId: 2
txHash: "0xcbdefc83080a8f60cbde7785eb2978548fd5c1f7d0ea2c024cce537845d339c7"
blockHeight: 18708316n
blockTime: 1699443287
attributes:
recipientContract: "0xF80cf52922B512B22D46aA8916BD7767524305d9"
sourceChain: 30
sequence: 2304
deliveryVaaHash: "0xf29cac97156fa11c205eda95c0655e4a6e2a9c247245bab4d3d8257c41fc11d2"
status: 0
gasUsed: 80521
refundStatus: 0
additionalStatusInfo: "0x"
overridesInfo: "0x"
schemas:
base:
type: object
@ -103,7 +141,37 @@ components:
type: string
sequence:
type: number
standardRelayDelivered:
allOf:
- $ref: "#/components/schemas/base"
type: object
properties:
data:
type: object
allOf:
- $ref: "#/components/schemas/chainEventBase"
properties:
attributes:
type: object
properties:
recipientContract:
type: string
sourceChain:
type: number
sequence:
type: number
deliveryVaaHash:
type: string
status:
type: number
gasUsed:
type: number
refundStatus:
type: number
additionalStatusInfo:
type: string
overridesInfo:
type: string
sentAt:
type: string
format: date-time

View File

@ -1,17 +1,16 @@
{
"name": "@wormhole-foundation/blockchain-watcher",
"version": "0.0.2",
"version": "0.0.5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@wormhole-foundation/blockchain-watcher",
"version": "0.0.2",
"version": "0.0.5",
"license": "ISC",
"dependencies": {
"@aws-sdk/client-sns": "^3.445.0",
"@certusone/wormhole-sdk": "0.10.5",
"@types/config": "^3.3.3",
"axios": "^1.6.0",
"bs58": "^5.0.0",
"config": "^3.3.9",
@ -20,13 +19,12 @@
"mollitia": "^0.1.0",
"pg": "^8.11.3",
"prom-client": "^15.0.0",
"uuid": "^9.0.1",
"winston": "3.8.2"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@testcontainers/postgresql": "^10.3.2",
"@types/koa-router": "^7.4.4",
"@types/config": "^3.3.3",
"@types/pg": "^8.10.9",
"@types/uuid": "^9.0.6",
"@types/yargs": "^17.0.23",
@ -4746,15 +4744,6 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"node_modules/@types/accepts": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz",
"integrity": "sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.3",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz",
@ -4804,20 +4793,11 @@
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.2",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
"integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==",
"dev": true,
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/config": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@types/config/-/config-3.3.3.tgz",
"integrity": "sha512-BB8DBAud88EgiAKlz8WQStzI771Kb6F3j4dioRJ4GD+tP4tzcZyMlz86aXuZT4s9hyesFORehMQE6eqtA5O+Vg=="
"integrity": "sha512-BB8DBAud88EgiAKlz8WQStzI771Kb6F3j4dioRJ4GD+tP4tzcZyMlz86aXuZT4s9hyesFORehMQE6eqtA5O+Vg==",
"dev": true
},
"node_modules/@types/connect": {
"version": "3.4.35",
@ -4827,24 +4807,6 @@
"@types/node": "*"
}
},
"node_modules/@types/content-disposition": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.5.tgz",
"integrity": "sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==",
"dev": true
},
"node_modules/@types/cookies": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.7.tgz",
"integrity": "sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==",
"dev": true,
"dependencies": {
"@types/connect": "*",
"@types/express": "*",
"@types/keygrip": "*",
"@types/node": "*"
}
},
"node_modules/@types/docker-modem": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz",
@ -4865,30 +4827,6 @@
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.17",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
"integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==",
"dev": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.17.35",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz",
"integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==",
"dev": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/graceful-fs": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.8.tgz",
@ -4898,18 +4836,6 @@
"@types/node": "*"
}
},
"node_modules/@types/http-assert": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz",
"integrity": "sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==",
"dev": true
},
"node_modules/@types/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==",
"dev": true
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz",
@ -4934,46 +4860,6 @@
"@types/istanbul-lib-report": "*"
}
},
"node_modules/@types/keygrip": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz",
"integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==",
"dev": true
},
"node_modules/@types/koa": {
"version": "2.13.6",
"resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.13.6.tgz",
"integrity": "sha512-diYUfp/GqfWBAiwxHtYJ/FQYIXhlEhlyaU7lB/bWQrx4Il9lCET5UwpFy3StOAohfsxxvEQ11qIJgT1j2tfBvw==",
"dev": true,
"dependencies": {
"@types/accepts": "*",
"@types/content-disposition": "*",
"@types/cookies": "*",
"@types/http-assert": "*",
"@types/http-errors": "*",
"@types/keygrip": "*",
"@types/koa-compose": "*",
"@types/node": "*"
}
},
"node_modules/@types/koa-compose": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.5.tgz",
"integrity": "sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==",
"dev": true,
"dependencies": {
"@types/koa": "*"
}
},
"node_modules/@types/koa-router": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/koa-router/-/koa-router-7.4.4.tgz",
"integrity": "sha512-3dHlZ6CkhgcWeF6wafEUvyyqjWYfKmev3vy1PtOmr0mBc3wpXPU5E8fBBd4YQo5bRpHPfmwC5yDaX7s4jhIN6A==",
"dev": true,
"dependencies": {
"@types/koa": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.14.195",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz",
@ -4999,12 +4885,6 @@
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.7.tgz",
"integrity": "sha512-gKc9P2d4g5uYwmy4s/MO/yOVPmvHyvzka1YH6i5dM03UrFofHSmgc0D0ymbDRStFWHusk6cwwF6nhLm/ckBbbQ=="
},
"node_modules/@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"node_modules/@types/node": {
"version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
@ -5086,18 +4966,6 @@
"node": ">=12"
}
},
"node_modules/@types/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
"integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==",
"dev": true
},
"node_modules/@types/range-parser": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz",
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
"dev": true
},
"node_modules/@types/secp256k1": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-4.0.3.tgz",
@ -5106,27 +4974,6 @@
"@types/node": "*"
}
},
"node_modules/@types/send": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz",
"integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==",
"dev": true,
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.2.tgz",
"integrity": "sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==",
"dev": true,
"dependencies": {
"@types/http-errors": "*",
"@types/mime": "*",
"@types/node": "*"
}
},
"node_modules/@types/ssh2": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.18.tgz",
@ -5157,9 +5004,9 @@
"integrity": "sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g=="
},
"node_modules/@types/uuid": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.6.tgz",
"integrity": "sha512-BT2Krtx4xaO6iwzwMFUYvWBWkV2pr37zD68Vmp1CDV196MzczBRxuEpD6Pr395HAgebC/co7hOphs53r8V7jew==",
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz",
"integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==",
"dev": true
},
"node_modules/@types/ws": {
@ -12200,18 +12047,6 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@ -16168,15 +16003,6 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"@types/accepts": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz",
"integrity": "sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/babel__core": {
"version": "7.20.3",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz",
@ -16226,20 +16052,11 @@
"@types/node": "*"
}
},
"@types/body-parser": {
"version": "1.19.2",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
"integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==",
"dev": true,
"requires": {
"@types/connect": "*",
"@types/node": "*"
}
},
"@types/config": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@types/config/-/config-3.3.3.tgz",
"integrity": "sha512-BB8DBAud88EgiAKlz8WQStzI771Kb6F3j4dioRJ4GD+tP4tzcZyMlz86aXuZT4s9hyesFORehMQE6eqtA5O+Vg=="
"integrity": "sha512-BB8DBAud88EgiAKlz8WQStzI771Kb6F3j4dioRJ4GD+tP4tzcZyMlz86aXuZT4s9hyesFORehMQE6eqtA5O+Vg==",
"dev": true
},
"@types/connect": {
"version": "3.4.35",
@ -16249,24 +16066,6 @@
"@types/node": "*"
}
},
"@types/content-disposition": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.5.tgz",
"integrity": "sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==",
"dev": true
},
"@types/cookies": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.7.tgz",
"integrity": "sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==",
"dev": true,
"requires": {
"@types/connect": "*",
"@types/express": "*",
"@types/keygrip": "*",
"@types/node": "*"
}
},
"@types/docker-modem": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz",
@ -16287,30 +16086,6 @@
"@types/node": "*"
}
},
"@types/express": {
"version": "4.17.17",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
"integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==",
"dev": true,
"requires": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"@types/express-serve-static-core": {
"version": "4.17.35",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz",
"integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==",
"dev": true,
"requires": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"@types/graceful-fs": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.8.tgz",
@ -16320,18 +16095,6 @@
"@types/node": "*"
}
},
"@types/http-assert": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz",
"integrity": "sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==",
"dev": true
},
"@types/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==",
"dev": true
},
"@types/istanbul-lib-coverage": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz",
@ -16356,46 +16119,6 @@
"@types/istanbul-lib-report": "*"
}
},
"@types/keygrip": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz",
"integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==",
"dev": true
},
"@types/koa": {
"version": "2.13.6",
"resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.13.6.tgz",
"integrity": "sha512-diYUfp/GqfWBAiwxHtYJ/FQYIXhlEhlyaU7lB/bWQrx4Il9lCET5UwpFy3StOAohfsxxvEQ11qIJgT1j2tfBvw==",
"dev": true,
"requires": {
"@types/accepts": "*",
"@types/content-disposition": "*",
"@types/cookies": "*",
"@types/http-assert": "*",
"@types/http-errors": "*",
"@types/keygrip": "*",
"@types/koa-compose": "*",
"@types/node": "*"
}
},
"@types/koa-compose": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.5.tgz",
"integrity": "sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==",
"dev": true,
"requires": {
"@types/koa": "*"
}
},
"@types/koa-router": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/koa-router/-/koa-router-7.4.4.tgz",
"integrity": "sha512-3dHlZ6CkhgcWeF6wafEUvyyqjWYfKmev3vy1PtOmr0mBc3wpXPU5E8fBBd4YQo5bRpHPfmwC5yDaX7s4jhIN6A==",
"dev": true,
"requires": {
"@types/koa": "*"
}
},
"@types/lodash": {
"version": "4.14.195",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz",
@ -16421,12 +16144,6 @@
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.7.tgz",
"integrity": "sha512-gKc9P2d4g5uYwmy4s/MO/yOVPmvHyvzka1YH6i5dM03UrFofHSmgc0D0ymbDRStFWHusk6cwwF6nhLm/ckBbbQ=="
},
"@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"@types/node": {
"version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
@ -16495,18 +16212,6 @@
}
}
},
"@types/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
"integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==",
"dev": true
},
"@types/range-parser": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz",
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
"dev": true
},
"@types/secp256k1": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-4.0.3.tgz",
@ -16515,27 +16220,6 @@
"@types/node": "*"
}
},
"@types/send": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz",
"integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==",
"dev": true,
"requires": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"@types/serve-static": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.2.tgz",
"integrity": "sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==",
"dev": true,
"requires": {
"@types/http-errors": "*",
"@types/mime": "*",
"@types/node": "*"
}
},
"@types/ssh2": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.18.tgz",
@ -16566,9 +16250,9 @@
"integrity": "sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g=="
},
"@types/uuid": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.6.tgz",
"integrity": "sha512-BT2Krtx4xaO6iwzwMFUYvWBWkV2pr37zD68Vmp1CDV196MzczBRxuEpD6Pr395HAgebC/co7hOphs53r8V7jew==",
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz",
"integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==",
"dev": true
},
"@types/ws": {
@ -22011,11 +21695,6 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
},
"v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "@wormhole-foundation/blockchain-watcher",
"version": "0.0.2",
"version": "0.0.5",
"description": "A process for watching blockchain events and moving them to persistent storage",
"main": "index.js",
"scripts": {
@ -20,7 +20,6 @@
"dependencies": {
"@aws-sdk/client-sns": "^3.445.0",
"@certusone/wormhole-sdk": "0.10.5",
"@types/config": "^3.3.3",
"axios": "^1.6.0",
"bs58": "^5.0.0",
"config": "^3.3.9",
@ -29,17 +28,16 @@
"mollitia": "^0.1.0",
"pg": "^8.11.3",
"prom-client": "^15.0.0",
"uuid": "^9.0.1",
"winston": "3.8.2"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@testcontainers/postgresql": "^10.3.2",
"@types/koa-router": "^7.4.4",
"@types/pg": "^8.10.9",
"@types/uuid": "^9.0.6",
"@types/yargs": "^17.0.23",
"@vercel/ncc": "^0.38.1",
"@types/config": "^3.3.3",
"jest": "^29.7.0",
"nock": "^13.3.8",
"prettier": "^2.8.7",
@ -83,7 +81,7 @@
"coverageDirectory": "./coverage",
"coverageThreshold": {
"global": {
"lines": 73
"lines": 75
}
}
}

View File

@ -114,7 +114,7 @@ export class PollEvmLogs extends RunPollingJob {
toBlock = this.cfg.toBlock;
}
return { fromBlock, toBlock };
return { fromBlock: BigInt(fromBlock), toBlock: BigInt(toBlock) };
}
private report(): void {
@ -124,8 +124,14 @@ export class PollEvmLogs extends RunPollingJob {
commitment: this.cfg.getCommitment(),
};
this.statsRepository.count("job_execution", labels);
this.statsRepository.measure("block_height", this.latestBlockHeight ?? 0n, labels);
this.statsRepository.measure("block_cursor", this.blockHeightCursor ?? 0n, labels);
this.statsRepository.measure("polling_cursor", this.latestBlockHeight ?? 0n, {
...labels,
type: "max",
});
this.statsRepository.measure("polling_cursor", this.blockHeightCursor ?? 0n, {
...labels,
type: "current",
});
}
}

View File

@ -3,6 +3,7 @@ export * from "./evm/GetEvmLogs";
export * from "./evm/PollEvmLogs";
export * from "./solana/GetSolanaTransactions";
export * from "./solana/PollSolanaTransactions";
export * from "./solana/HandleSolanaTransactions";
export * from "./RunPollingJob";
export * from "./RunCronTask";
export * from "./jobs/StartJobs";

View File

@ -21,7 +21,7 @@ export class PollSolanaTransactions extends RunPollingJob {
statsRepo: StatRepository,
cfg: PollSolanaTransactionsConfig
) {
super(1_000, cfg.id, statsRepo);
super(cfg.interval ?? 1_000, cfg.id, statsRepo);
this.metadataRepo = metadataRepo;
this.slotRepository = slotRepo;
@ -130,8 +130,14 @@ export class PollSolanaTransactions extends RunPollingJob {
commitment: this.cfg.commitment,
};
this.statsRepo.count("job_execution", labels);
this.statsRepo.measure("block_height", BigInt(this.latestSlot ?? 0), labels);
this.statsRepo.measure("block_cursor", BigInt(this.slotCursor ?? 0n), labels);
this.statsRepo.measure("polling_cursor", BigInt(this.latestSlot ?? 0), {
...labels,
type: "max",
});
this.statsRepo.measure("polling_cursor", BigInt(this.slotCursor ?? 0n), {
...labels,
type: "current",
});
}
/**

View File

@ -15,3 +15,21 @@ export type LogMessagePublished = {
payload: string;
consistencyLevel: number;
};
export type TransferRedeemed = {
emitterChainId: number;
emitterAddress: string;
sequence: number;
};
export type StandardRelayDelivered = {
recipientContract: string;
sourceChain: number;
sequence: number;
deliveryVaaHash: string;
status: number;
gasUsed: number;
refundStatus: number;
additionalStatusInfo: string;
overridesInfo: string;
};

View File

@ -0,0 +1,34 @@
import { BigNumber } from "ethers";
import { EvmLog, LogFoundEvent, StandardRelayDelivered } from "../../domain/entities";
/*
* Delivery (index_topic_1 address recipientContract, index_topic_2 uint16 sourceChain, index_topic_3 uint64 sequence, bytes32 deliveryVaaHash, uint8 status, uint256 gasUsed, uint8 refundStatus, bytes additionalStatusInfo, bytes overridesInfo)
*/
export const evmStandardRelayDelivered = (
log: EvmLog,
args: ReadonlyArray<any>
): LogFoundEvent<StandardRelayDelivered> => {
if (!log.blockTime) {
throw new Error(`Block time is missing for log ${log.logIndex} in tx ${log.transactionHash}`);
}
return {
name: "standard-relay-delivered",
address: log.address,
chainId: log.chainId,
txHash: log.transactionHash,
blockHeight: log.blockNumber,
blockTime: log.blockTime,
attributes: {
recipientContract: args[0],
sourceChain: BigNumber.from(args[1]).toNumber(),
sequence: BigNumber.from(args[2]).toNumber(),
deliveryVaaHash: args[3],
status: BigNumber.from(args[4]).toNumber(),
gasUsed: BigNumber.from(args[5]).toNumber(),
refundStatus: BigNumber.from(args[6]).toNumber(),
additionalStatusInfo: args[7],
overridesInfo: args[8],
},
};
};

View File

@ -0,0 +1,25 @@
import { BigNumber } from "ethers";
import { EvmLog, LogFoundEvent, TransferRedeemed } from "../../domain/entities";
export const evmTransferRedeemedMapper = (
log: EvmLog,
_: ReadonlyArray<any>
): LogFoundEvent<TransferRedeemed> => {
if (!log.blockTime) {
throw new Error(`Block time is missing for log ${log.logIndex} in tx ${log.transactionHash}`);
}
return {
name: "transfer-redeemed",
address: log.address,
chainId: log.chainId,
txHash: log.transactionHash,
blockHeight: log.blockNumber,
blockTime: log.blockTime,
attributes: {
emitterChainId: Number(log.topics[1]),
emitterAddress: log.topics[2],
sequence: BigNumber.from(log.topics[3]).toNumber(),
},
};
};

View File

@ -1,2 +1,5 @@
export * from "./solanaLogMessagePublishedMapper";
export * from "./evmLogMessagePublishedMapper";
export * from "./evmTransferRedeemedMapper";
export * from "./evmStandardRelayDelivered";
export * from "./solanaLogMessagePublishedMapper";
export * from "./solanaTransferRedeemedMapper";

View File

@ -12,7 +12,9 @@ export const solanaLogMessagePublishedMapper = async (
{ programId, commitment }: { programId: string; commitment?: Commitment }
): Promise<LogFoundEvent<LogMessagePublished>[]> => {
if (!tx || !tx.blockTime) {
throw new Error(`Block time is missing for tx in slot ${tx?.slot} @ time ${tx?.blockTime}`);
throw new Error(
`Block time is missing for tx ${tx?.transaction?.signatures} in slot ${tx?.slot}`
);
}
const message = tx.transaction.message;

View File

@ -0,0 +1,93 @@
import { decode } from "bs58";
import { Connection, Commitment } from "@solana/web3.js";
import { solana, LogFoundEvent, TransferRedeemed } from "../../domain/entities";
import { CompiledInstruction, MessageCompiledInstruction } from "../../domain/entities/solana";
import { configuration } from "../config";
import { getPostedMessage } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole";
enum Instruction {
CompleteNativeTransfer = 0x02,
CompleteWrappedTransfer = 0x03,
CompleteNativeWithPayload = 0x09,
CompleteWrappedWithPayload = 0x0a,
}
const connection = new Connection(configuration.chains.solana.rpcs[0]);
export const solanaTransferRedeemedMapper = async (
tx: solana.Transaction,
{ programId, commitment }: { programId: string; commitment?: Commitment }
): Promise<LogFoundEvent<TransferRedeemed>[]> => {
if (!tx || !tx.blockTime) {
throw new Error(
`Block time is missing for tx ${tx?.transaction?.signatures} in slot ${tx?.slot}`
);
}
const message = tx.transaction.message;
const accountKeys = message.accountKeys;
const programIdIndex = accountKeys.findIndex((i) => i === programId);
const instructions = message.compiledInstructions;
const innerInstructions =
tx.meta?.innerInstructions?.flatMap((i) => i.instructions.map(normalizeCompileInstruction)) ||
[];
const whInstructions = innerInstructions
.concat(instructions)
.filter((i) => i.programIdIndex === programIdIndex);
const results: LogFoundEvent<TransferRedeemed>[] = [];
for (const instruction of whInstructions) {
if (isNotACompleteTransferInstruction(instruction.data)) {
continue;
}
const accountAddress = accountKeys[instruction.accountKeyIndexes[2]];
const { message } = await getPostedMessage(connection, accountAddress, commitment);
const { sequence, emitterAddress, emitterChain } = message || {};
results.push({
name: "transfer-redeemed",
address: programId,
chainId: 1,
txHash: tx.transaction.signatures[0],
blockHeight: BigInt(tx.slot.toString()),
blockTime: tx.blockTime,
attributes: {
emitterChainId: emitterChain,
emitterAddress: emitterAddress.toString("hex"),
sequence: Number(sequence),
},
});
}
return results;
};
const normalizeCompileInstruction = (
instruction: CompiledInstruction | MessageCompiledInstruction
): MessageCompiledInstruction => {
if ("accounts" in instruction) {
return {
accountKeyIndexes: instruction.accounts,
data: decode(instruction.data),
programIdIndex: instruction.programIdIndex,
};
} else {
return instruction;
}
};
/**
* Checks if the instruction is not to complete a transfer.
* @param instructionId - the instruction id
* @returns true if the instruction is valid, false otherwise
*/
const isNotACompleteTransferInstruction = (instructionId: Uint8Array): boolean => {
return (
instructionId[0] !== Instruction.CompleteNativeTransfer &&
instructionId[0] !== Instruction.CompleteWrappedTransfer &&
instructionId[0] !== Instruction.CompleteNativeWithPayload &&
instructionId[0] !== Instruction.CompleteWrappedWithPayload
);
};

View File

@ -6,6 +6,7 @@ import {
PollSolanaTransactions,
PollSolanaTransactionsConfig,
RunPollingJob,
HandleSolanaTransactions,
} from "../../../domain/actions";
import { JobDefinition, Handler, LogFoundEvent } from "../../../domain/entities";
import {
@ -15,10 +16,15 @@ import {
SolanaSlotRepository,
StatRepository,
} from "../../../domain/repositories";
import { FileMetadataRepository, SnsEventRepository } from "../index";
import { HandleSolanaTransactions } from "../../../domain/actions/solana/HandleSolanaTransactions";
import { solanaLogMessagePublishedMapper, evmLogMessagePublishedMapper } from "../../mappers";
import log from "../../log";
import { FileMetadataRepository, SnsEventRepository } from "..";
import {
solanaLogMessagePublishedMapper,
solanaTransferRedeemedMapper,
evmLogMessagePublishedMapper,
evmStandardRelayDelivered,
evmTransferRedeemedMapper,
} from "../../mappers";
export class StaticJobRepository implements JobRepository {
private fileRepo: FileMetadataRepository;
@ -112,7 +118,10 @@ export class StaticJobRepository implements JobRepository {
// Mappers
this.mappers.set("evmLogMessagePublishedMapper", evmLogMessagePublishedMapper);
this.mappers.set("evmStandardRelayDelivered", evmStandardRelayDelivered);
this.mappers.set("evmTransferRedeemedMapper", evmTransferRedeemedMapper);
this.mappers.set("solanaLogMessagePublishedMapper", solanaLogMessagePublishedMapper);
this.mappers.set("solanaTransferRedeemedMapper", solanaTransferRedeemedMapper);
// Targets
const snsTarget = () => this.snsRepo.asTarget();

View File

@ -0,0 +1,59 @@
import { describe, it, expect } from "@jest/globals";
import { evmStandardRelayDelivered } from "../../../src/infrastructure/mappers/evmStandardRelayDelivered";
import { HandleEvmLogs } from "../../../src/domain/actions";
const address = "0x27428dd2d3dd32a4d7f7c497eaaa23130d894911";
const topic = "0xbccc00b713f54173962e7de6098f643d8ebf53d488d71f4b2a5171496d038f9e";
const txHash = "0xcbdefc83080a8f60cbde7785eb2978548fd5c1f7d0ea2c024cce537845d339c7";
const handler = new HandleEvmLogs(
{
filter: { addresses: [address], topics: [topic] },
abi: "event Delivery(address indexed recipientContract, uint16 indexed sourceChain, uint64 indexed sequence, bytes32 deliveryVaaHash, uint8 status, uint256 gasUsed, uint8 refundStatus, bytes additionalStatusInfo, bytes overridesInfo)",
},
evmStandardRelayDelivered,
async () => {}
);
describe("evmStandardRelayDelivered", () => {
it("should be able to map log to TransferRedeeemed", async () => {
const [result] = await handler.handle([
{
chainId: 2,
address,
blockTime: 1699443287,
transactionHash: txHash,
topics: [
"0xbccc00b713f54173962e7de6098f643d8ebf53d488d71f4b2a5171496d038f9e",
"0x000000000000000000000000f80cf52922b512b22d46aa8916bd7767524305d9",
"0x000000000000000000000000000000000000000000000000000000000000001e",
"0x0000000000000000000000000000000000000000000000000000000000000900",
],
data: "0xf29cac97156fa11c205eda95c0655e4a6e2a9c247245bab4d3d8257c41fc11d200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000013a89000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
blockNumber: 18708316n,
transactionIndex: "0x3b",
blockHash: "0x8c55cbd97c96f8322bed4d1790c7ac4a84b1cff46c157bf86fc35eb5886be451",
logIndex: 5,
removed: false,
},
]);
expect(result.name).toBe("standard-relay-delivered");
expect(result.chainId).toBe(2);
expect(result.txHash).toBe(txHash);
expect(result.blockHeight).toBe(18708316n);
expect(result.blockTime).toBe(1699443287);
expect(result.attributes.recipientContract.toLowerCase()).toBe(
"0xf80cf52922b512b22d46aa8916bd7767524305d9"
);
expect(result.attributes.sourceChain).toBe(30);
expect(result.attributes.sequence).toBe(2304);
expect(result.attributes.deliveryVaaHash.toLowerCase()).toBe(
"0xf29cac97156fa11c205eda95c0655e4a6e2a9c247245bab4d3d8257c41fc11d2"
);
expect(result.attributes.status).toBe(0);
expect(result.attributes.gasUsed).toBe(80521);
expect(result.attributes.refundStatus).toBe(0);
});
});

View File

@ -0,0 +1,53 @@
import { describe, it, expect } from "@jest/globals";
import { evmTransferRedeemedMapper } from "../../../src/infrastructure/mappers/evmTransferRedeemedMapper";
import { HandleEvmLogs } from "../../../src/domain/actions";
const address = "0x98f3c9e6e3face36baad05fe09d375ef1464288b";
const topic = "0xcaf280c8cfeba144da67230d9b009c8f868a75bac9a528fa0474be1ba317c169";
const txHash = "0xcbdefc83080a8f60cbde7785eb2978548fd5c1f7d0ea2c024cce537845d339c7";
const handler = new HandleEvmLogs(
{
filter: { addresses: [address], topics: [topic] },
abi: "event TransferRedeemed(uint16 indexed emitterChainId, bytes32 indexed emitterAddress, uint64 indexed sequence)",
},
evmTransferRedeemedMapper,
async () => {}
);
describe("evmTransferRedeemed", () => {
it("should be able to map log to TransferRedeeemed", async () => {
const [result] = await handler.handle([
{
chainId: 2,
address,
topics: [
"0xcaf280c8cfeba144da67230d9b009c8f868a75bac9a528fa0474be1ba317c169",
"0x0000000000000000000000000000000000000000000000000000000000000001",
"0xec7372995d5cc8732397fb0ad35c0121e0eaa90d26f828a534cab54391b3a4f5",
"0x0000000000000000000000000000000000000000000000000000000000052a3e",
],
data: "0x",
blockNumber: 18708192n,
blockTime: 1699443287,
transactionHash: txHash,
transactionIndex: "0x3e",
blockHash: "0x241fa85f3494c654d59859b46af586bd43f37ec434f5cf0018a53e46c42da393",
logIndex: 216,
removed: false,
},
]);
expect(result.name).toBe("transfer-redeemed");
expect(result.chainId).toBe(2);
expect(result.txHash).toBe(txHash);
expect(result.blockHeight).toBe(18708192n);
expect(result.blockTime).toBe(1699443287);
expect(result.attributes.sequence).toBe(338494);
expect(result.attributes.emitterAddress.toLowerCase()).toBe(
"0xec7372995d5cc8732397fb0ad35c0121e0eaa90d26f828a534cab54391b3a4f5"
);
expect(result.attributes.emitterChainId).toBe(1);
});
});

View File

@ -0,0 +1,356 @@
import { expect, describe, it, jest } from "@jest/globals";
import { solana } from "../../../src/domain/entities";
import { solanaTransferRedeemedMapper } from "../../../src/infrastructure/mappers";
import { getPostedMessage } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole";
jest.mock("@certusone/wormhole-sdk/lib/cjs/solana/wormhole");
describe("solanaTransferRedeemedMapper", () => {
it("should map a token bridge tx to a transfer-redeemed event", async () => {
const mockGetPostedMessage = getPostedMessage as jest.MockedFunction<typeof getPostedMessage>;
mockGetPostedMessage.mockResolvedValueOnce({
message: {
emitterChain: 2,
sequence: 1500n,
emitterAddress: Buffer.from(
"0000000000000000000000003ee18b2214aff97000d974cf647e7c347e8fa585",
"hex"
),
submissionTime: 1700571923,
nonce: 0,
consistencyLevel: 1,
payload: Buffer.from("41QVZTrdrRxb", "base64"),
} as any,
});
const programId = "wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb";
const tx = {
blockTime: 1701724272,
meta: {
innerInstructions: [
{
index: 0,
instructions: [
{
accounts: [0, 3],
data: "3Bxs49175da2o1zw",
programIdIndex: 4,
stackHeight: 2,
},
{
accounts: [3],
data: "9krTCzbLfv4BRBcj",
programIdIndex: 4,
stackHeight: 2,
},
{
accounts: [3],
data: "SYXsBvR59hMYH7jGFg8pjr13roqCKDy5t1HFBVKFNWZ1FPp7",
programIdIndex: 4,
stackHeight: 2,
},
{
accounts: [2, 1, 6],
data: "6jFrQ56LiKZ1",
programIdIndex: 11,
stackHeight: 2,
},
{
accounts: [2, 1, 6],
data: "6AjePwYNteRu",
programIdIndex: 11,
stackHeight: 2,
},
],
},
],
logMessages: [
"Program wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb invoke [1]",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]",
"Program log: Instruction: MintTo",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4492 of 136305 compute units",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]",
"Program log: Instruction: MintTo",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4589 of 125187 compute units",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success",
"Program wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb consumed 80797 of 200000 compute units",
"Program wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb success",
],
status: {
Ok: null,
},
},
slot: 234015120,
transaction: {
message: {
header: {
numReadonlySignedAccounts: 0,
numReadonlyUnsignedAccounts: 10,
numRequiredSignatures: 1,
},
accountKeys: [
"7dm9am6Qx7cH64RB99Mzf7ZsLbEfmXM7ihXXCvMiT2X1",
"4RrFMkY3A5zWdizT61Px222qmSTJqnVszDeBZZNSoAH6",
"7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs",
"HkpTbh5td45g3SfFsKmjukX2YZUKfEHZG5HfRrw6Tkyi",
"11111111111111111111111111111111",
"2gQuwC9GMUCVcXw9VffeCswhKbPeyzHH9ZPEnRBw4K9w",
"BCD75RNBHrJJpW4dXVagL5mPjzRLnVZq4YirJdjEYMV7",
"CvYA8s1SnSzQzCv71rjt7Sc9iEVNjz2exRpoucyH2RCE",
"DapiQYH3BGonhN8cngWcXQ6SrqSm3cwysoznoHr6Sbsx",
"DujfLgMKW71CT2W8pxknf42FT86VbcK5PjQ6LsutjWKC",
"SysvarRent111111111111111111111111111111111",
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
"worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth",
"wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb",
],
recentBlockhash: "H9bDwcfav3nq9wvzJi9sEPCm4oHxqS59nZU81mncD6AU",
instructions: [
{
accounts: [0, 8, 7, 3, 9, 1, 1, 2, 5, 6, 10, 4, 11, 12],
data: "4",
programIdIndex: 13,
stackHeight: null,
},
],
indexToProgramIds: {},
compiledInstructions: [
{
programIdIndex: 13,
accountKeyIndexes: [0, 8, 7, 3, 9, 1, 1, 2, 5, 6, 10, 4, 11, 12],
data: new Uint8Array([3]),
},
],
},
signatures: [
"3FySmshUgVCM2N158oNYbeTfZt2typEU32c9ZxdAXiXURFHuTmeJHhc7cSUtqHdwAsbVWWvEsEddWNAKzkjVPSg2",
],
},
version: "legacy",
} as any as solana.Transaction;
const events = await solanaTransferRedeemedMapper(tx, { programId });
expect(events).toHaveLength(1);
expect(events[0].name).toBe("transfer-redeemed");
expect(events[0].address).toBe(programId);
expect(events[0].chainId).toBe(1);
expect(events[0].txHash).toBe(tx.transaction.signatures[0]);
expect(events[0].blockHeight).toBe(BigInt(tx.slot));
expect(events[0].blockTime).toBe(tx.blockTime);
});
it("should map a tx involving token bridge relayer (aka connect) to a transfer-redeemed event", async () => {
const mockGetPostedMessage = getPostedMessage as jest.MockedFunction<typeof getPostedMessage>;
mockGetPostedMessage.mockResolvedValueOnce({
message: {
emitterChain: 4,
sequence: 5185,
emitterAddress: Buffer.from(
"0000000000000000000000009dcf9d205c9de35334d646bee44b2d2859712a09",
"hex"
),
submissionTime: 1700571923,
nonce: 0,
consistencyLevel: 1,
payload: Buffer.from("41QVZTrdrRxb", "base64"),
} as any,
});
const programId = "DZnkkTmCiFWfYTfT41X3Rd1kDgozqzxWaHqsw6W4x2oe";
const tx = {
blockTime: 1701701948,
meta: {
innerInstructions: [
{
index: 0,
instructions: [
{
accounts: [0, 1],
data: "11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL",
programIdIndex: 7,
stackHeight: 2,
},
{
accounts: [1, 4],
data: "6NejZzEkDLeuHiYpvQR3Ck46Sw6FQeXFmX5TGWpBSLgJ1",
programIdIndex: 22,
stackHeight: 2,
},
{
accounts: [0, 15, 8, 5, 16, 1, 9, 1, 4, 12, 20, 21, 7, 11, 22],
data: "B",
programIdIndex: 18,
stackHeight: 2,
},
{
accounts: [0, 5],
data: "11112ncWAFpbecrgZiGiLpaHnEYkj7ECUfBBRHr4H5tFCq9bHFXWRyWUjj586frtFc19oa",
programIdIndex: 7,
stackHeight: 3,
},
{
accounts: [4, 1, 20],
data: "6j1A9VR8zuFm",
programIdIndex: 22,
stackHeight: 3,
},
{
accounts: [1, 2, 9],
data: "3tMLEJ9BQpG7",
programIdIndex: 22,
stackHeight: 2,
},
{
accounts: [1, 6, 9],
data: "3qeniiQUmAqm",
programIdIndex: 22,
stackHeight: 2,
},
{
accounts: [1, 0, 9],
data: "A",
programIdIndex: 22,
stackHeight: 2,
},
],
},
],
loadedAddresses: {
readonly: [],
writable: [],
},
logMessages: [
"Program 3bPRWXqtSfUaCw3S4wdgvypQtsSzcmvDeaqSqPDkncrg invoke [1]",
"Program log: Instruction: CompleteWrappedTransferWithRelay",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]",
"Program log: Instruction: InitializeAccount3",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4214 of 223786 compute units",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success",
"Program DZnkkTmCiFWfYTfT41X3Rd1kDgozqzxWaHqsw6W4x2oe invoke [2]",
"Program log: Instruction: LegacyCompleteTransferWithPayloadWrapped",
"Program 11111111111111111111111111111111 invoke [3]",
"Program 11111111111111111111111111111111 success",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]",
"Program log: Instruction: MintTo",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4492 of 128286 compute units",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success",
"Program DZnkkTmCiFWfYTfT41X3Rd1kDgozqzxWaHqsw6W4x2oe consumed 50570 of 173121 compute units",
"Program DZnkkTmCiFWfYTfT41X3Rd1kDgozqzxWaHqsw6W4x2oe success",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]",
"Program log: Instruction: Transfer",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 119156 compute units",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]",
"Program log: Instruction: Transfer",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 111686 compute units",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]",
"Program log: Instruction: CloseAccount",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3015 of 104235 compute units",
"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success",
"Program 3bPRWXqtSfUaCw3S4wdgvypQtsSzcmvDeaqSqPDkncrg consumed 150503 of 250000 compute units",
"Program 3bPRWXqtSfUaCw3S4wdgvypQtsSzcmvDeaqSqPDkncrg success",
"Program ComputeBudget111111111111111111111111111111 invoke [1]",
"Program ComputeBudget111111111111111111111111111111 success",
],
status: {
Ok: null,
},
},
slot: 262968784,
transaction: {
message: {
header: {
numReadonlySignedAccounts: 0,
numReadonlyUnsignedAccounts: 16,
numRequiredSignatures: 1,
},
accountKeys: [
"hiUN9rS9VTPVGYc71Vf2d6iyFLvsQaSsqWhxydqdaZf",
"14UpGeFGK9iEhVTgMbdd7RHZmBKb8BYxBpAYGtBWigeT",
"3pjtJPtu7Z9NzQinCaaRyUsZPwSrNjGxYi7RMwkigk47",
"8DW7zrpEe9EVxbD8PBfmEKNkCwXPFTVVukHXirrcE9iV",
"BaGfF51MQ3a61papTRDYaNefBgTQ9ywnVne5fCff4bxT",
"bMDMKEYXfWM2h5AyJL4kzBXLr8Wms29NSWeGQGLgEad",
"EGdE1V4GLFyZH5FFDtsxZaRkw84WhwPWJkCV4yT2L6F4",
"11111111111111111111111111111111",
"2EqgRpRxi1MR8QLycFDcMKws1Kv56dQcwSmXKkFJZgnW",
"2X2u43DR3odTT4jQKFqsG5f4SCfbEmz4pAHR2EVd3Xs3",
"3bPRWXqtSfUaCw3S4wdgvypQtsSzcmvDeaqSqPDkncrg",
"3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5",
"5rmBUDWruRcWU4S4JyjPLZhcYJARYw4FeU9brEo4nUzo",
"7pFgBNscwYBfKs1Bsi7wgyibmtNCmK4442pgL4xiJcTr",
"8huuoQHYxWGs3oYoKXJBsBgPousVa9XfkPyHWrpPH1B8",
"8PFZNjn19BBYVHNp4H31bEW7eAmu78Yf2RKV8EeA461K",
"9A7Z7kJw7hPsBJQDSeFU63DsgZGhSTjXmGLYS81yCHBN",
"ComputeBudget111111111111111111111111111111",
"DZnkkTmCiFWfYTfT41X3Rd1kDgozqzxWaHqsw6W4x2oe",
"HeMY4WFgEA8zkbp7HMCma5mswLArPzPe53EkYcXDJTUV",
"rRsXLHe7sBHdyKU3KY3wbcgWvoT1Ntqudf6e9PKusgb",
"SysvarRent111111111111111111111111111111111",
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
],
recentBlockhash: "KVvqcsFkfd4mgWjqjsEATKvciAwCwUgdeVHeRh4LqyT",
instructions: [
{
accounts: [0, 9, 2, 19, 4, 6, 3, 13, 14, 1, 12, 15, 8, 5, 16, 20, 11, 18, 7, 22, 21],
data: "9ewcWKkpwjKSXZao88ZRqjejhWC1Bf6cFzdxHbDN4taHHCL3zM9P3xi",
programIdIndex: 10,
stackHeight: null,
},
{
accounts: [],
data: "HnkkG7",
programIdIndex: 17,
stackHeight: null,
},
],
indexToProgramIds: {},
compiledInstructions: [
{
programIdIndex: 10,
accountKeyIndexes: [
0, 9, 2, 19, 4, 6, 3, 13, 14, 1, 12, 15, 8, 5, 16, 20, 11, 18, 7, 22, 21,
],
data: new Uint8Array([
174, 44, 4, 91, 81, 201, 235, 255, 59, 128, 71, 194, 194, 46, 49, 88, 200, 5, 254,
175, 217, 196, 30, 63, 1, 233, 245, 96, 162, 12, 73, 62, 205, 171, 142, 159, 18, 6,
57, 151,
]),
},
{
programIdIndex: 17,
accountKeyIndexes: [],
data: new Uint8Array([2, 144, 208, 3, 0]),
},
],
},
signatures: [
"5Cu3tD15AtcQ5NGK6PFT9UMVmqh94ARz8FXpFts5G1nNzQnXXaugP3ELa79P9xCwESC5Kw7FtGHUgh7vz8DuP8tM",
],
},
version: "legacy",
} as any as solana.Transaction;
const events = await solanaTransferRedeemedMapper(tx, { programId });
expect(events).toHaveLength(1);
expect(events[0].name).toBe("transfer-redeemed");
expect(events[0].address).toBe(programId);
expect(events[0].chainId).toBe(1);
expect(events[0].txHash).toBe(tx.transaction.signatures[0]);
expect(events[0].blockHeight).toBe(BigInt(tx.slot));
expect(events[0].blockTime).toBe(tx.blockTime);
});
});

View File

@ -37,6 +37,12 @@ var ETHEREUM_MAINNET = WatcherBlockchainAddresses{
Name: MetehodCompleteTransferWithRelay,
},
},
strings.ToLower("0xd8E1465908103eD5fd28e381920575fb09beb264"): {
{
ID: MethodIDReceiveMessageAndSwap,
Name: MethodReceiveMessageAndSwap,
},
},
},
}
@ -77,6 +83,12 @@ var POLYGON_MAINNET = WatcherBlockchainAddresses{
Name: MethodReceiveTbtc,
},
},
strings.ToLower("0xf6C5FD2C8Ecba25420859f61Be0331e68316Ba01"): {
{
ID: MethodIDReceiveMessageAndSwap,
Name: MethodReceiveMessageAndSwap,
},
},
},
}
@ -318,6 +330,12 @@ var ARBITRUM_MAINNET = WatcherBlockchainAddresses{
Name: MethodReceiveTbtc,
},
},
strings.ToLower("0xf8497FE5B0C5373778BFa0a001d476A21e01f09b"): {
{
ID: MethodIDReceiveMessageAndSwap,
Name: MethodReceiveMessageAndSwap,
},
},
},
}
@ -334,6 +352,12 @@ var OPTIMISM_MAINNET = WatcherBlockchainAddresses{
Name: MethodReceiveTbtc,
},
},
strings.ToLower("0xcF205Fa51D33280D9B70321Ae6a3686FB2c178b2"): {
{
ID: MethodIDReceiveMessageAndSwap,
Name: MethodReceiveMessageAndSwap,
},
},
},
}
@ -366,5 +390,11 @@ var BASE_MAINNET = WatcherBlockchainAddresses{
Name: MetehodCompleteTransferWithRelay,
},
},
strings.ToLower("0x9816d7C448f79CdD4aF18c4Ae1726A14299E8C75"): {
{
ID: MethodIDReceiveMessageAndSwap,
Name: MethodReceiveMessageAndSwap,
},
},
},
}

View File

@ -18,6 +18,9 @@ const (
//Method name for wormhole tBTC gateway
MethodReceiveTbtc = "receiveTbtc"
//Method name for Portico contract
MethodReceiveMessageAndSwap = "receiveMessageAndSwap"
//Method ids for wormhole token bridge contract
MethodIDCompleteTransfer = "0xc6878519"
MethodIDWrapAndTransfer = "0x9981509f"
@ -31,6 +34,9 @@ const (
//Method id for wormhole tBTC gateway
MethodIDReceiveTbtc = "0x5d21a596"
//Method id for Portico contract
MethodIDReceiveMessageAndSwap = "0x3d528f35"
)
type WatcherBlockchain struct {

View File

@ -269,8 +269,8 @@ data:
"config": {
"blockBatchSize": 100,
"commitment": "latest",
"interval": 5000,
"addresses": ["0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B"],
"interval": 15000,
"addresses": ["0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B", "0x27428dd2d3dd32a4d7f7c497eaaa23130d894911", "0x3ee18b2214aff97000d974cf647e7c347e8fa585"],
"chain": "ethereum"
}
},

View File

@ -61,6 +61,31 @@ data:
}
}
]
},
{
"id": "poll-transfer-redeemed-solana",
"chain": "solana",
"source": {
"action": "PollSolanaTransactions",
"config": {
"slotBatchSize": 1000,
"commitment": "finalized",
"interval": 5000,
"signaturesLimit": 100,
"programId": "DZnkkTmCiFWfYTfT41X3Rd1kDgozqzxWaHqsw6W4x2oe",
"chain": "solana"
}
},
"handlers": [
{
"action": "HandleSolanaTransactions",
"target": "sns",
"mapper": "solanaTransferRedeemedMapper",
"config": {
"programId": "DZnkkTmCiFWfYTfT41X3Rd1kDgozqzxWaHqsw6W4x2oe"
}
}
]
}
]
mainnet-jobs.json: |-
@ -71,9 +96,9 @@ data:
"source": {
"action": "PollSolanaTransactions",
"config": {
"slotBatchSize": 2000,
"slotBatchSize": 1000,
"commitment": "finalized",
"interval": 3500,
"interval": 1500,
"signaturesLimit": 200,
"programId": "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth",
"chain": "solana"
@ -89,6 +114,31 @@ data:
}
}
]
},
{
"id": "poll-transfer-redeemed-solana",
"chain": "solana",
"source": {
"action": "PollSolanaTransactions",
"config": {
"slotBatchSize": 1000,
"commitment": "finalized",
"interval": 1500,
"signaturesLimit": 200,
"programId": "wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb",
"chain": "solana"
}
},
"handlers": [
{
"action": "HandleSolanaTransactions",
"target": "sns",
"mapper": "solanaTransferRedeemedMapper",
"config": {
"programId": "wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb"
}
}
]
}
]
---